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 "" }