Update application UI and functionality
This commit is contained in:
@@ -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() }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user