Update application UI and functionality

This commit is contained in:
2026-07-26 16:20:36 +08:00
parent b9aff58f32
commit 97ea6fb7aa
48 changed files with 2790 additions and 628 deletions
@@ -61,6 +61,19 @@ type UploadOptions struct {
UpdateManifest bool
}
type UploadedPackageFile struct {
TempPath string
Size int64
SHA256 string
}
var (
ErrUploadedPackageMissing = errors.New("uploaded file is missing")
ErrUploadedPackageEmpty = errors.New("uploaded file is empty")
ErrUploadedPackageStorageFailed = errors.New("upload storage failed")
ErrUploadedPackageManifestFailed = errors.New("manifest update failed")
)
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}}
if len(noticeService) > 0 {
@@ -226,7 +239,7 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
return Package{}, err
}
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
return Package{}, err
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
target := filepath.Join(s.cfg.DownloadsDir, name)
resolved, err := filepath.Abs(target)
@@ -288,6 +301,108 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
return pkg, nil
}
func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageFile, opts UploadOptions, actor string) (Package, error) {
name, err := safePackageName(opts.FileName)
if err != nil {
return Package{}, err
}
if uploaded.TempPath == "" {
return Package{}, ErrUploadedPackageMissing
}
if uploaded.Size <= 0 {
return Package{}, ErrUploadedPackageEmpty
}
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")
}
if err := os.Chmod(uploaded.TempPath, 0o640); err != nil {
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
backup, err := backupExistingPackage(target)
if err != nil {
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
if err := os.Rename(uploaded.TempPath, target); err != nil {
_ = restorePackageBackup(target, backup)
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, 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: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL)) + "/downloads/" + name,
SHA256: strings.ToLower(uploaded.SHA256),
Size: uploaded.Size,
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 {
if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil {
return Package{}, fmt.Errorf("%w: %v; rollback failed: %v", ErrUploadedPackageManifestFailed, err, rollbackErr)
}
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)
}
}
if backup != "" {
_ = os.Remove(backup)
}
_ = s.store.InsertAudit(db.AuditLog{Actor: firstNonEmpty(actor, "admin"), Type: "release.package_uploaded", Target: name, Message: fmt.Sprintf("已上传发布包 %s%s%s", name, version, formatBytes(uploaded.Size))})
return pkg, nil
}
func backupExistingPackage(target string) (string, error) {
if _, err := os.Stat(target); errors.Is(err, os.ErrNotExist) {
return "", nil
} else if err != nil {
return "", err
}
file, err := os.CreateTemp(filepath.Dir(target), "."+filepath.Base(target)+".backup-*")
if err != nil {
return "", err
}
backup := file.Name()
if err := file.Close(); err != nil {
_ = os.Remove(backup)
return "", err
}
if err := os.Remove(backup); err != nil {
return "", err
}
if err := os.Rename(target, backup); err != nil {
return "", err
}
return backup, nil
}
func restorePackageBackup(target, backup string) error {
if err := os.Remove(target); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
if backup == "" {
return nil
}
return os.Rename(backup, target)
}
func (s *Service) updateLegacyManifest(pkg Package, opts UploadOptions) error {
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
payload := s.legacyUpdateBase()
@@ -1,6 +1,7 @@
package releases
import (
"errors"
"net/http/httptest"
"os"
"path/filepath"
@@ -132,3 +133,104 @@ func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
t.Fatal("expected unsafe filename to be rejected")
}
}
func TestSavePreparedPackageReplacesExistingFile(t *testing.T) {
service, cfg, cleanup := newPreparedPackageTestService(t)
defer cleanup()
name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
target := filepath.Join(cfg.DownloadsDir, name)
if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
t.Fatal(err)
}
temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
t.Fatal(err)
}
_, err := service.SavePreparedPackage(
httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
UploadOptions{FileName: name},
"admin")
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(target)
if err != nil {
t.Fatal(err)
}
if string(data) != "new package" {
t.Fatalf("target contains %q, want new package", data)
}
backups, err := filepath.Glob(filepath.Join(cfg.DownloadsDir, "."+name+".backup-*"))
if err != nil {
t.Fatal(err)
}
if len(backups) != 0 {
t.Fatalf("successful replacement left backups: %v", backups)
}
}
func TestSavePreparedPackageRestoresExistingFileWhenManifestFails(t *testing.T) {
service, cfg, cleanup := newPreparedPackageTestService(t)
defer cleanup()
name := "YMhut_Box_WinUI_Setup_2.0.8_x64.exe"
if err := os.MkdirAll(cfg.DownloadsDir, 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(cfg.DownloadsDir, name)
if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
t.Fatal(err)
}
temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
t.Fatal(err)
}
blocked := filepath.Join(cfg.BaseDir, "manifest-blocked")
if err := os.WriteFile(blocked, []byte("not a directory"), 0o640); err != nil {
t.Fatal(err)
}
cfg.UpdatePublicDir = blocked
_, err := service.SavePreparedPackage(
httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
UploadOptions{FileName: name, UpdateManifest: true},
"admin")
if !errors.Is(err, ErrUploadedPackageManifestFailed) {
t.Fatalf("got %v, want manifest failure", err)
}
data, readErr := os.ReadFile(target)
if readErr != nil {
t.Fatal(readErr)
}
if string(data) != "old package" {
t.Fatalf("rollback restored %q, want old package", data)
}
}
func newPreparedPackageTestService(t *testing.T) (*Service, *config.Config, func()) {
t.Helper()
dir := t.TempDir()
cfg := &config.Config{
BaseDir: dir,
StorageDir: filepath.Join(dir, "storage"),
DataDir: filepath.Join(dir, "data"),
UpdatePublicDir: filepath.Join(dir, "data", "update", "public"),
DownloadsDir: filepath.Join(dir, "data", "update", "public", "downloads"),
BaseURL: "https://update.ymhut.cn",
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
store, err := db.Open(cfg)
if err != nil {
t.Fatal(err)
}
return NewService(cfg, store), cfg, func() { _ = store.Close() }
}