更新 unified management 服务逻辑
build-winui / winui (push) Has been cancelled

This commit is contained in:
QWQLwToo
2026-06-26 13:57:58 +08:00
parent 79bdc34664
commit df6e9ab9e9
10 changed files with 459 additions and 13 deletions
@@ -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 ""
}