Files
YMhut-box-C-/server/unified-management/internal/releases/releases.go
T

614 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package releases
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"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
}
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")
)
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 {
service.notices = noticeService[0]
}
return service
}
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["packages"] = packages
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)
}
}
if len(packages) > 0 {
latest := packages[0]
payload["app_version"] = latest.Version
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 (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 {
entries, err := os.ReadDir(s.cfg.DownloadsDir)
if err != nil {
return []Package{}
}
base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL))
items := []Package{}
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
lower := strings.ToLower(name)
if !(strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".appinstaller") || strings.HasSuffix(lower, ".msi")) {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
version := detectVersion(name)
platform, arch := detectPlatform(name)
product := detectProduct(name)
url := base + "/downloads/" + name
items = append(items, Package{
ID: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
Name: product,
Version: version,
Platform: platform,
Arch: arch,
URL: url,
SHA256: s.cachedSHA256(filepath.Join(s.cfg.DownloadsDir, name), info),
Size: info.Size(),
Required: strings.Contains(strings.ToLower(product), "ymhut"),
Enabled: true,
FileName: name,
UpdatedAt: info.ModTime().UTC().Format(time.RFC3339),
})
}
sort.Slice(items, func(i, j int) bool {
return compareVersion(items[i].Version, items[j].Version) > 0
})
return items
}
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 := sha256File(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)
}
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: firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), 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) 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()
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 := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
if r.Host != "" {
return scheme + "://" + r.Host
}
}
return strings.TrimRight(fallback, "/")
}
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 "", errors.New("invalid filename")
}
name = filepath.Base(original)
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 ""
}