This commit is contained in:
@@ -4,6 +4,8 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -40,6 +42,16 @@ type Package struct {
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type UploadOptions struct {
|
||||
FileName string
|
||||
Version string
|
||||
Platform string
|
||||
Arch string
|
||||
Channel string
|
||||
Notes string
|
||||
UpdateManifest bool
|
||||
}
|
||||
|
||||
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||||
service := &Service{cfg: cfg, store: store}
|
||||
if len(noticeService) > 0 {
|
||||
@@ -162,6 +174,103 @@ func (s *Service) StaticJSON(name string) map[string]any {
|
||||
return readJSON(filepath.Join(s.cfg.UpdatePublicDir, name))
|
||||
}
|
||||
|
||||
func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts UploadOptions, actor string) (Package, error) {
|
||||
name, err := safePackageName(opts.FileName)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
target := filepath.Join(s.cfg.DownloadsDir, name)
|
||||
resolved, err := filepath.Abs(target)
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
base, _ := filepath.Abs(s.cfg.DownloadsDir)
|
||||
if resolved != base && !strings.HasPrefix(resolved, base+string(os.PathSeparator)) {
|
||||
return Package{}, errors.New("path escape rejected")
|
||||
}
|
||||
tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload")
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
defer os.Remove(tmpName)
|
||||
hash := sha256.New()
|
||||
written, err := io.Copy(tmp, io.TeeReader(reader, hash))
|
||||
if closeErr := tmp.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if written <= 0 {
|
||||
return Package{}, errors.New("uploaded file is empty")
|
||||
}
|
||||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
if err := os.Rename(tmpName, target); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
version := firstNonEmpty(opts.Version, detectVersion(name))
|
||||
platform, arch := detectPlatform(name)
|
||||
platform = firstNonEmpty(opts.Platform, platform)
|
||||
arch = firstNonEmpty(opts.Arch, arch)
|
||||
product := detectProduct(name)
|
||||
pkg := Package{
|
||||
ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
|
||||
Name: product,
|
||||
Version: version,
|
||||
Platform: platform,
|
||||
Arch: arch,
|
||||
URL: requestBaseURL(r, s.cfg.BaseURL) + "/downloads/" + name,
|
||||
SHA256: hex.EncodeToString(hash.Sum(nil)),
|
||||
Size: written,
|
||||
Required: strings.Contains(strings.ToLower(product), "ymhut"),
|
||||
Enabled: true,
|
||||
FileName: name,
|
||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
if opts.UpdateManifest {
|
||||
if err := s.updateLegacyManifest(pkg, opts); err != nil {
|
||||
return Package{}, err
|
||||
}
|
||||
}
|
||||
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s(%s,%s)", name, version, formatBytes(written))})
|
||||
return pkg, nil
|
||||
}
|
||||
|
||||
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
|
||||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||||
payload := readJSON(path)
|
||||
payload["app_version"] = pkg.Version
|
||||
payload["download_url"] = pkg.URL
|
||||
payload["package_sha256"] = pkg.SHA256
|
||||
payload["package_size"] = pkg.Size
|
||||
payload["updated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
payload["download_mirrors"] = []map[string]any{{
|
||||
"id": "primary",
|
||||
"name": "官方直连",
|
||||
"url": pkg.URL,
|
||||
"type": "direct",
|
||||
"sha256": pkg.SHA256,
|
||||
"enabled": true,
|
||||
}}
|
||||
if strings.TrimSpace(opts.Notes) != "" {
|
||||
payload["release_notes"] = opts.Notes
|
||||
}
|
||||
data, err := json.MarshalIndent(payload, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, append(data, '\n'), 0o640)
|
||||
}
|
||||
|
||||
func readJSON(path string) map[string]any {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
@@ -257,3 +366,40 @@ func sha256File(path string) string {
|
||||
}
|
||||
return hex.EncodeToString(hash.Sum(nil))
|
||||
}
|
||||
|
||||
func safePackageName(name string) (string, error) {
|
||||
name = strings.TrimSpace(filepath.Base(name))
|
||||
if name == "" || name == "." || name == ".." || strings.ContainsAny(name, `/\`) {
|
||||
return "", errors.New("invalid filename")
|
||||
}
|
||||
lower := strings.ToLower(name)
|
||||
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
|
||||
if strings.HasSuffix(lower, suffix) {
|
||||
return name, nil
|
||||
}
|
||||
}
|
||||
return "", errors.New("unsupported package extension")
|
||||
}
|
||||
|
||||
func formatBytes(value int64) string {
|
||||
if value < 1024 {
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
next := float64(value) / 1024
|
||||
for _, unit := range []string{"KB", "MB", "GB"} {
|
||||
if next < 1024 || unit == "GB" {
|
||||
return fmt.Sprintf("%.1f %s", next, unit)
|
||||
}
|
||||
next /= 1024
|
||||
}
|
||||
return fmt.Sprintf("%d B", value)
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user