877 lines
24 KiB
Go
877 lines
24 KiB
Go
package releases
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"ymhut-box/server/unified-management/internal/config"
|
||
"ymhut-box/server/unified-management/internal/db"
|
||
"ymhut-box/server/unified-management/internal/notices"
|
||
)
|
||
|
||
type Service struct {
|
||
cfg *config.Config
|
||
store *db.Store
|
||
notices *notices.Service
|
||
hashMu sync.Mutex
|
||
hashes map[string]cachedFileHash
|
||
hashFile func(string) string
|
||
packageMu sync.RWMutex
|
||
packages []Package
|
||
reconcileMu sync.Mutex
|
||
callbackMu sync.RWMutex
|
||
onChange func()
|
||
stop chan struct{}
|
||
stopOnce sync.Once
|
||
}
|
||
|
||
type cachedFileHash struct {
|
||
size int64
|
||
modifiedAt int64
|
||
sha256 string
|
||
}
|
||
|
||
type Package struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Version string `json:"version"`
|
||
Platform string `json:"platform"`
|
||
Arch string `json:"arch"`
|
||
URL string `json:"url"`
|
||
SHA256 string `json:"sha256"`
|
||
Size int64 `json:"size"`
|
||
Required bool `json:"required"`
|
||
Enabled bool `json:"enabled"`
|
||
FileName string `json:"fileName"`
|
||
UpdatedAt string `json:"updatedAt"`
|
||
}
|
||
|
||
type UploadOptions struct {
|
||
FileName string
|
||
Version string
|
||
Platform string
|
||
Arch string
|
||
Channel string
|
||
Notes string
|
||
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")
|
||
ErrUploadedPackageIndexFailed = errors.New("package index update failed")
|
||
ErrUnsupportedPackage = errors.New("unsupported package extension")
|
||
ErrUnsafePackageName = errors.New("unsafe package file name")
|
||
)
|
||
|
||
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||
return newService(cfg, store, sha256File, noticeService...)
|
||
}
|
||
|
||
func newService(cfg *config.Config, store *db.Store, hashFile func(string) string, noticeService ...*notices.Service) *Service {
|
||
service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}, hashFile: hashFile, stop: make(chan struct{})}
|
||
if len(noticeService) > 0 {
|
||
service.notices = noticeService[0]
|
||
}
|
||
_ = service.Reconcile(context.Background())
|
||
return service
|
||
}
|
||
|
||
func (s *Service) Start(ctx context.Context) {
|
||
go func() {
|
||
ticker := time.NewTicker(time.Minute)
|
||
defer ticker.Stop()
|
||
for {
|
||
select {
|
||
case <-ticker.C:
|
||
_ = s.Reconcile(context.Background())
|
||
case <-ctx.Done():
|
||
return
|
||
case <-s.stop:
|
||
return
|
||
}
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (s *Service) Stop() {
|
||
s.stopOnce.Do(func() { close(s.stop) })
|
||
}
|
||
|
||
func (s *Service) SetChangeCallback(callback func()) {
|
||
s.callbackMu.Lock()
|
||
s.onChange = callback
|
||
s.callbackMu.Unlock()
|
||
}
|
||
|
||
func (s *Service) notifyChanged() {
|
||
s.callbackMu.RLock()
|
||
callback := s.onChange
|
||
s.callbackMu.RUnlock()
|
||
if callback != nil {
|
||
callback()
|
||
}
|
||
}
|
||
|
||
func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
|
||
payload := s.legacyUpdateBase()
|
||
manifest := s.Manifest(r)
|
||
for _, key := range []string{"app_version", "download_url", "download_mirrors", "detected_product", "detected_packages", "packages", "modules", "manifest_version", "release_notes", "release_notes_md", "message", "message_md", "notices", "latest_notice"} {
|
||
if value, ok := manifest[key]; ok {
|
||
payload[key] = value
|
||
}
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func (s *Service) Manifest(r *http.Request) map[string]any {
|
||
payload := s.legacyUpdateBase()
|
||
packages := s.ScanPackages(r)
|
||
modules := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "modules.json"))["modules"]
|
||
if modules == nil {
|
||
modules = []any{}
|
||
}
|
||
payload["manifest_version"] = 2
|
||
payload["service_version"] = config.Version
|
||
payload["modules"] = modules
|
||
payload["assets"] = []any{}
|
||
payload["generated_at"] = time.Now().UTC().Format(time.RFC3339)
|
||
if s.notices != nil {
|
||
if items, err := s.notices.List(50); err == nil && len(items) > 0 {
|
||
publicNotices := notices.PublicList(items)
|
||
payload["notices"] = publicNotices
|
||
payload["latest_notice"] = publicNotices[0]
|
||
latestNotice := items[0]
|
||
setIfMissing(payload, "app_version", latestNotice.Version)
|
||
setIfMissing(payload, "title", latestNotice.Title)
|
||
setIfMissing(payload, "message", latestNotice.Message)
|
||
setIfMissing(payload, "message_md", latestNotice.MessageMD)
|
||
setIfMissing(payload, "release_notes", latestNotice.ReleaseNotes)
|
||
setIfMissing(payload, "release_notes_md", latestNotice.ReleaseNotesMD)
|
||
setIfMissing(payload, "download_url", latestNotice.DownloadURL)
|
||
}
|
||
}
|
||
preferredVersion := manifestString(payload, "app_version")
|
||
packages = prioritizePackages(packages, preferredVersion)
|
||
payload["packages"] = packages
|
||
if len(packages) > 0 {
|
||
latest := packages[0]
|
||
if preferredVersion == "" {
|
||
preferredVersion = latest.Version
|
||
payload["app_version"] = preferredVersion
|
||
}
|
||
if sameVersion(latest.Version, preferredVersion) {
|
||
payload["download_url"] = latest.URL
|
||
payload["download_mirrors"] = []map[string]any{{
|
||
"id": "primary",
|
||
"name": "官方直连",
|
||
"url": latest.URL,
|
||
"type": "direct",
|
||
"sha256": latest.SHA256,
|
||
"enabled": true,
|
||
}}
|
||
payload["detected_product"] = latest.Name
|
||
}
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func manifestString(payload map[string]any, key string) string {
|
||
value, _ := payload[key].(string)
|
||
return strings.TrimSpace(value)
|
||
}
|
||
|
||
func prioritizePackages(items []Package, preferredVersion string) []Package {
|
||
if strings.TrimSpace(preferredVersion) == "" {
|
||
return items
|
||
}
|
||
sort.SliceStable(items, func(i, j int) bool {
|
||
left := sameVersion(items[i].Version, preferredVersion)
|
||
right := sameVersion(items[j].Version, preferredVersion)
|
||
return left && !right
|
||
})
|
||
return items
|
||
}
|
||
|
||
func sameVersion(a, b string) bool {
|
||
a = strings.TrimSpace(a)
|
||
b = strings.TrimSpace(b)
|
||
if a == "" || b == "" {
|
||
return false
|
||
}
|
||
if a == b {
|
||
return true
|
||
}
|
||
return versionPattern.FindString(a) == a && versionPattern.FindString(b) == b && compareVersion(a, b) == 0
|
||
}
|
||
|
||
func (s *Service) PublishLegacyUpdateInfo(r *http.Request, actor string) error {
|
||
payload := s.LegacyUpdateInfo(r)
|
||
data, err := json.MarshalIndent(payload, "", " ")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
path := filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")
|
||
if err := atomicWrite(path, append(data, '\n')); err != nil {
|
||
return err
|
||
}
|
||
_, _ = s.store.SaveLegacyRevision("update-info", string(append(data, '\n')), "generated from release database", firstNonEmpty(actor, "system"))
|
||
return nil
|
||
}
|
||
|
||
func setIfMissing(payload map[string]any, key, value string) {
|
||
if strings.TrimSpace(value) == "" {
|
||
return
|
||
}
|
||
if existing, ok := payload[key].(string); !ok || strings.TrimSpace(existing) == "" {
|
||
payload[key] = value
|
||
}
|
||
}
|
||
|
||
func (s *Service) ScanPackages(r *http.Request) []Package {
|
||
base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL))
|
||
s.packageMu.RLock()
|
||
items := append([]Package(nil), s.packages...)
|
||
s.packageMu.RUnlock()
|
||
for index := range items {
|
||
items[index].URL = base + "/downloads/" + items[index].FileName
|
||
}
|
||
return items
|
||
}
|
||
|
||
// Reconcile keeps request-time manifest generation independent from package I/O.
|
||
// Hashes are reused from the persistent index whenever size and mtime still match.
|
||
func (s *Service) Reconcile(ctx context.Context) error {
|
||
s.reconcileMu.Lock()
|
||
defer s.reconcileMu.Unlock()
|
||
if err := os.MkdirAll(s.cfg.DownloadsDir, 0o750); err != nil {
|
||
return err
|
||
}
|
||
indexed := map[string]db.ReleasePackage{}
|
||
var firstErr error
|
||
if s.store != nil {
|
||
rows, err := s.store.ListReleasePackages()
|
||
if err != nil {
|
||
firstErr = err
|
||
} else {
|
||
for _, item := range rows {
|
||
indexed[item.FileName] = item
|
||
}
|
||
}
|
||
}
|
||
entries, err := os.ReadDir(s.cfg.DownloadsDir)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
items := make([]Package, 0, len(entries))
|
||
seen := map[string]bool{}
|
||
for _, entry := range entries {
|
||
if err := ctx.Err(); err != nil {
|
||
return err
|
||
}
|
||
if entry.IsDir() || !isSupportedPackageName(entry.Name()) {
|
||
continue
|
||
}
|
||
info, err := entry.Info()
|
||
if err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
continue
|
||
}
|
||
name := entry.Name()
|
||
seen[name] = true
|
||
modifiedAt := info.ModTime().UTC().Format(time.RFC3339Nano)
|
||
record, cached := indexed[name]
|
||
var item Package
|
||
if cached && record.SizeBytes == info.Size() && record.UpdatedAt == modifiedAt && strings.TrimSpace(record.SHA256) != "" {
|
||
item = packageFromRecord(record)
|
||
s.hashMu.Lock()
|
||
s.hashes[filepath.Join(s.cfg.DownloadsDir, name)] = cachedFileHash{size: info.Size(), modifiedAt: info.ModTime().UnixNano(), sha256: record.SHA256}
|
||
s.hashMu.Unlock()
|
||
} else {
|
||
version := detectVersion(name)
|
||
platform, arch := detectPlatform(name)
|
||
product := detectProduct(name)
|
||
hashValue := s.cachedSHA256(filepath.Join(s.cfg.DownloadsDir, name), info)
|
||
if strings.TrimSpace(hashValue) == "" {
|
||
if firstErr == nil {
|
||
firstErr = fmt.Errorf("hash package %q: empty SHA-256", name)
|
||
}
|
||
continue
|
||
}
|
||
item = Package{
|
||
ID: packageID(product, platform, arch, version),
|
||
Name: product,
|
||
Version: version,
|
||
Platform: platform,
|
||
Arch: arch,
|
||
SHA256: hashValue,
|
||
Size: info.Size(),
|
||
Required: strings.Contains(strings.ToLower(product), "ymhut"),
|
||
Enabled: true,
|
||
FileName: name,
|
||
UpdatedAt: modifiedAt,
|
||
}
|
||
if s.store != nil {
|
||
if _, err := s.store.UpsertReleasePackage(releasePackageRecord(item)); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if s.store != nil {
|
||
for name := range indexed {
|
||
if !seen[name] {
|
||
if err := s.store.DeleteReleasePackage(name); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if firstErr != nil {
|
||
return firstErr
|
||
}
|
||
sort.Slice(items, func(i, j int) bool { return compareVersion(items[i].Version, items[j].Version) > 0 })
|
||
s.packageMu.Lock()
|
||
changed := !reflect.DeepEqual(s.packages, items)
|
||
s.packages = items
|
||
s.packageMu.Unlock()
|
||
if changed {
|
||
s.notifyChanged()
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
func packageFromRecord(item db.ReleasePackage) Package {
|
||
return Package{
|
||
ID: packageID(item.Product, item.Platform, item.Arch, item.Version), Name: item.Product, Version: item.Version,
|
||
Platform: item.Platform, Arch: item.Arch, URL: item.URL, SHA256: item.SHA256, Size: item.SizeBytes,
|
||
Required: strings.Contains(strings.ToLower(item.Product), "ymhut"), Enabled: item.Enabled,
|
||
FileName: item.FileName, UpdatedAt: item.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func releasePackageRecord(item Package) db.ReleasePackage {
|
||
return db.ReleasePackage{
|
||
Product: item.Name, Version: item.Version, Platform: item.Platform, Arch: item.Arch, FileName: item.FileName,
|
||
URL: item.URL, SHA256: item.SHA256, SizeBytes: item.Size, Enabled: item.Enabled, UpdatedAt: item.UpdatedAt,
|
||
}
|
||
}
|
||
|
||
func packageID(product, platform, arch, version string) string {
|
||
return strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-"))
|
||
}
|
||
|
||
func isSupportedPackageName(name string) bool {
|
||
lower := strings.ToLower(name)
|
||
for _, suffix := range []string{".exe", ".msix", ".appinstaller", ".msi", ".zip", ".7z"} {
|
||
if strings.HasSuffix(lower, suffix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func (s *Service) cachedSHA256(path string, info os.FileInfo) string {
|
||
modifiedAt := info.ModTime().UnixNano()
|
||
s.hashMu.Lock()
|
||
if cached, ok := s.hashes[path]; ok && cached.size == info.Size() && cached.modifiedAt == modifiedAt {
|
||
s.hashMu.Unlock()
|
||
return cached.sha256
|
||
}
|
||
s.hashMu.Unlock()
|
||
|
||
value := s.hashFile(path)
|
||
s.hashMu.Lock()
|
||
s.hashes[path] = cachedFileHash{size: info.Size(), modifiedAt: modifiedAt, sha256: value}
|
||
for cachedPath := range s.hashes {
|
||
if cachedPath != path {
|
||
if _, err := os.Stat(cachedPath); errors.Is(err, os.ErrNotExist) {
|
||
delete(s.hashes, cachedPath)
|
||
}
|
||
}
|
||
}
|
||
s.hashMu.Unlock()
|
||
return value
|
||
}
|
||
|
||
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{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||
}
|
||
tmp, err := os.CreateTemp(s.cfg.DownloadsDir, "."+name+".*.upload")
|
||
if err != nil {
|
||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||
}
|
||
tmpName := tmp.Name()
|
||
defer os.Remove(tmpName)
|
||
hash := sha256.New()
|
||
written, err := io.Copy(io.MultiWriter(tmp, hash), reader)
|
||
if err == nil {
|
||
err = tmp.Sync()
|
||
}
|
||
if closeErr := tmp.Close(); err == nil {
|
||
err = closeErr
|
||
}
|
||
if err != nil {
|
||
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
|
||
}
|
||
if written <= 0 {
|
||
return Package{}, ErrUploadedPackageEmpty
|
||
}
|
||
opts.FileName = name
|
||
return s.SavePreparedPackage(r, UploadedPackageFile{TempPath: tmpName, Size: written, SHA256: hex.EncodeToString(hash.Sum(nil))}, opts, actor)
|
||
}
|
||
|
||
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{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, 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)
|
||
}
|
||
rollbackFile := func(cause error) error {
|
||
if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil {
|
||
return fmt.Errorf("%w; rollback failed: %v", cause, rollbackErr)
|
||
}
|
||
return cause
|
||
}
|
||
info, err := os.Stat(target)
|
||
if err != nil {
|
||
return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err))
|
||
}
|
||
if info.Size() != uploaded.Size {
|
||
return Package{}, rollbackFile(fmt.Errorf("%w: uploaded size changed during commit", ErrUploadedPackageStorageFailed))
|
||
}
|
||
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: packageID(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: info.ModTime().UTC().Format(time.RFC3339Nano),
|
||
}
|
||
var previousManifest fileSnapshot
|
||
if opts.UpdateManifest {
|
||
previousManifest, err = captureFile(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||
if err != nil {
|
||
return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err))
|
||
}
|
||
if err := s.updateLegacyManifest(pkg, opts); err != nil {
|
||
return Package{}, rollbackFile(fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err))
|
||
}
|
||
}
|
||
if s.store != nil {
|
||
if _, err := s.store.UpsertReleasePackage(releasePackageRecord(pkg)); err != nil {
|
||
cause := fmt.Errorf("%w: %v", ErrUploadedPackageIndexFailed, err)
|
||
if opts.UpdateManifest {
|
||
if restoreErr := previousManifest.restore(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json")); restoreErr != nil {
|
||
cause = fmt.Errorf("%w; manifest rollback failed: %v", cause, restoreErr)
|
||
}
|
||
}
|
||
return Package{}, rollbackFile(cause)
|
||
}
|
||
}
|
||
if backup != "" {
|
||
_ = os.Remove(backup)
|
||
}
|
||
s.upsertPackageSnapshot(pkg)
|
||
if s.store != nil {
|
||
_ = 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
|
||
}
|
||
|
||
type fileSnapshot struct {
|
||
data []byte
|
||
mode os.FileMode
|
||
existed bool
|
||
}
|
||
|
||
func captureFile(path string) (fileSnapshot, error) {
|
||
info, err := os.Stat(path)
|
||
if errors.Is(err, os.ErrNotExist) {
|
||
return fileSnapshot{}, nil
|
||
}
|
||
if err != nil {
|
||
return fileSnapshot{}, err
|
||
}
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return fileSnapshot{}, err
|
||
}
|
||
return fileSnapshot{data: data, mode: info.Mode(), existed: true}, nil
|
||
}
|
||
|
||
func (s fileSnapshot) restore(path string) error {
|
||
if !s.existed {
|
||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||
return err
|
||
}
|
||
return nil
|
||
}
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||
return err
|
||
}
|
||
mode := s.mode
|
||
if mode == 0 {
|
||
mode = 0o640
|
||
}
|
||
return os.WriteFile(path, s.data, mode)
|
||
}
|
||
|
||
func (s *Service) upsertPackageSnapshot(item Package) {
|
||
s.packageMu.Lock()
|
||
replaced := false
|
||
for index := range s.packages {
|
||
if s.packages[index].FileName == item.FileName {
|
||
s.packages[index] = item
|
||
replaced = true
|
||
break
|
||
}
|
||
}
|
||
if !replaced {
|
||
s.packages = append(s.packages, item)
|
||
}
|
||
sort.Slice(s.packages, func(i, j int) bool { return compareVersion(s.packages[i].Version, s.packages[j].Version) > 0 })
|
||
s.packageMu.Unlock()
|
||
s.notifyChanged()
|
||
}
|
||
|
||
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()
|
||
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
|
||
}
|
||
return atomicWrite(path, append(data, '\n'))
|
||
}
|
||
|
||
func (s *Service) legacyUpdateBase() map[string]any {
|
||
payload := map[string]any{}
|
||
for _, path := range []string{
|
||
filepath.Join(s.cfg.LegacyUpdateDir, "public", "update-info.json"),
|
||
filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"),
|
||
} {
|
||
for key, value := range readJSON(path) {
|
||
payload[key] = value
|
||
}
|
||
}
|
||
if payload["app_version"] == nil {
|
||
if value, ok := payload["appVersion"]; ok {
|
||
payload["app_version"] = value
|
||
} else if value, ok := payload["latestVersion"]; ok {
|
||
payload["app_version"] = value
|
||
}
|
||
}
|
||
if payload["manifest_version"] == nil {
|
||
if value, ok := payload["manifestVersion"]; ok {
|
||
payload["manifest_version"] = value
|
||
}
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func readJSON(path string) map[string]any {
|
||
data, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return map[string]any{}
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return map[string]any{}
|
||
}
|
||
return payload
|
||
}
|
||
|
||
func atomicWrite(path string, data []byte) error {
|
||
dir := filepath.Dir(path)
|
||
if err := os.MkdirAll(dir, 0o750); err != nil {
|
||
return err
|
||
}
|
||
tmp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".*.tmp")
|
||
if err != nil {
|
||
return err
|
||
}
|
||
tmpName := tmp.Name()
|
||
defer os.Remove(tmpName)
|
||
if _, err := tmp.Write(data); err != nil {
|
||
_ = tmp.Close()
|
||
return err
|
||
}
|
||
if err := tmp.Close(); err != nil {
|
||
return err
|
||
}
|
||
if err := os.Chmod(tmpName, 0o640); err != nil {
|
||
return err
|
||
}
|
||
return os.Rename(tmpName, path)
|
||
}
|
||
|
||
func requestBaseURL(r *http.Request, fallback string) string {
|
||
if r != nil {
|
||
scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto"))
|
||
if scheme != "http" && scheme != "https" {
|
||
if r.TLS != nil {
|
||
scheme = "https"
|
||
} else {
|
||
scheme = "http"
|
||
}
|
||
}
|
||
host := firstForwardedHeader(r.Header.Get("X-Forwarded-Host"))
|
||
if !validForwardedHost(host) {
|
||
host = strings.TrimSpace(r.Host)
|
||
}
|
||
if validForwardedHost(host) {
|
||
return scheme + "://" + host
|
||
}
|
||
}
|
||
return strings.TrimRight(fallback, "/")
|
||
}
|
||
|
||
func firstForwardedHeader(value string) string {
|
||
return strings.ToLower(strings.TrimSpace(strings.Split(value, ",")[0]))
|
||
}
|
||
|
||
func validForwardedHost(value string) bool {
|
||
if value == "" || strings.TrimSpace(value) != value || strings.ContainsAny(value, "\\\\\r\n\t ") {
|
||
return false
|
||
}
|
||
parsed, err := url.Parse("http://" + value)
|
||
if err != nil || parsed.Host != value || parsed.User != nil || parsed.Hostname() == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
|
||
return false
|
||
}
|
||
if port := parsed.Port(); port != "" {
|
||
value, err := strconv.Atoi(port)
|
||
if err != nil || value < 1 || value > 65535 {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
var versionPattern = regexp.MustCompile(`\d+\.\d+\.\d+(?:\.\d+)?`)
|
||
|
||
func detectVersion(name string) string {
|
||
match := versionPattern.FindString(name)
|
||
if match == "" {
|
||
return "0.0.0"
|
||
}
|
||
return match
|
||
}
|
||
|
||
func detectPlatform(name string) (string, string) {
|
||
lower := strings.ToLower(name)
|
||
platform := "windows"
|
||
if strings.Contains(lower, "appinstaller") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msi") {
|
||
platform = "windows"
|
||
}
|
||
arch := "x64"
|
||
if strings.Contains(lower, "arm64") {
|
||
arch = "arm64"
|
||
} else if strings.Contains(lower, "x86") && !strings.Contains(lower, "x64") {
|
||
arch = "x86"
|
||
}
|
||
return platform, arch
|
||
}
|
||
|
||
func detectProduct(name string) string {
|
||
if strings.Contains(strings.ToLower(name), "ymhut") {
|
||
return "YMhut Box"
|
||
}
|
||
return "YMhut Package"
|
||
}
|
||
|
||
func compareVersion(a, b string) int {
|
||
as := strings.Split(a, ".")
|
||
bs := strings.Split(b, ".")
|
||
for len(as) < 4 {
|
||
as = append(as, "0")
|
||
}
|
||
for len(bs) < 4 {
|
||
bs = append(bs, "0")
|
||
}
|
||
for i := 0; i < 4; i++ {
|
||
ai, _ := strconv.Atoi(as[i])
|
||
bi, _ := strconv.Atoi(bs[i])
|
||
if ai > bi {
|
||
return 1
|
||
}
|
||
if ai < bi {
|
||
return -1
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func sha256File(path string) string {
|
||
file, err := os.Open(path)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
defer file.Close()
|
||
hash := sha256.New()
|
||
if _, err := io.Copy(hash, file); err != nil {
|
||
return ""
|
||
}
|
||
return hex.EncodeToString(hash.Sum(nil))
|
||
}
|
||
|
||
func safePackageName(name string) (string, error) {
|
||
original := strings.TrimSpace(name)
|
||
if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) {
|
||
return "", ErrUnsafePackageName
|
||
}
|
||
name = filepath.Base(original)
|
||
if isSupportedPackageName(name) {
|
||
return name, nil
|
||
}
|
||
return "", ErrUnsupportedPackage
|
||
}
|
||
|
||
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 ""
|
||
}
|