260 lines
6.8 KiB
Go
260 lines
6.8 KiB
Go
package releases
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"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"`
|
|
}
|
|
|
|
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 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))
|
|
}
|