406 lines
11 KiB
Go
406 lines
11 KiB
Go
package releases
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"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
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func NewService(cfg *config.Config, store *db.Store, noticeService ...*notices.Service) *Service {
|
||
service := &Service{cfg: cfg, store: store}
|
||
if len(noticeService) > 0 {
|
||
service.notices = noticeService[0]
|
||
}
|
||
return service
|
||
}
|
||
|
||
func (s *Service) LegacyUpdateInfo(r *http.Request) map[string]any {
|
||
payload := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||
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 := readJSON(filepath.Join(s.cfg.UpdatePublicDir, "update-info.json"))
|
||
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 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 := 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: sha256File(filepath.Join(s.cfg.DownloadsDir, name)),
|
||
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) 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 {
|
||
return map[string]any{}
|
||
}
|
||
var payload map[string]any
|
||
if err := json.Unmarshal(data, &payload); err != nil {
|
||
return map[string]any{}
|
||
}
|
||
return payload
|
||
}
|
||
|
||
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) {
|
||
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 ""
|
||
}
|