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 ""
|
||||
}
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package releases
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"ymhut-box/server/unified-management/internal/config"
|
||||
"ymhut-box/server/unified-management/internal/db"
|
||||
)
|
||||
|
||||
func TestCompareVersion(t *testing.T) {
|
||||
cases := []struct {
|
||||
@@ -29,3 +38,68 @@ func TestDetectPackageMetadata(t *testing.T) {
|
||||
t.Fatalf("detectVersion returned %q", version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
|
||||
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"),
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
service := NewService(cfg, store)
|
||||
req := httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil)
|
||||
pkg, err := service.SaveUploadedPackage(req, strings.NewReader("package bytes"), UploadOptions{
|
||||
FileName: "YMhut_Box_WinUI_Setup_2.0.6.31.exe",
|
||||
UpdateManifest: true,
|
||||
}, "admin")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if pkg.Version != "2.0.6.31" || pkg.SHA256 == "" || pkg.Size == 0 {
|
||||
t.Fatalf("unexpected package metadata: %#v", pkg)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.DownloadsDir, pkg.FileName)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
manifest := readJSON(filepath.Join(cfg.UpdatePublicDir, "update-info.json"))
|
||||
if manifest["download_url"] != pkg.URL || manifest["package_sha256"] != pkg.SHA256 {
|
||||
t.Fatalf("manifest not updated: %#v", manifest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveUploadedPackageRejectsUnsafeName(t *testing.T) {
|
||||
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"),
|
||||
Database: config.DatabaseConfig{
|
||||
Provider: "sqlite",
|
||||
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
|
||||
},
|
||||
}
|
||||
store, err := db.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer store.Close()
|
||||
service := NewService(cfg, store)
|
||||
_, err = service.SaveUploadedPackage(httptest.NewRequest("POST", "/", nil), strings.NewReader("x"), UploadOptions{FileName: "../evil.exe"}, "admin")
|
||||
if err == nil {
|
||||
t.Fatal("expected unsafe filename to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user