feat: complete 2.0.7.12 platform overhaul

This commit is contained in:
2026-08-16 19:33:03 +08:00
parent 73555cd04c
commit c9fa6f7a88
159 changed files with 13243 additions and 2539 deletions
@@ -0,0 +1,205 @@
package adminassets
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
webassets "ymhut-box/server/unified-management/web"
)
const (
ModeEmbedded = "embedded"
ModeDisk = "disk"
)
type Diagnostics struct {
Mode string `json:"mode"`
Source string `json:"source"`
BuildID string `json:"buildId,omitempty"`
CompiledBuildID string `json:"compiledBuildId,omitempty"`
ManifestStatus string `json:"manifestStatus"`
ManifestEntries int `json:"manifestEntries"`
Ready bool `json:"ready"`
ValidationError string `json:"validationError,omitempty"`
}
type Service struct {
mode string
diskRoot string
embedRoot string
diagnostics Diagnostics
}
type manifestEntry struct {
File string `json:"file"`
CSS []string `json:"css"`
Assets []string `json:"assets"`
Imports []string `json:"imports"`
DynamicImports []string `json:"dynamicImports"`
}
type buildMetadata struct {
BuildID string `json:"buildId"`
}
type fileReader func(string) ([]byte, error)
func New(mode, diskRoot, compiledBuildID string) *Service {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode != ModeDisk && mode != ModeEmbedded {
mode = DefaultMode()
}
service := &Service{
mode: mode,
diskRoot: diskRoot,
embedRoot: "admin/dist",
}
service.diagnostics = service.validate(compiledBuildID)
return service
}
func DefaultMode() string {
if webassets.Embedded {
return ModeEmbedded
}
return ModeDisk
}
func (s *Service) Mode() string {
return s.mode
}
func (s *Service) Diagnostics() Diagnostics {
return s.diagnostics
}
func (s *Service) ReadFile(name string) ([]byte, error) {
name, err := cleanAssetPath(name)
if err != nil {
return nil, err
}
if !s.diagnostics.Ready {
return nil, errors.New("admin asset source failed validation")
}
if s.mode == ModeEmbedded {
return webassets.ReadFile(s.embedRoot + "/" + name)
}
return os.ReadFile(filepath.Join(s.diskRoot, filepath.FromSlash(name)))
}
func ValidateDisk(root, compiledBuildID string) Diagnostics {
return validateSource(ModeDisk, root, compiledBuildID, func(name string) ([]byte, error) {
return os.ReadFile(filepath.Join(root, filepath.FromSlash(name)))
})
}
func ValidateEmbedded(compiledBuildID string) Diagnostics {
const root = "admin/dist"
return validateSource(ModeEmbedded, root, compiledBuildID, func(name string) ([]byte, error) {
return webassets.ReadFile(root + "/" + name)
})
}
func (s *Service) validate(compiledBuildID string) Diagnostics {
if s.mode == ModeEmbedded {
if !webassets.Embedded {
return Diagnostics{
Mode: s.mode, Source: s.embedRoot, CompiledBuildID: compiledBuildID,
ManifestStatus: "unavailable", ValidationError: "binary was built without embed_web",
}
}
return ValidateEmbedded(compiledBuildID)
}
return ValidateDisk(s.diskRoot, compiledBuildID)
}
func validateSource(mode, source, compiledBuildID string, read fileReader) Diagnostics {
result := Diagnostics{
Mode: mode, Source: source, CompiledBuildID: strings.TrimSpace(compiledBuildID),
ManifestStatus: "invalid",
}
fail := func(err error) Diagnostics {
result.ValidationError = err.Error()
return result
}
if _, err := read("index.html"); err != nil {
return fail(fmt.Errorf("read index.html: %w", err))
}
metadataBytes, err := read("admin-build.json")
if err != nil {
return fail(fmt.Errorf("read admin-build.json: %w", err))
}
var metadata buildMetadata
if err := json.Unmarshal(metadataBytes, &metadata); err != nil {
return fail(fmt.Errorf("parse admin-build.json: %w", err))
}
result.BuildID = strings.TrimSpace(metadata.BuildID)
if result.BuildID == "" {
return fail(errors.New("admin-build.json has an empty buildId"))
}
if result.CompiledBuildID != "" && result.CompiledBuildID != "dev" && result.BuildID != result.CompiledBuildID {
return fail(fmt.Errorf("admin build ID %q does not match binary build ID %q", result.BuildID, result.CompiledBuildID))
}
manifestBytes, err := read("asset-manifest.json")
if err != nil {
return fail(fmt.Errorf("read asset-manifest.json: %w", err))
}
manifest := map[string]manifestEntry{}
if err := json.Unmarshal(manifestBytes, &manifest); err != nil {
return fail(fmt.Errorf("parse asset-manifest.json: %w", err))
}
if len(manifest) == 0 {
return fail(errors.New("asset-manifest.json is empty"))
}
for key, entry := range manifest {
if strings.TrimSpace(entry.File) == "" {
return fail(fmt.Errorf("manifest entry %q has no output file", key))
}
for _, dependencyKey := range append(append([]string{}, entry.Imports...), entry.DynamicImports...) {
if _, ok := manifest[dependencyKey]; !ok {
return fail(fmt.Errorf("manifest entry %q references missing entry %q", key, dependencyKey))
}
}
files := append([]string{entry.File}, entry.CSS...)
files = append(files, entry.Assets...)
for _, name := range files {
name, cleanErr := cleanAssetPath(name)
if cleanErr != nil {
return fail(fmt.Errorf("manifest entry %q: %w", key, cleanErr))
}
data, readErr := read(name)
if readErr != nil {
return fail(fmt.Errorf("manifest entry %q is missing %s: %w", key, name, readErr))
}
if len(data) == 0 {
return fail(fmt.Errorf("manifest entry %q references empty file %s", key, name))
}
}
}
result.ManifestEntries = len(manifest)
result.ManifestStatus = "valid"
result.Ready = true
return result
}
func cleanAssetPath(name string) (string, error) {
name = filepath.ToSlash(strings.TrimSpace(name))
name = strings.TrimPrefix(name, "./")
if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "\\") {
return "", fmt.Errorf("invalid admin asset path %q", name)
}
cleaned := filepath.ToSlash(filepath.Clean(name))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
return "", fmt.Errorf("invalid admin asset path %q", name)
}
return cleaned, nil
}
func IsMissing(err error) bool {
return errors.Is(err, fs.ErrNotExist)
}
@@ -0,0 +1,15 @@
//go:build embed_web
package adminassets
import "testing"
func TestEmbeddedProductionBuildIsComplete(t *testing.T) {
status := New(ModeEmbedded, "", "dev").Diagnostics()
if !status.Ready {
t.Fatalf("embedded admin build is incomplete: %#v", status)
}
if status.ManifestEntries == 0 {
t.Fatalf("embedded admin manifest is empty: %#v", status)
}
}
@@ -0,0 +1,63 @@
package adminassets
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestValidateDiskChecksDynamicImportsAndEveryOutput(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
writeAssetTestFile(t, root, "index.html", `<script type="module" src="/admin/assets/index-12345678.js"></script>`)
writeAssetTestFile(t, root, "admin-build.json", `{"buildId":"build-1"}`)
writeAssetTestFile(t, root, "asset-manifest.json", `{
"src/main.ts":{"file":"assets/index-12345678.js","dynamicImports":["src/views/FeedbacksView.vue"]},
"src/views/FeedbacksView.vue":{"file":"assets/FeedbacksView-12345678.js","css":["assets/FeedbacksView-12345678.css"]}
}`)
writeAssetTestFile(t, root, "assets/index-12345678.js", "import('./FeedbacksView-12345678.js')")
writeAssetTestFile(t, root, "assets/FeedbacksView-12345678.js", "export default {}")
writeAssetTestFile(t, root, "assets/FeedbacksView-12345678.css", ".page{}")
status := ValidateDisk(root, "build-1")
if !status.Ready || status.ManifestEntries != 2 {
t.Fatalf("valid build rejected: %#v", status)
}
if err := os.Remove(filepath.Join(root, "assets", "FeedbacksView-12345678.js")); err != nil {
t.Fatal(err)
}
status = ValidateDisk(root, "build-1")
if status.Ready || !strings.Contains(status.ValidationError, "FeedbacksView-12345678.js") {
t.Fatalf("missing lazy chunk not diagnosed: %#v", status)
}
}
func TestValidateDiskRejectsBuildIDMismatch(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "assets"), 0o755); err != nil {
t.Fatal(err)
}
writeAssetTestFile(t, root, "index.html", "<main></main>")
writeAssetTestFile(t, root, "admin-build.json", `{"buildId":"old"}`)
writeAssetTestFile(t, root, "asset-manifest.json", `{"src/main.ts":{"file":"assets/index-12345678.js"}}`)
writeAssetTestFile(t, root, "assets/index-12345678.js", "export{}")
status := ValidateDisk(root, "new")
if status.Ready || !strings.Contains(status.ValidationError, "does not match") {
t.Fatalf("build mismatch not rejected: %#v", status)
}
}
func writeAssetTestFile(t *testing.T, root, name, value string) {
t.Helper()
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(value), 0o644); err != nil {
t.Fatal(err)
}
}
@@ -77,20 +77,16 @@ func NewService(store *db.Store) *Service {
}
}
func (s *Service) Bootstrap(ctx context.Context) (map[string]any, error) {
func (s *Service) Bootstrap(ctx context.Context, includeSecurityState bool) (map[string]any, error) {
payload := map[string]any{"ok": true}
if !includeSecurityState {
return payload, nil
}
isDefault, err := s.store.IsDefaultAdminPassword(ctx)
if err != nil {
return nil, err
}
payload := map[string]any{
"ok": true,
"defaultUsername": "admin",
"defaultPassword": "",
"isDefaultPassword": isDefault,
}
if isDefault {
payload["defaultPassword"] = "admin"
}
payload["isDefaultPassword"] = isDefault
return payload, nil
}
@@ -12,7 +12,7 @@ import (
"ymhut-box/server/unified-management/internal/db"
)
func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
func TestBootstrapOnlyReturnsDefaultPasswordStateToAuthenticatedCaller(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{
StorageDir: root,
@@ -32,22 +32,29 @@ func TestBootstrapShowsDefaultPasswordOnlyBeforeChange(t *testing.T) {
t.Fatal(err)
}
service := NewService(store)
payload, err := service.Bootstrap(context.Background())
publicPayload, err := service.Bootstrap(context.Background(), false)
if err != nil {
t.Fatal(err)
}
if payload["isDefaultPassword"] != true || payload["defaultPassword"] != "admin" {
t.Fatalf("unexpected bootstrap payload: %#v", payload)
if _, exists := publicPayload["isDefaultPassword"]; exists || publicPayload["defaultPassword"] != nil || publicPayload["defaultUsername"] != nil {
t.Fatalf("public bootstrap leaked administrator state: %#v", publicPayload)
}
payload, err := service.Bootstrap(context.Background(), true)
if err != nil {
t.Fatal(err)
}
if payload["isDefaultPassword"] != true || payload["defaultPassword"] != nil || payload["defaultUsername"] != nil {
t.Fatalf("unexpected authenticated bootstrap payload: %#v", payload)
}
if err := store.ChangeAdminPassword(context.Background(), "admin", "admin", "changed-password"); err != nil {
t.Fatal(err)
}
payload, err = service.Bootstrap(context.Background())
payload, err = service.Bootstrap(context.Background(), true)
if err != nil {
t.Fatal(err)
}
if payload["isDefaultPassword"] != false || payload["defaultPassword"] != "" {
t.Fatalf("default password leaked after change: %#v", payload)
if payload["isDefaultPassword"] != false || payload["defaultPassword"] != nil || payload["defaultUsername"] != nil {
t.Fatalf("default password fields leaked after change: %#v", payload)
}
}
@@ -11,7 +11,10 @@ import (
const DefaultListen = ":33550"
var Version = "0.1.0"
var (
Version = "0.1.0"
AdminBuildID = "dev"
)
type Config struct {
BaseDir string `json:"base_dir"`
@@ -26,6 +29,7 @@ type Config struct {
UpdateNoticeDir string `json:"update_notice_dir"`
DownloadsDir string `json:"downloads_dir"`
AdminWebDir string `json:"admin_web_dir"`
AdminAssetMode string `json:"admin_asset_mode"`
PortalWebDir string `json:"portal_web_dir"`
SetupWebDir string `json:"setup_web_dir"`
LegacyUpdateDir string `json:"legacy_update_dir"`
@@ -140,6 +144,7 @@ func defaults(root string) *Config {
UpdateNoticeDir: filepath.Join(root, "data", "update-notice"),
DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"),
AdminWebDir: filepath.Join(root, "web", "admin", "dist"),
AdminAssetMode: defaultAdminAssetMode(),
PortalWebDir: filepath.Join(root, "web", "portal", "dist"),
SetupWebDir: filepath.Join(root, "web", "setup", "dist"),
LegacyUpdateDir: filepath.Clean(filepath.Join(root, "..", "update")),
@@ -225,6 +230,9 @@ func applyEnv(cfg *Config) {
if value := os.Getenv("YMHUT_DOWNLOADS_DIR"); value != "" {
cfg.DownloadsDir = value
}
if value := os.Getenv("YMHUT_ADMIN_ASSET_MODE"); value != "" {
cfg.AdminAssetMode = value
}
if value := os.Getenv("YMHUT_LEGACY_UPDATE_DIR"); value != "" {
cfg.LegacyUpdateDir = value
}
@@ -386,6 +394,10 @@ func normalize(root string, cfg *Config) {
cfg.AdminWebDir = filepath.Join(cfg.BaseDir, "web", "admin", "dist")
}
cfg.AdminWebDir = absPath(cfg.BaseDir, cfg.AdminWebDir)
cfg.AdminAssetMode = strings.ToLower(strings.TrimSpace(cfg.AdminAssetMode))
if cfg.AdminAssetMode != "embedded" && cfg.AdminAssetMode != "disk" {
cfg.AdminAssetMode = defaultAdminAssetMode()
}
if cfg.PortalWebDir == "" {
cfg.PortalWebDir = filepath.Join(cfg.BaseDir, "web", "portal", "dist")
}
@@ -111,9 +111,27 @@ func TestReleaseUploadLimitCanBeConfiguredFromEnvironment(t *testing.T) {
}
}
func TestAdminAssetModeCanBeConfiguredFromEnvironment(t *testing.T) {
t.Setenv("YMHUT_ADMIN_ASSET_MODE", "embedded")
cfg := defaults(t.TempDir())
applyEnv(cfg)
normalize(cfg.BaseDir, cfg)
if cfg.AdminAssetMode != "embedded" {
t.Fatalf("AdminAssetMode = %q, want embedded", cfg.AdminAssetMode)
}
t.Setenv("YMHUT_ADMIN_ASSET_MODE", "invalid")
applyEnv(cfg)
normalize(cfg.BaseDir, cfg)
if cfg.AdminAssetMode != defaultAdminAssetMode() {
t.Fatalf("invalid AdminAssetMode normalized to %q, want %q", cfg.AdminAssetMode, defaultAdminAssetMode())
}
}
func TestPreflightReportsMissingAdminAssetName(t *testing.T) {
root := t.TempDir()
cfg := defaults(root)
cfg.AdminAssetMode = "disk"
if err := os.MkdirAll(filepath.Join(cfg.AdminWebDir, "assets"), 0o755); err != nil {
t.Fatal(err)
}
@@ -123,6 +141,12 @@ func TestPreflightReportsMissingAdminAssetName(t *testing.T) {
0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfg.AdminWebDir, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(cfg.AdminWebDir, "asset-manifest.json"), []byte(`{"src/main.ts":{"file":"assets/missing.js","isEntry":true}}`), 0o644); err != nil {
t.Fatal(err)
}
checks := Preflight(cfg)
admin := checks[0]
@@ -4,9 +4,9 @@ import (
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"ymhut-box/server/unified-management/internal/adminassets"
webassets "ymhut-box/server/unified-management/web"
)
@@ -46,45 +46,37 @@ func Preflight(cfg *Config) []Check {
checkSeedFile("legacy update-info", filepath.Join(cfg.UpdatePublicDir, "update-info.json"), []byte(defaultUpdateInfoJSON)),
checkSeedFile("legacy media-types", filepath.Join(cfg.UpdatePublicDir, "media-types.json"), []byte(defaultMediaTypesJSON)),
checkNoticeIndex("version notice index", filepath.Join(cfg.UpdateNoticeDir, "total.json")),
checkAdminWebBuild("admin web dist", cfg.AdminWebDir, "admin/dist"),
checkAdminWebBuild("admin web dist", cfg),
checkWebBuild("portal web dist", cfg.PortalWebDir, "portal/dist"),
checkWebBuild("setup web dist", cfg.SetupWebDir, "setup/dist"),
}
return checks
}
var adminPreflightAssetPattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
func checkAdminWebBuild(name string, cfg *Config) Check {
var status adminassets.Diagnostics
path := cfg.AdminWebDir
if cfg.AdminAssetMode == adminassets.ModeEmbedded {
status = adminassets.ValidateEmbedded(AdminBuildID)
path = "embedded:admin/dist"
} else {
status = adminassets.ValidateDisk(cfg.AdminWebDir, AdminBuildID)
}
if !status.Ready {
message := status.ValidationError
if cfg.AdminAssetMode == adminassets.ModeDisk {
message = strings.ReplaceAll(message, cfg.AdminWebDir, ".")
}
return Check{Name: name, Status: "error", Path: path, Message: message}
}
return Check{
Name: name, Status: "ok", Path: path,
Message: fmt.Sprintf("%s assets; build %s; %d manifest entries", status.Mode, status.BuildID, status.ManifestEntries),
}
}
func checkAdminWebBuild(name, path, embedRoot string) Check {
check := checkWebBuild(name, path, embedRoot)
if check.Status != "ok" || strings.Contains(check.Message, "embedded frontend assets") {
return check
}
index := filepath.Join(path, "index.html")
data, err := os.ReadFile(index)
if err != nil {
return check
}
matches := adminPreflightAssetPattern.FindAllSubmatch(data, -1)
if len(matches) == 0 {
return Check{Name: name, Status: "error", Path: index, Message: "index.html does not reference any /admin/assets files"}
}
for _, match := range matches {
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
return Check{Name: name, Status: "error", Path: index, Message: fmt.Sprintf("invalid admin asset reference %s", assetPath)}
}
info, statErr := os.Stat(filepath.Join(path, filepath.FromSlash(assetPath)))
if statErr == nil && !info.IsDir() {
continue
}
message := fmt.Sprintf("disk asset %s is missing", assetPath)
if embeddedWebBuildOK(embedRoot) {
return Check{Name: name, Status: "ok", Path: path, Message: message + "; using embedded frontend assets"}
}
return Check{Name: name, Status: "error", Path: filepath.Join(path, filepath.FromSlash(assetPath)), Message: message}
}
return check
func defaultAdminAssetMode() string {
return adminassets.DefaultMode()
}
func checkDir(name, path string, create bool) Check {
@@ -0,0 +1,80 @@
package db
import (
"path/filepath"
"testing"
"time"
"ymhut-box/server/unified-management/internal/config"
)
func TestFeedbackSummaryPageOmitsHeavyDetailFields(t *testing.T) {
store := openAdminPerformanceStore(t)
defer store.Close()
item := Feedback{Code: "FB-SUMMARY-1", Title: "Summary", Body: "large private body", SummaryText: "short", Status: "new"}
if err := store.InsertFeedback(item); err != nil {
t.Fatal(err)
}
items, total, err := store.ListFeedbackSummariesFiltered(1, 20, FeedbackFilters{})
if err != nil || total != 1 || len(items) != 1 {
t.Fatalf("summary list total=%d items=%d err=%v", total, len(items), err)
}
if items[0].Body != "" || items[0].SummaryText != "short" {
t.Fatalf("summary list loaded heavy fields: %#v", items[0])
}
detail, err := store.GetFeedback("FB-SUMMARY-1")
if err != nil || detail.Body != "large private body" {
t.Fatalf("detail body=%q err=%v", detail.Body, err)
}
}
func TestSystemLogsUseFilteredDatabasePagination(t *testing.T) {
store := openAdminPerformanceStore(t)
defer store.Close()
if err := store.InsertAudit(AuditLog{Actor: "admin", Type: "release.saved", Target: "2.0", Message: "published"}); err != nil {
t.Fatal(err)
}
if err := store.RecordSourceCall(SourceCall{SourceID: "music", Status: "ok", LatencyMS: 30, Client: "test"}); err != nil {
t.Fatal(err)
}
page, err := store.ListSystemLogsPage(SystemLogFilters{Page: 1, PerPage: 1})
if err != nil || page.Total < 2 || len(page.Items) != 1 {
t.Fatalf("system log page=%#v err=%v", page, err)
}
filtered, err := store.ListSystemLogsPage(SystemLogFilters{Page: 1, PerPage: 10, Category: "client", Query: "music"})
if err != nil || filtered.Total != 1 || filtered.Items[0].Category != "client" {
t.Fatalf("filtered system logs=%#v err=%v", filtered, err)
}
}
func TestDashboardWindowIncludesSummaryAndGeneratedMetadata(t *testing.T) {
store := openAdminPerformanceStore(t)
defer store.Close()
source, err := store.UpsertSource(Source{CategoryID: "network", CategoryName: "网络", SourceID: "status", Name: "状态", APIURL: "https://example.com", Enabled: true, EnabledSet: true, ClientVisible: true, ClientVisibleSet: true})
if err != nil {
t.Fatal(err)
}
if err := store.RecordSourceCheck(source.ID, "ok", 42, ""); err != nil {
t.Fatal(err)
}
overview, err := store.DashboardOverviewWindow(60, time.Now().UTC().Add(-time.Hour).Format(time.RFC3339))
if err != nil {
t.Fatal(err)
}
if overview["generatedAt"] == "" || len(overview["sourceRows"].([]map[string]any)) != 1 {
t.Fatalf("dashboard metadata missing: %#v", overview)
}
}
func openAdminPerformanceStore(t *testing.T) *Store {
t.Helper()
root := t.TempDir()
store, err := Open(&config.Config{
StorageDir: root,
Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "admin-performance.sqlite"), HealthIntervalSec: 3600, MaxOpenConns: 2, MaxIdleConns: 1},
})
if err != nil {
t.Fatal(err)
}
return store
}
@@ -8,21 +8,62 @@ import (
)
func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
return s.DashboardOverviewWindow(limit, "")
}
func (s *Store) DashboardOverviewWindow(limit int, since string) (map[string]any, error) {
if limit <= 0 || limit > 200 {
limit = 80
}
feedbackTotal, _ := s.countTable("feedback_tickets")
feedbackToday, _ := s.countWhere("feedback_tickets", "created_at LIKE ?", time.Now().UTC().Format("2006-01-02")+"%")
sourceTotal, _ := s.countTable("source_endpoints")
sourceVisible, _ := s.countWhere("source_endpoints", "enabled = 1 AND client_visible = 1")
releaseTotal, _ := s.countTable("release_notices")
mailFailed, _ := s.countWhere("mail_records", "status = ?", "failed")
statusCounts, _ := s.groupCounts("feedback_tickets", "status")
healthCounts, _ := s.groupCounts("source_endpoints", "last_status")
recentChecks, _ := s.RecentSourceChecks(limit)
recentCalls, _ := s.RecentSourceCalls(limit)
averageLatency, _ := s.AverageSourceLatencyBuckets(limit)
audit, _ := s.ListAuditLogs(10)
var feedbackTotal, feedbackToday, sourceTotal, sourceVisible, releaseTotal, mailFailed int
today := time.Now().UTC().Format("2006-01-02") + "T00:00:00Z"
if err := s.queryRow(`SELECT
(SELECT COUNT(*) FROM feedback_tickets),
(SELECT COUNT(*) FROM feedback_tickets WHERE created_at >= ?),
(SELECT COUNT(*) FROM source_endpoints),
(SELECT COUNT(*) FROM source_endpoints WHERE enabled = 1 AND client_visible = 1),
(SELECT COUNT(*) FROM release_notices),
(SELECT COUNT(*) FROM mail_records WHERE status = 'failed')`, today).Scan(
&feedbackTotal, &feedbackToday, &sourceTotal, &sourceVisible, &releaseTotal, &mailFailed,
); err != nil {
return nil, err
}
warnings := []string{}
statusCounts, err := s.groupCounts("feedback_tickets", "status")
if err != nil {
warnings = append(warnings, "feedback status unavailable: "+err.Error())
statusCounts = map[string]int{}
}
healthCounts, err := s.groupCounts("source_endpoints", "last_status")
if err != nil {
warnings = append(warnings, "source health unavailable: "+err.Error())
healthCounts = map[string]int{}
}
recentChecks, err := s.RecentSourceChecksWindow(limit, since)
if err != nil {
warnings = append(warnings, "source checks unavailable: "+err.Error())
recentChecks = []map[string]any{}
}
recentCalls, err := s.RecentSourceCallsWindow(limit, since)
if err != nil {
warnings = append(warnings, "client calls unavailable: "+err.Error())
recentCalls = []map[string]any{}
}
averageLatency, err := s.AverageSourceLatencyBucketsWindow(limit, since)
if err != nil {
warnings = append(warnings, "latency trend unavailable: "+err.Error())
averageLatency = []map[string]any{}
}
audit, err := s.ListAuditLogs(10)
if err != nil {
warnings = append(warnings, "audit summary unavailable: "+err.Error())
audit = []AuditLog{}
}
sourceRows, err := s.DashboardSourceRows()
if err != nil {
warnings = append(warnings, "source summary unavailable: "+err.Error())
sourceRows = []map[string]any{}
}
return map[string]any{
"ok": true,
"kpis": map[string]any{
@@ -40,14 +81,28 @@ func (s *Store) DashboardOverview(limit int) (map[string]any, error) {
"clientCalls": recentCalls,
"database": s.Status(),
"audit": audit,
"sourceRows": sourceRows,
"generatedAt": time.Now().UTC().Format(time.RFC3339),
"warnings": warnings,
}, nil
}
func (s *Store) AverageSourceLatencyBuckets(limit int) ([]map[string]any, error) {
return s.AverageSourceLatencyBucketsWindow(limit, "")
}
func (s *Store) AverageSourceLatencyBucketsWindow(limit int, since string) ([]map[string]any, error) {
if limit <= 0 || limit > 200 {
limit = 80
}
rows, err := s.query(`SELECT checked_at, latency_ms, status FROM endpoint_health_checks ORDER BY checked_at DESC, id DESC LIMIT ?`, limit*4)
where := ""
args := []any{}
if strings.TrimSpace(since) != "" {
where = " WHERE checked_at >= ?"
args = append(args, since)
}
args = append(args, limit*4)
rows, err := s.query(`SELECT checked_at, latency_ms, status FROM endpoint_health_checks`+where+` ORDER BY checked_at DESC, id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
@@ -125,9 +180,20 @@ func latencyBucketLabel(value string) string {
}
func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
return s.RecentSourceChecksWindow(limit, "")
}
func (s *Store) RecentSourceChecksWindow(limit int, since string) ([]map[string]any, error) {
where := ""
args := []any{}
if strings.TrimSpace(since) != "" {
where = " WHERE h.checked_at >= ?"
args = append(args, since)
}
args = append(args, limit)
rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, limit)
`+where+` ORDER BY h.checked_at DESC, h.id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
@@ -152,7 +218,18 @@ func (s *Store) RecentSourceChecks(limit int) ([]map[string]any, error) {
}
func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY created_at DESC, id DESC LIMIT ?`, limit)
return s.RecentSourceCallsWindow(limit, "")
}
func (s *Store) RecentSourceCallsWindow(limit int, since string) ([]map[string]any, error) {
where := ""
args := []any{}
if strings.TrimSpace(since) != "" {
where = " WHERE created_at >= ?"
args = append(args, since)
}
args = append(args, limit)
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs`+where+` ORDER BY created_at DESC, id DESC LIMIT ?`, args...)
if err != nil {
return nil, err
}
@@ -170,6 +247,30 @@ func (s *Store) RecentSourceCalls(limit int) ([]map[string]any, error) {
return items, rows.Err()
}
func (s *Store) DashboardSourceRows() ([]map[string]any, error) {
rows, err := s.query(`SELECT source_id, category_id, category_name, name, enabled, client_visible,
last_status, last_latency_ms, last_checked_at, last_error, consecutive_failure
FROM source_endpoints ORDER BY category_id ASC, name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var sourceID, categoryID, categoryName, name, status, checkedAt, lastError string
var enabled, visible, latency, failures int
if err := rows.Scan(&sourceID, &categoryID, &categoryName, &name, &enabled, &visible, &status, &latency, &checkedAt, &lastError, &failures); err != nil {
return nil, err
}
items = append(items, map[string]any{
"sourceId": sourceID, "categoryId": categoryID, "categoryName": categoryName, "name": name,
"enabled": enabled == 1, "clientVisible": visible == 1, "status": status,
"latencyMs": latency, "checkedAt": checkedAt, "healthError": lastError, "consecutiveFailure": failures,
})
}
return items, rows.Err()
}
func (s *Store) InsertAudit(log AuditLog) error {
return s.InsertAuditContext(context.Background(), log)
}
@@ -106,6 +106,14 @@ func (s *Store) ListFeedbacks(limit int) ([]Feedback, error) {
}
func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) {
return s.listFeedbacksFiltered(page, perPage, filters, false)
}
func (s *Store) ListFeedbackSummariesFiltered(page, perPage int, filters FeedbackFilters) ([]Feedback, int, error) {
return s.listFeedbacksFiltered(page, perPage, filters, true)
}
func (s *Store) listFeedbacksFiltered(page, perPage int, filters FeedbackFilters, summaryOnly bool) ([]Feedback, int, error) {
page, perPage = normalizePage(page, perPage)
where, args := feedbackWhere(filters)
var total int
@@ -117,12 +125,21 @@ func (s *Store) ListFeedbacksFiltered(page, perPage int, filters FeedbackFilters
order = ` ORDER BY created_at ASC`
}
args = append(args, perPage, (page-1)*perPage)
rows, err := s.query(feedbackSelectSQL()+where+order+` LIMIT ? OFFSET ?`, args...)
selectSQL := feedbackSelectSQL()
if summaryOnly {
selectSQL = feedbackSummarySelectSQL()
}
rows, err := s.query(selectSQL+where+order+` LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
}
defer rows.Close()
items, err := scanFeedbackRows(rows)
var items []Feedback
if summaryOnly {
items, err = scanFeedbackSummaryRows(rows)
} else {
items, err = scanFeedbackRows(rows)
}
return items, total, err
}
@@ -6,6 +6,87 @@ import (
"strings"
)
func (s *Store) UpsertReleasePackage(item ReleasePackage) (ReleasePackage, error) {
item.FileName = strings.TrimSpace(item.FileName)
if item.FileName == "" {
return ReleasePackage{}, errors.New("file name is required")
}
if item.CreatedAt == "" {
if existing, ok, err := s.GetReleasePackage(item.FileName); err == nil && ok {
item.CreatedAt = existing.CreatedAt
} else if err != nil {
return ReleasePackage{}, err
} else {
item.CreatedAt = Now()
}
}
if item.UpdatedAt == "" {
item.UpdatedAt = Now()
}
columns := []string{"product", "version", "platform", "arch", "file_name", "url", "sha256", "size_bytes", "enabled", "created_at", "updated_at"}
conn, d := s.active()
_, err := conn.Exec(d.rebind(d.upsert("release_packages", columns, []string{"file_name"})),
sanitize(item.Product), sanitize(item.Version), sanitize(item.Platform), sanitize(item.Arch), sanitize(item.FileName),
sanitizeLong(item.URL, 1200), sanitize(item.SHA256), item.SizeBytes, d.boolExpr(item.Enabled), item.CreatedAt, item.UpdatedAt)
if err != nil {
s.markFailover(err)
return ReleasePackage{}, err
}
stored, ok, err := s.GetReleasePackage(item.FileName)
if err != nil {
return ReleasePackage{}, err
}
if !ok {
return ReleasePackage{}, errors.New("release package was not persisted")
}
return stored, nil
}
func (s *Store) GetReleasePackage(fileName string) (ReleasePackage, bool, error) {
var item ReleasePackage
var enabled int
err := s.queryRow(releasePackageSelectSQL()+` WHERE file_name = ?`, strings.TrimSpace(fileName)).Scan(
&item.ID, &item.Product, &item.Version, &item.Platform, &item.Arch, &item.FileName, &item.URL,
&item.SHA256, &item.SizeBytes, &enabled, &item.CreatedAt, &item.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return ReleasePackage{}, false, nil
}
if err != nil {
return ReleasePackage{}, false, err
}
item.Enabled = enabled != 0
return item, true, nil
}
func (s *Store) ListReleasePackages() ([]ReleasePackage, error) {
rows, err := s.query(releasePackageSelectSQL() + ` ORDER BY file_name ASC`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ReleasePackage{}
for rows.Next() {
var item ReleasePackage
var enabled int
if err := rows.Scan(&item.ID, &item.Product, &item.Version, &item.Platform, &item.Arch, &item.FileName, &item.URL,
&item.SHA256, &item.SizeBytes, &enabled, &item.CreatedAt, &item.UpdatedAt); err != nil {
return nil, err
}
item.Enabled = enabled != 0
items = append(items, item)
}
return items, rows.Err()
}
func (s *Store) DeleteReleasePackage(fileName string) error {
_, err := s.exec(`DELETE FROM release_packages WHERE file_name = ?`, strings.TrimSpace(fileName))
return err
}
func releasePackageSelectSQL() string {
return `SELECT id, product, version, platform, arch, file_name, url, sha256, size_bytes, enabled, created_at, updated_at FROM release_packages`
}
func (s *Store) UpsertReleaseNotice(item ReleaseNotice) (ReleaseNotice, error) {
now := Now()
item.Version = strings.TrimSpace(item.Version)
@@ -21,6 +21,11 @@ func feedbackSelectSQL() string {
mail_sent, remote_addr, tags, created_at, updated_at, last_activity_at FROM feedback_tickets`
}
func feedbackSummarySelectSQL() string {
return `SELECT code, title, type, severity, category, priority, status, assignee, source_channel,
risk_score, summary_text, mail_sent, tags, created_at, updated_at, last_activity_at FROM feedback_tickets`
}
func releaseNoticeSelectSQL() string {
return `SELECT id, version, build, channel, title, message, release_notes, message_md, release_notes_md,
download_url, notice_file, raw_json, published_at, created_at, updated_at FROM release_notices`
@@ -65,6 +70,24 @@ func scanFeedbackRows(rows *sql.Rows) ([]Feedback, error) {
return items, rows.Err()
}
func scanFeedbackSummaryRows(rows *sql.Rows) ([]Feedback, error) {
items := []Feedback{}
for rows.Next() {
var item Feedback
var mailSent int
var tags string
if err := rows.Scan(&item.Code, &item.Title, &item.Type, &item.Severity, &item.Category, &item.Priority,
&item.Status, &item.Assignee, &item.SourceChannel, &item.RiskScore, &item.SummaryText, &mailSent,
&tags, &item.CreatedAt, &item.UpdatedAt, &item.LastActivityAt); err != nil {
return nil, err
}
item.MailSent = mailSent == 1
_ = json.Unmarshal([]byte(tags), &item.Tags)
items = append(items, item)
}
return items, rows.Err()
}
func sourceSelectSQL() string {
return `SELECT id, category_id, category_name, source_id, name, description, method, api_url, url_template, thumbnail_url,
proxy_mode, timeout_ms, retry_count, cache_seconds, check_interval_sec, enabled, client_visible, supported_formats,
@@ -5,7 +5,7 @@ import (
"fmt"
)
const CurrentSchemaVersion = "2026-06-compat-baseline"
const CurrentSchemaVersion = "2026-08-admin-performance-indexes"
func (s *Store) migrate(conn *sql.DB, d dialect) error {
statements := []string{}
@@ -296,11 +296,20 @@ type schemaIndex struct {
func schemaIndexes() []schemaIndex {
return []schemaIndex{
{name: "idx_feedback_tickets_activity", table: "feedback_tickets", columns: "last_activity_at"},
{name: "idx_feedback_tickets_status_activity", table: "feedback_tickets", columns: "status, last_activity_at"},
{name: "idx_feedback_tickets_priority_activity", table: "feedback_tickets", columns: "priority, last_activity_at"},
{name: "idx_feedback_tickets_category_activity", table: "feedback_tickets", columns: "category, last_activity_at"},
{name: "idx_feedback_comments_code", table: "feedback_comments", columns: "feedback_code"},
{name: "idx_feedback_attachments_code", table: "feedback_attachments", columns: "feedback_code"},
{name: "idx_feedback_events_code", table: "feedback_events", columns: "feedback_code"},
{name: "idx_mail_records_code", table: "mail_records", columns: "feedback_code"},
{name: "idx_mail_records_status_created", table: "mail_records", columns: "status, created_at"},
{name: "idx_source_endpoints_checked", table: "source_endpoints", columns: "last_checked_at"},
{name: "idx_endpoint_health_checked", table: "endpoint_health_checks", columns: "checked_at, id"},
{name: "idx_endpoint_health_source_checked", table: "endpoint_health_checks", columns: "source_db_id, checked_at"},
{name: "idx_endpoint_call_logs_source", table: "endpoint_call_logs", columns: "source_id"},
{name: "idx_endpoint_call_logs_source_created", table: "endpoint_call_logs", columns: "source_id, created_at"},
{name: "idx_endpoint_call_logs_created", table: "endpoint_call_logs", columns: "created_at, id"},
{name: "idx_audit_logs_created", table: "audit_logs", columns: "created_at"},
{name: "idx_audit_logs_type", table: "audit_logs", columns: "type"},
{name: "idx_audit_logs_target", table: "audit_logs", columns: "target"},
@@ -350,7 +359,7 @@ func (s *Store) recordSchemaVersion(conn *sql.DB, d dialect) error {
_, err := conn.Exec(d.rebind(d.upsert("schema_migrations", columns, []string{"version"})),
CurrentSchemaVersion,
Now(),
"unified-management layered monolith baseline",
"admin performance indexes and production asset diagnostics",
)
return err
}
@@ -1,11 +1,6 @@
package db
import (
"encoding/json"
"fmt"
"sort"
"strings"
)
import "strings"
func (s *Store) ListSystemLogsPage(filters SystemLogFilters) (SystemLogPage, error) {
page := filters.Page
@@ -19,209 +14,71 @@ func (s *Store) ListSystemLogsPage(filters SystemLogFilters) (SystemLogPage, err
if perPage > 100 {
perPage = 100
}
items, err := s.collectSystemLogs(filters)
base := systemLogUnionSQL()
where, args := systemLogWhere(filters)
var total int
if err := s.queryRow(`SELECT COUNT(*) FROM (`+base+`) system_logs`+where, args...).Scan(&total); err != nil {
return SystemLogPage{}, err
}
queryArgs := append(append([]any{}, args...), perPage, (page-1)*perPage)
rows, err := s.query(`SELECT id, category, type, target, status, message, detail, created_at
FROM (`+base+`) system_logs`+where+` ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`, queryArgs...)
if err != nil {
return SystemLogPage{}, err
}
sort.SliceStable(items, func(i, j int) bool {
if items[i].CreatedAt == items[j].CreatedAt {
return items[i].ID > items[j].ID
}
return items[i].CreatedAt > items[j].CreatedAt
})
total := len(items)
start := (page - 1) * perPage
if start > total {
start = total
}
end := start + perPage
if end > total {
end = total
}
return SystemLogPage{Items: items[start:end], Total: total, Page: page, PerPage: perPage}, nil
}
func (s *Store) collectSystemLogs(filters SystemLogFilters) ([]SystemLogItem, error) {
items := []SystemLogItem{}
appendItems := func(category string, next []SystemLogItem, err error) error {
if err != nil {
return err
}
for _, item := range next {
if matchesSystemLog(filters, item, category) {
items = append(items, item)
}
}
return nil
}
logs, err := s.operationLogs()
if err := appendItems("operation", logs, err); err != nil {
return nil, err
}
logs, err = s.healthLogs()
if err := appendItems("health", logs, err); err != nil {
return nil, err
}
logs, err = s.clientCallLogs()
if err := appendItems("client", logs, err); err != nil {
return nil, err
}
logs, err = s.databaseSyncLogs()
if err := appendItems("database_sync", logs, err); err != nil {
return nil, err
}
logs, err = s.legacySyncLogs()
if err := appendItems("legacy_sync", logs, err); err != nil {
return nil, err
}
return items, nil
}
func (s *Store) operationLogs() ([]SystemLogItem, error) {
rows, err := s.query(`SELECT id, actor, type, target, message, ip, user_agent, created_at FROM audit_logs ORDER BY id DESC LIMIT 500`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SystemLogItem{}
for rows.Next() {
var item AuditLog
if err := rows.Scan(&item.ID, &item.Actor, &item.Type, &item.Target, &item.Message, &item.IP, &item.UserAgent, &item.CreatedAt); err != nil {
return nil, err
var item SystemLogItem
if err := rows.Scan(&item.ID, &item.Category, &item.Type, &item.Target, &item.Status, &item.Message, &item.Detail, &item.CreatedAt); err != nil {
return SystemLogPage{}, err
}
items = append(items, SystemLogItem{
ID: item.ID,
Category: "operation",
Type: item.Type,
Target: item.Target,
Status: firstNonEmpty(item.Actor, "system"),
Message: item.Message,
Detail: strings.TrimSpace(item.IP + " " + item.UserAgent),
CreatedAt: item.CreatedAt,
})
items = append(items, item)
}
return items, rows.Err()
if err := rows.Err(); err != nil {
return SystemLogPage{}, err
}
return SystemLogPage{Items: items, Total: total, Page: page, PerPage: perPage}, nil
}
func (s *Store) healthLogs() ([]SystemLogItem, error) {
rows, err := s.query(`SELECT h.id, h.source_db_id, COALESCE(e.source_id, ''), COALESCE(e.name, ''), h.status, h.latency_ms, h.error, h.checked_at
func systemLogUnionSQL() string {
return `SELECT id, 'operation' AS category, type, target,
COALESCE(NULLIF(actor, ''), 'system') AS status, message, user_agent AS detail, created_at
FROM audit_logs
UNION ALL
SELECT h.id, 'health' AS category, 'endpoint.health' AS type,
COALESCE(NULLIF(e.source_id, ''), 'unknown') AS target, h.status,
COALESCE(NULLIF(e.name, ''), 'unknown endpoint') AS message, h.error AS detail, h.checked_at AS created_at
FROM endpoint_health_checks h LEFT JOIN source_endpoints e ON e.id = h.source_db_id
ORDER BY h.id DESC LIMIT 500`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SystemLogItem{}
for rows.Next() {
var id, sourceDBID int64
var sourceID, name, status, message, checkedAt string
var latency int
if err := rows.Scan(&id, &sourceDBID, &sourceID, &name, &status, &latency, &message, &checkedAt); err != nil {
return nil, err
}
target := firstNonEmpty(sourceID, fmt.Sprintf("source#%d", sourceDBID))
items = append(items, SystemLogItem{
ID: id,
Category: "health",
Type: "endpoint.health",
Target: target,
Status: status,
Message: firstNonEmpty(name, target),
Detail: fmt.Sprintf("%dms %s", latency, message),
CreatedAt: checkedAt,
})
}
return items, rows.Err()
UNION ALL
SELECT id, 'client' AS category, 'endpoint.call' AS type, source_id AS target,
status, error AS message, client AS detail, created_at
FROM endpoint_call_logs
UNION ALL
SELECT id, 'database_sync' AS category, direction AS type, direction AS target,
status, message, tables_json AS detail, COALESCE(NULLIF(finished_at, ''), started_at) AS created_at
FROM database_sync_jobs
UNION ALL
SELECT id, 'legacy_sync' AS category, 'legacy.sync' AS type, 'legacy' AS target,
status, summary AS message, stats_json AS detail, COALESCE(NULLIF(finished_at, ''), started_at) AS created_at
FROM legacy_sync_jobs`
}
func (s *Store) clientCallLogs() ([]SystemLogItem, error) {
rows, err := s.query(`SELECT id, source_id, status, latency_ms, error, client, created_at FROM endpoint_call_logs ORDER BY id DESC LIMIT 500`)
if err != nil {
return nil, err
func systemLogWhere(filters SystemLogFilters) (string, []any) {
clauses := []string{}
args := []any{}
if category := strings.TrimSpace(filters.Category); category != "" {
clauses = append(clauses, "category = ?")
args = append(args, sanitize(category))
}
defer rows.Close()
items := []SystemLogItem{}
for rows.Next() {
var id int64
var sourceID, status, message, client, createdAt string
var latency int
if err := rows.Scan(&id, &sourceID, &status, &latency, &message, &client, &createdAt); err != nil {
return nil, err
}
items = append(items, SystemLogItem{
ID: id,
Category: "client",
Type: "endpoint.call",
Target: sourceID,
Status: status,
Message: message,
Detail: fmt.Sprintf("%dms %s", latency, client),
CreatedAt: createdAt,
})
if query := strings.TrimSpace(filters.Query); query != "" {
like := "%" + strings.ToLower(sanitize(query)) + "%"
clauses = append(clauses, `(LOWER(category) LIKE ? OR LOWER(type) LIKE ? OR LOWER(target) LIKE ? OR
LOWER(status) LIKE ? OR LOWER(message) LIKE ? OR LOWER(detail) LIKE ? OR LOWER(created_at) LIKE ?)`)
args = append(args, like, like, like, like, like, like, like)
}
return items, rows.Err()
}
func (s *Store) databaseSyncLogs() ([]SystemLogItem, error) {
rows, err := s.query(`SELECT id, direction, status, message, tables_json, started_at, finished_at FROM database_sync_jobs ORDER BY id DESC LIMIT 500`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SystemLogItem{}
for rows.Next() {
job, err := scanDatabaseSyncJob(rows)
if err != nil {
return nil, err
}
detail, _ := json.Marshal(map[string]any{"tables": job.Tables, "warnings": job.Warnings, "errors": job.Errors})
items = append(items, SystemLogItem{
ID: job.ID,
Category: "database_sync",
Type: job.Direction,
Target: directionLabel(job.Direction),
Status: job.Status,
Message: strings.Join(job.Output, "\n"),
Detail: string(detail),
CreatedAt: firstNonEmpty(job.FinishedAt, job.StartedAt),
})
}
return items, rows.Err()
}
func (s *Store) legacySyncLogs() ([]SystemLogItem, error) {
rows, err := s.query(`SELECT id, status, summary, stats_json, started_at, finished_at FROM legacy_sync_jobs ORDER BY id DESC LIMIT 500`)
if err != nil {
return nil, err
}
defer rows.Close()
items := []SystemLogItem{}
for rows.Next() {
var item LegacySyncJob
if err := rows.Scan(&item.ID, &item.Status, &item.Summary, &item.StatsJSON, &item.StartedAt, &item.FinishedAt); err != nil {
return nil, err
}
items = append(items, SystemLogItem{
ID: item.ID,
Category: "legacy_sync",
Type: "legacy.sync",
Target: "legacy",
Status: item.Status,
Message: item.Summary,
Detail: item.StatsJSON,
CreatedAt: firstNonEmpty(item.FinishedAt, item.StartedAt),
})
}
return items, rows.Err()
}
func matchesSystemLog(filters SystemLogFilters, item SystemLogItem, category string) bool {
if value := strings.TrimSpace(filters.Category); value != "" && value != category && value != item.Category {
return false
}
if value := strings.ToLower(strings.TrimSpace(filters.Query)); value != "" {
haystack := strings.ToLower(strings.Join([]string{item.Category, item.Type, item.Target, item.Status, item.Message, item.Detail, item.CreatedAt}, " "))
return strings.Contains(haystack, value)
}
return true
if len(clauses) == 0 {
return "", args
}
return " WHERE " + strings.Join(clauses, " AND "), args
}
@@ -1,20 +1,55 @@
package health
import (
"sync"
"time"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
)
func Snapshot(cfg *config.Config, store *db.Store) map[string]any {
type Service struct {
cfg *config.Config
store *db.Store
mu sync.RWMutex
checks []config.Check
checkedAt time.Time
}
func NewService(cfg *config.Config, store *db.Store) *Service {
service := &Service{cfg: cfg, store: store}
service.RefreshPreflight()
return service
}
func (s *Service) RefreshPreflight() []config.Check {
checks := config.Preflight(s.cfg)
s.mu.Lock()
s.checks = append([]config.Check(nil), checks...)
s.checkedAt = time.Now().UTC()
s.mu.Unlock()
return checks
}
func (s *Service) Snapshot() map[string]any {
s.mu.RLock()
checks := append([]config.Check(nil), s.checks...)
checkedAt := s.checkedAt
s.mu.RUnlock()
return map[string]any{
"ok": true,
"version": config.Version,
"service": map[string]any{
"name": "YMhut Unified Management",
"baseUrl": cfg.BaseURL,
"cdnBaseUrl": cfg.CDNBaseURL,
"baseUrl": s.cfg.BaseURL,
"cdnBaseUrl": s.cfg.CDNBaseURL,
},
"database": store.Status(),
"preflight": config.Preflight(cfg),
"database": s.store.Status(),
"preflight": checks,
"preflightCheckedAt": checkedAt.Format(time.RFC3339),
}
}
func Snapshot(cfg *config.Config, store *db.Store) map[string]any {
return NewService(cfg, store).Snapshot()
}
@@ -0,0 +1,38 @@
package health
import (
"os"
"path/filepath"
"testing"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
)
func TestSnapshotReadsCachedPreflightUntilManualRefresh(t *testing.T) {
root := t.TempDir()
cfg := &config.Config{
BaseDir: root, StorageDir: filepath.Join(root, "storage"), DataDir: filepath.Join(root, "data"),
UpdatePublicDir: filepath.Join(root, "data", "update", "public"), UpdateNoticeDir: filepath.Join(root, "data", "notices"),
DownloadsDir: filepath.Join(root, "data", "update", "public", "downloads"), AdminWebDir: filepath.Join(root, "admin"),
PortalWebDir: filepath.Join(root, "portal"), SetupWebDir: filepath.Join(root, "setup"), AdminAssetMode: "disk",
Database: config.DatabaseConfig{Provider: "sqlite", SQLitePath: filepath.Join(root, "storage", "health.sqlite"), HealthIntervalSec: 3600},
}
store, err := db.Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
service := NewService(cfg, store)
if err := os.RemoveAll(cfg.DownloadsDir); err != nil {
t.Fatal(err)
}
_ = service.Snapshot()
if _, err := os.Stat(cfg.DownloadsDir); !os.IsNotExist(err) {
t.Fatalf("cached snapshot unexpectedly reran filesystem preflight: %v", err)
}
service.RefreshPreflight()
if _, err := os.Stat(cfg.DownloadsDir); err != nil {
t.Fatalf("manual refresh did not recreate downloads directory: %v", err)
}
}
@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"kind": "cpu",
"version": "2026.08",
"generatedAt": "2026-08-16T00:00:00Z",
"source": {
"name": "YMhut Box hardware reference",
"url": "https://update.ymhut.cn/api/client/reference-data/cpu",
"disclaimer": "Relative scores summarize public multi-core and gaming results. Actual performance depends on power limits, memory, cooling and workload."
},
"checksum": "sha256:eca642fe4223c6fc53cf9fabce94d905cfcecc878d5ebd403fd66e1b06de429c",
"items": [
{ "rank": 1, "vendor": "AMD", "model": "Ryzen 9 9950X3D", "score": 100, "tier": "旗舰", "segment": "desktop" },
{ "rank": 2, "vendor": "AMD", "model": "Ryzen 9 9950X", "score": 96, "tier": "旗舰", "segment": "desktop" },
{ "rank": 3, "vendor": "Intel", "model": "Core Ultra 9 285K", "score": 90, "tier": "旗舰", "segment": "desktop" },
{ "rank": 4, "vendor": "AMD", "model": "Ryzen 9 9900X3D", "score": 86, "tier": "高端", "segment": "desktop" },
{ "rank": 5, "vendor": "AMD", "model": "Ryzen 7 9800X3D", "score": 81, "tier": "高端", "segment": "desktop" },
{ "rank": 6, "vendor": "Intel", "model": "Core i9-14900KS", "score": 79, "tier": "高端", "segment": "desktop" },
{ "rank": 7, "vendor": "AMD", "model": "Ryzen 9 7950X3D", "score": 77, "tier": "高端", "segment": "desktop" },
{ "rank": 8, "vendor": "Intel", "model": "Core i9-14900K", "score": 75, "tier": "高端", "segment": "desktop" },
{ "rank": 9, "vendor": "AMD", "model": "Ryzen 9 9900X", "score": 73, "tier": "高端", "segment": "desktop" },
{ "rank": 10, "vendor": "Intel", "model": "Core Ultra 7 265K", "score": 69, "tier": "中高端", "segment": "desktop" },
{ "rank": 11, "vendor": "AMD", "model": "Ryzen 7 9700X", "score": 64, "tier": "中高端", "segment": "desktop" },
{ "rank": 12, "vendor": "Intel", "model": "Core i7-14700K", "score": 62, "tier": "中高端", "segment": "desktop" },
{ "rank": 13, "vendor": "AMD", "model": "Ryzen 7 7800X3D", "score": 60, "tier": "中高端", "segment": "desktop" },
{ "rank": 14, "vendor": "AMD", "model": "Ryzen AI 9 HX 370", "score": 54, "tier": "移动高端", "segment": "mobile" },
{ "rank": 15, "vendor": "Intel", "model": "Core Ultra 9 185H", "score": 50, "tier": "移动高端", "segment": "mobile" },
{ "rank": 16, "vendor": "AMD", "model": "Ryzen 5 9600X", "score": 47, "tier": "主流", "segment": "desktop" },
{ "rank": 17, "vendor": "Intel", "model": "Core Ultra 5 245K", "score": 46, "tier": "主流", "segment": "desktop" },
{ "rank": 18, "vendor": "Intel", "model": "Core i5-14600K", "score": 44, "tier": "主流", "segment": "desktop" },
{ "rank": 19, "vendor": "AMD", "model": "Ryzen 5 7600", "score": 36, "tier": "主流", "segment": "desktop" },
{ "rank": 20, "vendor": "Intel", "model": "Core Ultra 7 155H", "score": 35, "tier": "移动主流", "segment": "mobile" }
]
}
@@ -0,0 +1,34 @@
{
"schemaVersion": 1,
"kind": "gpu",
"version": "2026.08",
"generatedAt": "2026-08-16T00:00:00Z",
"source": {
"name": "YMhut Box hardware reference",
"url": "https://update.ymhut.cn/api/client/reference-data/gpu",
"disclaimer": "Relative scores summarize public gaming and compute results. Actual performance depends on power, cooling, drivers and workload."
},
"checksum": "sha256:a8258fa438b830446322750cd71435fd8e0ff65102eede3ca006bea6b61f3361",
"items": [
{ "rank": 1, "vendor": "NVIDIA", "model": "GeForce RTX 5090", "score": 100, "tier": "旗舰", "segment": "desktop" },
{ "rank": 2, "vendor": "NVIDIA", "model": "GeForce RTX 4090", "score": 82, "tier": "旗舰", "segment": "desktop" },
{ "rank": 3, "vendor": "NVIDIA", "model": "GeForce RTX 5080", "score": 78, "tier": "旗舰", "segment": "desktop" },
{ "rank": 4, "vendor": "AMD", "model": "Radeon RX 9070 XT", "score": 67, "tier": "高端", "segment": "desktop" },
{ "rank": 5, "vendor": "NVIDIA", "model": "GeForce RTX 5070 Ti", "score": 65, "tier": "高端", "segment": "desktop" },
{ "rank": 6, "vendor": "AMD", "model": "Radeon RX 7900 XTX", "score": 63, "tier": "高端", "segment": "desktop" },
{ "rank": 7, "vendor": "NVIDIA", "model": "GeForce RTX 4080 SUPER", "score": 62, "tier": "高端", "segment": "desktop" },
{ "rank": 8, "vendor": "AMD", "model": "Radeon RX 9070", "score": 57, "tier": "高端", "segment": "desktop" },
{ "rank": 9, "vendor": "NVIDIA", "model": "GeForce RTX 5070", "score": 52, "tier": "中高端", "segment": "desktop" },
{ "rank": 10, "vendor": "AMD", "model": "Radeon RX 7900 XT", "score": 51, "tier": "中高端", "segment": "desktop" },
{ "rank": 11, "vendor": "NVIDIA", "model": "GeForce RTX 4070 Ti SUPER", "score": 49, "tier": "中高端", "segment": "desktop" },
{ "rank": 12, "vendor": "Intel", "model": "Arc B580", "score": 31, "tier": "主流", "segment": "desktop" },
{ "rank": 13, "vendor": "NVIDIA", "model": "GeForce RTX 5060 Ti", "score": 30, "tier": "主流", "segment": "desktop" },
{ "rank": 14, "vendor": "AMD", "model": "Radeon RX 7800 XT", "score": 29, "tier": "主流", "segment": "desktop" },
{ "rank": 15, "vendor": "NVIDIA", "model": "GeForce RTX 4060 Ti", "score": 24, "tier": "主流", "segment": "desktop" },
{ "rank": 16, "vendor": "Intel", "model": "Arc B570", "score": 23, "tier": "主流", "segment": "desktop" },
{ "rank": 17, "vendor": "AMD", "model": "Radeon RX 7700 XT", "score": 22, "tier": "主流", "segment": "desktop" },
{ "rank": 18, "vendor": "NVIDIA", "model": "GeForce RTX 5060 Laptop", "score": 20, "tier": "移动主流", "segment": "mobile" },
{ "rank": 19, "vendor": "AMD", "model": "Radeon RX 7600", "score": 17, "tier": "入门", "segment": "desktop" },
{ "rank": 20, "vendor": "NVIDIA", "model": "GeForce RTX 4060 Laptop", "score": 16, "tier": "移动主流", "segment": "mobile" }
]
}
@@ -0,0 +1,76 @@
package reference
import (
"embed"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
)
//go:embed data/*.json
var bundled embed.FS
var ErrNotFound = errors.New("reference data not found")
type Service struct {
overrideRoot string
}
func NewService(overrideRoot string) *Service {
return &Service{overrideRoot: strings.TrimSpace(overrideRoot)}
}
func (s *Service) Read(kind string) (map[string]any, error) {
name, ok := fileName(kind)
if !ok {
return nil, ErrNotFound
}
var data []byte
var err error
if s.overrideRoot != "" {
data, err = os.ReadFile(filepath.Join(s.overrideRoot, "reference-data", name))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
if len(data) == 0 {
data, err = bundled.ReadFile("data/" + name)
if err != nil {
return nil, err
}
}
var payload map[string]any
if err := json.Unmarshal(data, &payload); err != nil {
return nil, err
}
if payload["schemaVersion"] == nil || payload["items"] == nil {
return nil, errors.New("reference data schema is invalid")
}
return payload, nil
}
func (s *Service) Descriptors() map[string]any {
return map[string]any{
"cpu": map[string]any{
"url": "/api/client/reference-data/cpu",
"bundledFallback": true,
},
"gpu": map[string]any{
"url": "/api/client/reference-data/gpu",
"bundledFallback": true,
},
}
}
func fileName(kind string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(kind)) {
case "cpu", "cpu-ranking":
return "cpu-ranking.json", true
case "gpu", "gpu-ranking":
return "gpu-ranking.json", true
default:
return "", false
}
}
@@ -0,0 +1,46 @@
package reference
import (
"os"
"path/filepath"
"testing"
)
func TestReadUsesBundledSnapshot(t *testing.T) {
service := NewService(t.TempDir())
payload, err := service.Read("gpu")
if err != nil {
t.Fatal(err)
}
if payload["kind"] != "gpu" {
t.Fatalf("kind = %#v", payload["kind"])
}
items, ok := payload["items"].([]any)
if !ok || len(items) < 10 {
t.Fatalf("unexpected items: %#v", payload["items"])
}
}
func TestReadPrefersValidOverride(t *testing.T) {
root := t.TempDir()
dir := filepath.Join(root, "reference-data")
if err := os.MkdirAll(dir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "cpu-ranking.json"), []byte(`{"schemaVersion":1,"kind":"cpu","version":"override","items":[]}`), 0o640); err != nil {
t.Fatal(err)
}
payload, err := NewService(root).Read("cpu")
if err != nil {
t.Fatal(err)
}
if payload["version"] != "override" {
t.Fatalf("version = %#v", payload["version"])
}
}
func TestReadRejectsUnknownKind(t *testing.T) {
if _, err := NewService("").Read("unknown"); err != ErrNotFound {
t.Fatalf("err = %v", err)
}
}
@@ -1,6 +1,7 @@
package releases
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
@@ -8,8 +9,10 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
@@ -23,11 +26,19 @@ import (
)
type Service struct {
cfg *config.Config
store *db.Store
notices *notices.Service
hashMu sync.Mutex
hashes map[string]cachedFileHash
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 {
@@ -72,16 +83,60 @@ var (
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 {
service := &Service{cfg: cfg, store: store, hashes: map[string]cachedFileHash{}}
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)
@@ -102,7 +157,6 @@ func (s *Service) Manifest(r *http.Request) map[string]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)
@@ -121,23 +175,60 @@ func (s *Service) Manifest(r *http.Request) map[string]any {
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]
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
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, "", " ")
@@ -162,48 +253,149 @@ func setIfMissing(payload map[string]any, key, value string) {
}
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 []Package{}
return err
}
base := firstNonEmpty(strings.TrimRight(s.cfg.CDNBaseURL, "/"), requestBaseURL(r, s.cfg.BaseURL))
items := []Package{}
items := make([]Package, 0, len(entries))
seen := map[string]bool{}
for _, entry := range entries {
if entry.IsDir() {
continue
if err := ctx.Err(); err != nil {
return err
}
name := entry.Name()
lower := strings.ToLower(name)
if !(strings.HasSuffix(lower, ".exe") || strings.HasSuffix(lower, ".msix") || strings.HasSuffix(lower, ".appinstaller") || strings.HasSuffix(lower, ".msi")) {
if entry.IsDir() || !isSupportedPackageName(entry.Name()) {
continue
}
info, err := entry.Info()
if err != nil {
if firstErr == nil {
firstErr = err
}
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),
})
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)
}
sort.Slice(items, func(i, j int) bool {
return compareVersion(items[i].Version, items[j].Version) > 0
})
return items
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 {
@@ -215,7 +407,7 @@ func (s *Service) cachedSHA256(path string, info os.FileInfo) string {
}
s.hashMu.Unlock()
value := sha256File(path)
value := s.hashFile(path)
s.hashMu.Lock()
s.hashes[path] = cachedFileHash{size: info.Size(), modifiedAt: modifiedAt, sha256: value}
for cachedPath := range s.hashes {
@@ -241,64 +433,28 @@ func (s *Service) SaveUploadedPackage(r *http.Request, reader io.Reader, opts Up
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
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
hash := sha256.New()
written, err := io.Copy(tmp, io.TeeReader(reader, hash))
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{}, err
return Package{}, fmt.Errorf("%w: %v", ErrUploadedPackageStorageFailed, err)
}
if written <= 0 {
return Package{}, errors.New("uploaded file is empty")
return Package{}, ErrUploadedPackageEmpty
}
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
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) {
@@ -335,13 +491,26 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF
_ = 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: strings.ToLower(strings.ReplaceAll(product+"-"+platform+"-"+arch+"-"+version, " ", "-")),
ID: packageID(product, platform, arch, version),
Name: product,
Version: version,
Platform: platform,
@@ -352,23 +521,95 @@ func (s *Service) SavePreparedPackage(r *http.Request, uploaded UploadedPackageF
Required: strings.Contains(strings.ToLower(product), "ymhut"),
Enabled: true,
FileName: name,
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
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 {
if rollbackErr := restorePackageBackup(target, backup); rollbackErr != nil {
return Package{}, fmt.Errorf("%w: %v; rollback failed: %v", ErrUploadedPackageManifestFailed, err, rollbackErr)
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{}, fmt.Errorf("%w: %v", ErrUploadedPackageManifestFailed, err)
return Package{}, rollbackFile(cause)
}
}
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))})
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
@@ -492,21 +733,46 @@ func atomicWrite(path string, data []byte) error {
func requestBaseURL(r *http.Request, fallback string) string {
if r != nil {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto"))
if scheme != "http" && scheme != "https" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
if r.Host != "" {
return scheme + "://" + r.Host
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 {
@@ -577,16 +843,13 @@ func sha256File(path string) string {
func safePackageName(name string) (string, error) {
original := strings.TrimSpace(name)
if original == "" || original == "." || original == ".." || strings.ContainsAny(original, `/\`) {
return "", errors.New("invalid filename")
return "", ErrUnsafePackageName
}
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
}
if isSupportedPackageName(name) {
return name, nil
}
return "", errors.New("unsupported package extension")
return "", ErrUnsupportedPackage
}
func formatBytes(value int64) string {
@@ -2,6 +2,7 @@ package releases
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
@@ -12,6 +13,46 @@ import (
"ymhut-box/server/unified-management/internal/db"
)
func TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://internal:33550/downloads/file.zip", nil)
req.Header.Set("X-Forwarded-Proto", "HTTPS, http")
req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550")
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" {
t.Fatalf("requestBaseURL() = %q", got)
}
}
func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) {
tests := []struct {
name string
proto string
host string
request string
fallback string
want string
}{
{name: "invalid scheme", proto: "javascript", host: "updates.example.com", request: "https://internal:33550/path", want: "https://updates.example.com"},
{name: "invalid forwarded host", proto: "https", host: "user@evil.example", request: "http://internal:33550/path", want: "https://internal:33550"},
{name: "invalid forwarded port", proto: "https", host: "updates.example.com:99999", request: "http://internal:33550/path", want: "https://internal:33550"},
{name: "invalid request host", proto: "https", host: "evil.example/path", request: "http://internal/path", fallback: "https://fallback.example.com/", want: "https://fallback.example.com"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, test.request, nil)
req.Header.Set("X-Forwarded-Proto", test.proto)
req.Header.Set("X-Forwarded-Host", test.host)
if test.name == "invalid request host" {
req.Host = "bad host"
}
if got := requestBaseURL(req, test.fallback); got != test.want {
t.Fatalf("requestBaseURL() = %q, want %q", got, test.want)
}
})
}
}
func TestCompareVersion(t *testing.T) {
cases := []struct {
a string
@@ -67,6 +108,231 @@ func TestScanPackagesUsesCDNAndCachesHashes(t *testing.T) {
}
}
func TestReconcileListsEverySupportedPackageType(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"app.exe", "app.msix", "app.appinstaller", "app.msi", "app.zip", "app.7z"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("ignored"), 0o640); err != nil {
t.Fatal(err)
}
service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil)
if got := len(service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/releases", nil))); got != 6 {
t.Fatalf("listed %d packages, want 6", got)
}
}
func TestReconcileReusesPersistentHashAfterRestart(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
StorageDir: filepath.Join(dir, "storage"),
DownloadsDir: filepath.Join(dir, "downloads"),
BaseURL: "https://update.example",
Database: config.DatabaseConfig{
Provider: "sqlite",
SQLitePath: filepath.Join(dir, "storage", "unified.sqlite"),
HealthIntervalSec: 30,
},
}
if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil {
t.Fatal(err)
}
name := "YMhut_Box_2.1.0_x64.zip"
if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte("persistent package hash"), 0o640); err != nil {
t.Fatal(err)
}
store, err := db.Open(cfg)
if err != nil {
t.Fatal(err)
}
defer store.Close()
first := NewService(cfg, store)
if packages := first.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 || packages[0].SHA256 == "" {
t.Fatalf("initial reconcile did not index the package: %#v", packages)
}
hashCalls := 0
second := newService(cfg, store, func(path string) string {
hashCalls++
return sha256File(path)
})
if packages := second.ScanPackages(httptest.NewRequest("GET", "https://update.example", nil)); len(packages) != 1 {
t.Fatalf("restart snapshot contains %d packages, want 1", len(packages))
}
if hashCalls != 0 {
t.Fatalf("restart rehashed %d files, want 0", hashCalls)
}
}
func TestReconcileRehashesChangedFileAndRemovesDeletedIndex(t *testing.T) {
service, cfg, cleanup := newPreparedPackageTestService(t)
defer cleanup()
name := "YMhut_Box_2.2.0_x64.zip"
path := filepath.Join(cfg.DownloadsDir, name)
if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte("first"), 0o640); err != nil {
t.Fatal(err)
}
hashCalls := 0
service.hashFile = func(path string) string {
hashCalls++
return sha256File(path)
}
if err := service.Reconcile(t.Context()); err != nil {
t.Fatal(err)
}
first := service.ScanPackages(nil)
if len(first) != 1 || hashCalls != 1 {
t.Fatalf("first reconcile packages=%d hashCalls=%d", len(first), hashCalls)
}
if err := os.WriteFile(path, []byte("changed package bytes"), 0o640); err != nil {
t.Fatal(err)
}
if err := service.Reconcile(t.Context()); err != nil {
t.Fatal(err)
}
second := service.ScanPackages(nil)
if hashCalls != 2 || len(second) != 1 || second[0].SHA256 == first[0].SHA256 {
t.Fatalf("changed package was not rehashed: first=%#v second=%#v calls=%d", first, second, hashCalls)
}
if err := os.Remove(path); err != nil {
t.Fatal(err)
}
if err := service.Reconcile(t.Context()); err != nil {
t.Fatal(err)
}
if packages := service.ScanPackages(nil); len(packages) != 0 {
t.Fatalf("deleted package remains in snapshot: %#v", packages)
}
if _, ok, err := service.store.GetReleasePackage(name); err != nil || ok {
t.Fatalf("deleted package remains in index: ok=%v err=%v", ok, err)
}
}
func TestScanPackagesNeverHashesDuringRequest(t *testing.T) {
dir := t.TempDir()
name := "YMhut_Box_2.2.1_x64.7z"
if err := os.WriteFile(filepath.Join(dir, name), []byte("large package placeholder"), 0o640); err != nil {
t.Fatal(err)
}
service := NewService(&config.Config{DownloadsDir: dir, BaseURL: "https://update.example"}, nil)
service.hashFile = func(string) string {
t.Fatal("request-time package scan attempted to hash file content")
return ""
}
for range 10 {
if packages := service.ScanPackages(httptest.NewRequest("GET", "https://update.example/api/client/bootstrap", nil)); len(packages) != 1 {
t.Fatalf("snapshot contains %d packages, want 1", len(packages))
}
}
}
func TestManifestPrioritizesPublishedVersionOverNumericallyHigherHistoricalPackage(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
DownloadsDir: filepath.Join(dir, "downloads"),
UpdatePublicDir: filepath.Join(dir, "update", "public"),
BaseURL: "https://update.example",
}
if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil {
t.Fatal(err)
}
for _, name := range []string{
"YMhut_Box_WinUI_Setup_2.0.7.111.exe",
"YMhut_Box_WinUI_Setup_2.0.7.12.exe",
} {
if err := os.WriteFile(filepath.Join(cfg.DownloadsDir, name), []byte(name), 0o640); err != nil {
t.Fatal(err)
}
}
if err := os.WriteFile(
filepath.Join(cfg.UpdatePublicDir, "update-info.json"),
[]byte(`{"app_version":"2.0.7.12"}`),
0o640); err != nil {
t.Fatal(err)
}
service := NewService(cfg, nil)
manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil))
if got := manifestString(manifest, "app_version"); got != "2.0.7.12" {
t.Fatalf("manifest app_version = %q, want 2.0.7.12", got)
}
packages, ok := manifest["packages"].([]Package)
if !ok || len(packages) != 2 {
t.Fatalf("manifest packages = %#v", manifest["packages"])
}
if packages[0].Version != "2.0.7.12" {
t.Fatalf("first package version = %q, want published version 2.0.7.12", packages[0].Version)
}
if got := manifestString(manifest, "download_url"); !strings.Contains(got, "2.0.7.12.exe") {
t.Fatalf("manifest download_url = %q, want current package", got)
}
}
func TestManifestFallsBackToHighestPackageWithoutPublishedVersion(t *testing.T) {
dir := t.TempDir()
for _, name := range []string{"YMhut_Box_2.0.7.12.exe", "YMhut_Box_2.0.7.111.exe"} {
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o640); err != nil {
t.Fatal(err)
}
}
service := NewService(&config.Config{
DownloadsDir: dir,
UpdatePublicDir: filepath.Join(dir, "missing-public"),
BaseURL: "https://update.example",
}, nil)
manifest := service.Manifest(httptest.NewRequest(http.MethodGet, "https://update.example/api/client/releases", nil))
if got := manifestString(manifest, "app_version"); got != "2.0.7.111" {
t.Fatalf("manifest fallback app_version = %q, want highest indexed package 2.0.7.111", got)
}
}
func TestSavePreparedPackageRestoresFileAndManifestWhenIndexFails(t *testing.T) {
service, cfg, closeStore := newPreparedPackageTestService(t)
name := "YMhut_Box_WinUI_Setup_2.3.0_x64.exe"
if err := os.MkdirAll(cfg.DownloadsDir, 0o750); err != nil {
t.Fatal(err)
}
target := filepath.Join(cfg.DownloadsDir, name)
if err := os.WriteFile(target, []byte("old package"), 0o640); err != nil {
t.Fatal(err)
}
manifestPath := filepath.Join(cfg.UpdatePublicDir, "update-info.json")
if err := os.MkdirAll(cfg.UpdatePublicDir, 0o750); err != nil {
t.Fatal(err)
}
oldManifest := []byte("{\"app_version\":\"1.0.0\"}\n")
if err := os.WriteFile(manifestPath, oldManifest, 0o640); err != nil {
t.Fatal(err)
}
temp := filepath.Join(cfg.DownloadsDir, ".upload-new")
if err := os.WriteFile(temp, []byte("new package"), 0o640); err != nil {
t.Fatal(err)
}
closeStore()
_, err := service.SavePreparedPackage(
httptest.NewRequest("POST", "https://update.ymhut.cn/api/admin/releases/packages", nil),
UploadedPackageFile{TempPath: temp, Size: int64(len("new package")), SHA256: "abc123"},
UploadOptions{FileName: name, UpdateManifest: true},
"admin")
if !errors.Is(err, ErrUploadedPackageIndexFailed) {
t.Fatalf("got %v, want index failure", err)
}
if data, readErr := os.ReadFile(target); readErr != nil || string(data) != "old package" {
t.Fatalf("package rollback data=%q err=%v", data, readErr)
}
if data, readErr := os.ReadFile(manifestPath); readErr != nil || string(data) != string(oldManifest) {
t.Fatalf("manifest rollback data=%q err=%v", data, readErr)
}
}
func TestSaveUploadedPackageWritesFileAndUpdatesManifest(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{
@@ -28,6 +28,7 @@ type Service struct {
mu sync.RWMutex
jobs map[string]CheckJob
subscribers map[chan Event]struct{}
onChange func()
}
type Event struct {
@@ -548,6 +549,7 @@ func (s *Service) CheckOne(ctx context.Context, item db.Source) error {
}
func (s *Service) CheckOneStatus(ctx context.Context, item db.Source) (string, error) {
defer s.notifyChange()
if strings.TrimSpace(item.APIURL) == "" {
return "error", errors.New("source api_url is empty")
}
@@ -657,6 +659,21 @@ func (s *Service) PublishEvent(kind string, data map[string]any) {
s.emit(kind, data)
}
func (s *Service) SetChangeCallback(callback func()) {
s.mu.Lock()
s.onChange = callback
s.mu.Unlock()
}
func (s *Service) notifyChange() {
s.mu.RLock()
callback := s.onChange
s.mu.RUnlock()
if callback != nil {
callback()
}
}
func newJobID() string {
return fmt.Sprintf("check-%d", time.Now().UnixNano())
}
@@ -17,7 +17,7 @@ func (r *router) handleAdminFeedbacks(w http.ResponseWriter, req *http.Request)
if req.URL.Query().Get("page") != "" {
page, _ := strconv.Atoi(req.URL.Query().Get("page"))
perPage, _ := strconv.Atoi(req.URL.Query().Get("perPage"))
items, total, err := r.store.ListFeedbacksFiltered(page, perPage, db.FeedbackFilters{
items, total, err := r.store.ListFeedbackSummariesFiltered(page, perPage, db.FeedbackFilters{
Status: req.URL.Query().Get("status"),
Category: req.URL.Query().Get("category"),
Priority: req.URL.Query().Get("priority"),
@@ -222,9 +222,16 @@ func writeReleaseUploadError(w http.ResponseWriter, err error) {
if errors.Is(err, releases.ErrUploadedPackageManifestFailed) {
status = http.StatusInternalServerError
code = "MANIFEST_UPDATE_FAILED"
} else if errors.Is(err, releases.ErrUploadedPackageIndexFailed) {
status = http.StatusInternalServerError
code = "PACKAGE_INDEX_FAILED"
} else if errors.Is(err, releases.ErrUploadedPackageStorageFailed) {
status = http.StatusInternalServerError
code = "UPLOAD_STORAGE_FAILED"
} else if errors.Is(err, releases.ErrUnsupportedPackage) {
code = "PACKAGE_TYPE_UNSUPPORTED"
} else if errors.Is(err, releases.ErrUnsafePackageName) {
code = "PACKAGE_NAME_INVALID"
} else if errors.Is(err, releases.ErrUploadedPackageEmpty) {
code = "PACKAGE_EMPTY"
} else if errors.Is(err, releases.ErrUploadedPackageMissing) {
@@ -11,7 +11,6 @@ import (
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/health"
feedbackmail "ymhut-box/server/unified-management/internal/mail"
)
@@ -54,6 +53,7 @@ func (r *router) handleAdminDatabase(w http.ResponseWriter, req *http.Request) {
writeError(w, http.StatusInternalServerError, "DATABASE_SAVE_FAILED", err)
return
}
r.health.RefreshPreflight()
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "system.database.saved", Target: body.Provider, Message: "数据库配置已保存并热切换", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "database": r.store.Status(), "config": config.SafeDatabase(r.cfg.BaseDir, r.cfg.Database)})
case req.Method == http.MethodPost && path == "/api/admin/database/sync/jobs":
@@ -161,15 +161,47 @@ func (r *router) handleAdminDashboard(w http.ResponseWriter, req *http.Request)
http.NotFound(w, req)
return
}
overview, err := r.store.DashboardOverview(80)
window, duration, points := dashboardWindow(req.URL.Query().Get("window"))
overview, cacheHit, err := r.dashboardCache.Get(window, func() (map[string]any, error) {
since := time.Now().UTC().Add(-duration).Format(time.RFC3339)
result, buildErr := r.store.DashboardOverviewWindow(points, since)
if buildErr != nil {
return nil, buildErr
}
result["window"] = window
jobs := r.sources.CheckJobs()
if len(jobs) > 5 {
jobs = jobs[:5]
}
result["sourceCheckJobs"] = jobs
result["health"] = r.healthSnapshot()
return result, nil
})
if err != nil {
writeError(w, http.StatusInternalServerError, "DASHBOARD_FAILED", err)
return
}
overview["health"] = health.Snapshot(r.cfg, r.store)
if cacheHit {
w.Header().Set("X-Admin-Cache", "hit")
} else {
w.Header().Set("X-Admin-Cache", "miss")
}
writeJSON(w, http.StatusOK, overview)
}
func dashboardWindow(value string) (string, time.Duration, int) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "1h":
return "1h", time.Hour, 60
case "6h":
return "6h", 6 * time.Hour, 72
case "7d":
return "7d", 7 * 24 * time.Hour, 112
default:
return "24h", 24 * time.Hour, 96
}
}
func (r *router) handleAdminSync(w http.ResponseWriter, req *http.Request) {
if r.syncer == nil {
writeError(w, http.StatusNotFound, "SYNC_DISABLED", errors.New("legacy sync service is not configured"))
@@ -239,7 +271,18 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
path := cleanPath(req.URL.Path)
switch path {
case "/api/admin/system/health":
writeJSON(w, http.StatusOK, health.Snapshot(r.cfg, r.store))
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
writeJSON(w, http.StatusOK, r.healthSnapshot())
case "/api/admin/system/preflight":
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
r.health.RefreshPreflight()
writeJSON(w, http.StatusOK, r.healthSnapshot())
case "/api/admin/system/audit":
page, err := r.store.ListAuditLogsPage(db.AuditFilters{
Page: queryInt(req, "page", 1),
@@ -293,6 +336,12 @@ func (r *router) handleAdminSystem(w http.ResponseWriter, req *http.Request) {
}
}
func (r *router) healthSnapshot() map[string]any {
snapshot := r.health.Snapshot()
snapshot["adminAssets"] = r.adminAssets.Diagnostics()
return snapshot
}
func queryInt(req *http.Request, key string, fallback int) int {
value, err := strconv.Atoi(req.URL.Query().Get(key))
if err != nil || value <= 0 {
@@ -1,6 +1,7 @@
package web
import (
"errors"
"net/http"
"strings"
"time"
@@ -8,6 +9,7 @@ import (
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/health"
"ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/reference"
)
func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request) {
@@ -34,6 +36,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request)
"releaseManifest": true,
"endpointCalls": true,
"legacyJson": true,
"referenceData": true,
},
"endpoints": map[string]string{
"releases": "/api/client/releases",
@@ -41,6 +44,7 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request)
"clientEndpoints": "/api/client/endpoints",
"endpointCalls": "/api/client/endpoint-calls",
"notices": "/api/client/notices",
"referenceData": "/api/client/reference-data/:kind",
"feedback": "/",
},
"cache": map[string]int{
@@ -48,19 +52,44 @@ func (r *router) handleClientBootstrap(w http.ResponseWriter, req *http.Request)
"releasesSeconds": 300,
"sourcesSeconds": 600,
"healthSeconds": 300,
"referenceSeconds": 86400,
},
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
"release": release,
"sources": sourceCatalog,
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
"branding": config.SafeBranding(r.effectiveBranding()),
"health": health.Snapshot(r.cfg, r.store),
"notices": publicNotices,
"legacyRoutes": []string{"/update-info.json", "/update-info", "/api/update-info", "/api/releases", "/tool-status.json", "/media-types.json", "/modules.json", "/downloads/:filename"},
"release": release,
"sources": sourceCatalog,
"feedback": map[string]any{"submit": "/", "status": "/?api=status&code=:code"},
"branding": config.SafeBranding(r.effectiveBranding()),
"health": health.Snapshot(r.cfg, r.store),
"notices": publicNotices,
"referenceData": r.referenceData.Descriptors(),
}
})
writePublicSnapshot(w, req, snapshot)
}
func (r *router) handleClientReferenceData(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet && req.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
kind := strings.TrimPrefix(cleanPath(req.URL.Path), "/api/client/reference-data/")
if kind == "" || strings.Contains(kind, "/") {
http.NotFound(w, req)
return
}
payload, err := r.referenceData.Read(kind)
if errors.Is(err, reference.ErrNotFound) {
writeError(w, http.StatusNotFound, "REFERENCE_DATA_NOT_FOUND", err)
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "REFERENCE_DATA_FAILED", err)
return
}
snapshot := r.publicSnapshots.Get("reference-data|"+kind, func(_ time.Time) any { return payload })
writePublicSnapshot(w, req, snapshot)
}
func (r *router) handleClientSources(w http.ResponseWriter, req *http.Request) {
baseURL := requestBaseURL(req, r.cfg.BaseURL)
snapshot := r.publicSnapshots.Get("sources|"+baseURL, func(_ time.Time) any {
@@ -0,0 +1,85 @@
package web
import (
"sync"
"time"
)
type dashboardCacheEntry struct {
value map[string]any
expiresAt time.Time
building chan struct{}
}
type dashboardSnapshotCache struct {
mu sync.Mutex
ttl time.Duration
generation uint64
entries map[string]*dashboardCacheEntry
}
func newDashboardSnapshotCache(ttl time.Duration) *dashboardSnapshotCache {
if ttl <= 0 {
ttl = 5 * time.Second
}
return &dashboardSnapshotCache{ttl: ttl, entries: map[string]*dashboardCacheEntry{}}
}
func (c *dashboardSnapshotCache) Get(key string, build func() (map[string]any, error)) (map[string]any, bool, error) {
for {
now := time.Now()
c.mu.Lock()
entry := c.entries[key]
if entry != nil && entry.value != nil && now.Before(entry.expiresAt) {
value := entry.value
c.mu.Unlock()
return value, true, nil
}
if entry != nil && entry.building != nil {
ready := entry.building
c.mu.Unlock()
<-ready
continue
}
generation := c.generation
ready := make(chan struct{})
c.entries[key] = &dashboardCacheEntry{building: ready}
c.mu.Unlock()
value, err := build()
c.mu.Lock()
if generation != c.generation {
if current := c.entries[key]; current != nil && current.building == ready {
delete(c.entries, key)
}
close(ready)
c.mu.Unlock()
if err != nil {
return nil, false, err
}
continue
}
if err != nil {
delete(c.entries, key)
close(ready)
c.mu.Unlock()
return nil, false, err
}
c.entries[key] = &dashboardCacheEntry{value: value, expiresAt: time.Now().Add(c.ttl)}
close(ready)
c.mu.Unlock()
return value, false, nil
}
}
func (c *dashboardSnapshotCache) Invalidate() {
c.mu.Lock()
c.generation++
for key, entry := range c.entries {
if entry.building == nil {
delete(c.entries, key)
}
}
c.mu.Unlock()
}
@@ -0,0 +1,42 @@
package web
import (
"sync"
"sync/atomic"
"testing"
"time"
)
func TestDashboardSnapshotCacheSharesConcurrentBuildAndInvalidates(t *testing.T) {
cache := newDashboardSnapshotCache(time.Minute)
var builds atomic.Int32
build := func() (map[string]any, error) {
builds.Add(1)
time.Sleep(15 * time.Millisecond)
return map[string]any{"ok": true}, nil
}
var wait sync.WaitGroup
for range 12 {
wait.Add(1)
go func() {
defer wait.Done()
value, _, err := cache.Get("24h", build)
if err != nil || value["ok"] != true {
t.Errorf("Get returned value=%#v err=%v", value, err)
}
}()
}
wait.Wait()
if got := builds.Load(); got != 1 {
t.Fatalf("concurrent cache builds = %d, want 1", got)
}
cache.Invalidate()
if _, hit, err := cache.Get("24h", build); err != nil || hit {
t.Fatalf("invalidated cache returned hit=%v err=%v", hit, err)
}
if got := builds.Load(); got != 2 {
t.Fatalf("builds after invalidation = %d, want 2", got)
}
}
@@ -28,6 +28,8 @@ type publicSnapshotService struct {
entries map[string]publicSnapshotEntry
}
const maxPublicSnapshotEntries = 64
func newPublicSnapshotService(ttl time.Duration) *publicSnapshotService {
return &publicSnapshotService{ttl: ttl, entries: map[string]publicSnapshotEntry{}}
}
@@ -39,6 +41,22 @@ func (s *publicSnapshotService) Get(key string, build func(generatedAt time.Time
if entry, ok := s.entries[key]; ok && now.Before(entry.expiresAt) {
return entry.snapshot
}
for entryKey, entry := range s.entries {
if !now.Before(entry.expiresAt) {
delete(s.entries, entryKey)
}
}
if len(s.entries) >= maxPublicSnapshotEntries {
oldestKey := ""
var oldestExpiry time.Time
for entryKey, entry := range s.entries {
if oldestKey == "" || entry.expiresAt.Before(oldestExpiry) {
oldestKey = entryKey
oldestExpiry = entry.expiresAt
}
}
delete(s.entries, oldestKey)
}
payload := build(now)
data, err := json.Marshal(payload)
@@ -2,12 +2,31 @@ package web
import (
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"runtime/debug"
"strconv"
"strings"
)
func withSecurity(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if recovered := recover(); recovered != nil {
if recovered == http.ErrAbortHandler {
panic(recovered)
}
log.Printf("recovered request panic method=%s path=%s error=%v\n%s", r.Method, r.URL.Path, recovered, debug.Stack())
w.Header().Set("Cache-Control", "no-store")
if strings.HasPrefix(cleanPath(r.URL.Path), "/api/") {
writeError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", fmt.Errorf("request failed unexpectedly"))
return
}
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}()
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Referrer-Policy", "same-origin")
next.ServeHTTP(w, r)
@@ -77,6 +96,7 @@ func localizedErrorMessage(code, message string) string {
return translated
}
byCode := map[string]string{
"INTERNAL_SERVER_ERROR": "服务端处理请求时发生异常,请稍后重试并检查服务日志",
"UNAUTHORIZED": "需要登录后继续操作",
"LOGIN_FAILED": "登录失败,请检查密码和验证码",
"LOGIN_LOCKED": "登录失败次数过多,请 5 分钟后重试",
@@ -98,6 +118,9 @@ func localizedErrorMessage(code, message string) string {
"PACKAGE_UPLOAD_FAILED": "发布包上传失败",
"PACKAGE_EMPTY": "发布包不能为空",
"PACKAGE_TOO_LARGE": "发布包超过服务端上传上限",
"PACKAGE_INDEX_FAILED": "发布包已回滚,数据库索引更新失败",
"PACKAGE_TYPE_UNSUPPORTED": "仅支持 EXE、MSIX、APPINSTALLER、MSI、ZIP 或 7Z 发布包",
"PACKAGE_NAME_INVALID": "发布包文件名不合法",
"UPLOAD_STORAGE_FAILED": "服务端无法保存上传文件",
"MANIFEST_UPDATE_FAILED": "发布包已回滚,更新清单写入失败",
"UPLOAD_INTERRUPTED": "上传连接已中断,请重新上传",
@@ -163,20 +186,45 @@ func cleanPath(path string) string {
}
func requestBaseURL(r *http.Request, fallback string) string {
scheme := r.Header.Get("X-Forwarded-Proto")
if scheme == "" {
scheme := firstForwardedHeader(r.Header.Get("X-Forwarded-Proto"))
if scheme != "http" && scheme != "https" {
if r.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
if r.Host != "" {
return scheme + "://" + r.Host
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
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
@@ -4,17 +4,21 @@ import (
"context"
"encoding/json"
"errors"
"log"
"net"
"net/http"
"strings"
"time"
"ymhut-box/server/unified-management/internal/adminassets"
"ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
"ymhut-box/server/unified-management/internal/feedback"
"ymhut-box/server/unified-management/internal/health"
"ymhut-box/server/unified-management/internal/legacy"
"ymhut-box/server/unified-management/internal/notices"
"ymhut-box/server/unified-management/internal/reference"
"ymhut-box/server/unified-management/internal/releases"
"ymhut-box/server/unified-management/internal/sources"
"ymhut-box/server/unified-management/internal/synclegacy"
@@ -29,8 +33,12 @@ type router struct {
sources *sources.Service
legacy *legacy.Service
notices *notices.Service
referenceData *reference.Service
syncer *synclegacy.Service
publicSnapshots *publicSnapshotService
adminAssets *adminassets.Service
health *health.Service
dashboardCache *dashboardSnapshotCache
}
const loginRequestTimeout = 8 * time.Second
@@ -44,7 +52,17 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f
releases: releaseService,
sources: sourceService,
legacy: legacyService,
referenceData: reference.NewService(cfg.UpdatePublicDir),
publicSnapshots: newPublicSnapshotService(60 * time.Second),
adminAssets: adminassets.New(cfg.AdminAssetMode, cfg.AdminWebDir, config.AdminBuildID),
health: health.NewService(cfg, store),
dashboardCache: newDashboardSnapshotCache(5 * time.Second),
}
assetStatus := r.adminAssets.Diagnostics()
if assetStatus.Ready {
log.Printf("admin assets: mode=%s build=%s manifest=%s entries=%d", assetStatus.Mode, assetStatus.BuildID, assetStatus.ManifestStatus, assetStatus.ManifestEntries)
} else {
log.Printf("admin assets unavailable: mode=%s source=%s error=%s", assetStatus.Mode, assetStatus.Source, assetStatus.ValidationError)
}
for _, item := range optional {
switch typed := item.(type) {
@@ -54,11 +72,32 @@ func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, f
r.syncer = typed
}
}
if releaseService != nil {
releaseService.SetChangeCallback(func() {
r.publicSnapshots.Invalidate()
r.dashboardCache.Invalidate()
})
}
if sourceService != nil {
sourceService.SetChangeCallback(r.dashboardCache.Invalidate)
}
return withSecurity(r)
}
func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
req.RemoteAddr = clientAddress(req)
path := cleanPath(req.URL.Path)
if strings.HasPrefix(path, "/api/admin/") {
w.Header().Set("X-Admin-Build-ID", r.adminAssets.Diagnostics().BuildID)
if path != "/api/admin/events" {
diagnostics := &diagnosticResponseWriter{ResponseWriter: w, status: http.StatusOK}
w = diagnostics
started := time.Now()
defer func() {
log.Printf("admin_api method=%s path=%s status=%d duration_ms=%d cache=%s build=%s", req.Method, path, diagnostics.status, time.Since(started).Milliseconds(), firstNonEmpty(w.Header().Get("X-Admin-Cache"), "n/a"), r.adminAssets.Diagnostics().BuildID)
}()
}
}
if strings.HasPrefix(path, "/api/admin/") &&
path != "/api/admin/auth/login" &&
path != "/api/admin/auth/logout" &&
@@ -70,6 +109,7 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
return
}
r.publicSnapshots.Invalidate()
r.dashboardCache.Invalidate()
if r.sources != nil {
r.sources.PublishEvent(adminMutationEvent(path), map[string]any{"path": path, "method": req.Method, "time": time.Now().UTC().Format(time.RFC3339)})
}
@@ -110,6 +150,8 @@ func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.handleClientEndpoints(w, req)
case path == "/api/client/notices" || strings.HasPrefix(path, "/api/client/notices/"):
r.handleClientNotices(w, req)
case strings.HasPrefix(path, "/api/client/reference-data/"):
r.handleClientReferenceData(w, req)
case path == "/api/client/endpoint-calls" || path == "/api/client/source-calls":
r.handleSourceCall(w, req)
case path == "/update-info.json" || path == "/update-info":
@@ -160,6 +202,16 @@ type mutationResponseWriter struct {
status int
}
type diagnosticResponseWriter struct {
http.ResponseWriter
status int
}
func (w *diagnosticResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
}
func (w *mutationResponseWriter) WriteHeader(status int) {
w.status = status
w.ResponseWriter.WriteHeader(status)
@@ -181,11 +233,13 @@ func adminMutationEvent(path string) string {
}
func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
payload, err := r.auth.Bootstrap(req.Context())
_, _, authenticated := r.auth.UserForRequest(req)
payload, err := r.auth.Bootstrap(req.Context(), authenticated)
if err != nil {
writeError(w, http.StatusInternalServerError, "BOOTSTRAP_FAILED", err)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, payload)
}
@@ -264,6 +318,35 @@ func remoteHost(remoteAddress string) string {
return strings.TrimSpace(remoteAddress)
}
func clientAddress(req *http.Request) string {
if req == nil {
return ""
}
peer := remoteHost(req.RemoteAddr)
peerIP := net.ParseIP(strings.Trim(peer, "[]"))
if peerIP == nil || !peerIP.IsLoopback() {
return peer
}
if forwarded := validClientIP(req.Header.Get("X-Real-IP")); forwarded != "" {
return forwarded
}
values := strings.Split(req.Header.Get("X-Forwarded-For"), ",")
for index := len(values) - 1; index >= 0; index-- {
if forwarded := validClientIP(values[index]); forwarded != "" {
return forwarded
}
}
return peer
}
func validClientIP(value string) string {
value = strings.Trim(strings.TrimSpace(value), "[]")
if parsed := net.ParseIP(value); parsed != nil {
return parsed.String()
}
return ""
}
func (r *router) recordLoginAudit(username, remoteAddr, userAgent string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
@@ -11,6 +11,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"image/color"
"image/png"
"io"
@@ -26,6 +27,7 @@ import (
"testing"
"time"
"ymhut-box/server/unified-management/internal/adminassets"
"ymhut-box/server/unified-management/internal/auth"
"ymhut-box/server/unified-management/internal/config"
"ymhut-box/server/unified-management/internal/db"
@@ -36,6 +38,74 @@ import (
"ymhut-box/server/unified-management/internal/sources"
)
func TestRequestBaseURLUsesTrustedForwardedValues(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://internal:33550/api/client/bootstrap", nil)
req.Header.Set("X-Forwarded-Proto", "HTTPS, http")
req.Header.Set("X-Forwarded-Host", "updates.example.com:8443, internal:33550")
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://updates.example.com:8443" {
t.Fatalf("requestBaseURL() = %q", got)
}
}
func TestRequestBaseURLRejectsInvalidForwardedValues(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "https://internal:33550/api/client/bootstrap", nil)
req.Header.Set("X-Forwarded-Proto", "file")
req.Header.Set("X-Forwarded-Host", "user@evil.example")
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://internal:33550" {
t.Fatalf("requestBaseURL() = %q", got)
}
req.Host = "bad host"
req.Header.Set("X-Forwarded-Host", "updates.example.com:99999")
if got := requestBaseURL(req, "https://fallback.example.com/"); got != "https://fallback.example.com" {
t.Fatalf("requestBaseURL() with invalid hosts = %q", got)
}
}
func TestPublicSnapshotCacheBoundsHostVariants(t *testing.T) {
service := newPublicSnapshotService(time.Hour)
for index := 0; index < maxPublicSnapshotEntries+20; index++ {
key := fmt.Sprintf("bootstrap|https://host-%d.example.com", index)
service.Get(key, func(time.Time) any { return map[string]any{"ok": true} })
}
if got := len(service.entries); got != maxPublicSnapshotEntries {
t.Fatalf("snapshot cache has %d entries, want %d", got, maxPublicSnapshotEntries)
}
}
func TestPublicSnapshotCacheRemovesExpiredEntries(t *testing.T) {
service := newPublicSnapshotService(time.Millisecond)
service.Get("expired", func(time.Time) any { return map[string]any{"value": 1} })
time.Sleep(2 * time.Millisecond)
service.Get("current", func(time.Time) any { return map[string]any{"value": 2} })
if _, exists := service.entries["expired"]; exists {
t.Fatal("expired snapshot was not removed")
}
}
func TestClientAddressTrustsForwardedIPOnlyFromLoopbackProxy(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://internal/", nil)
req.RemoteAddr = "127.0.0.1:44000"
req.Header.Set("X-Real-IP", "203.0.113.8")
req.Header.Set("X-Forwarded-For", "198.51.100.2, 203.0.113.9")
if got := clientAddress(req); got != "203.0.113.8" {
t.Fatalf("clientAddress() = %q", got)
}
req.Header.Set("X-Real-IP", "not-an-ip")
if got := clientAddress(req); got != "203.0.113.9" {
t.Fatalf("clientAddress() XFF fallback = %q", got)
}
req.RemoteAddr = "192.0.2.25:44000"
req.Header.Set("X-Real-IP", "203.0.113.10")
if got := clientAddress(req); got != "192.0.2.25" {
t.Fatalf("clientAddress() trusted a non-loopback peer: %q", got)
}
}
func TestCompatibilityRoutes(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -54,6 +124,29 @@ func TestCompatibilityRoutes(t *testing.T) {
}
}
func TestPublicAuthBootstrapDoesNotExposeAdministratorCredentials(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
req := httptest.NewRequest(http.MethodGet, "/api/admin/auth/bootstrap", nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("bootstrap returned %d: %s", res.Code, res.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
for _, field := range []string{"defaultUsername", "defaultPassword", "isDefaultPassword"} {
if _, exists := payload[field]; exists {
t.Fatalf("public bootstrap exposed %s: %#v", field, payload)
}
}
if cacheControl := res.Header().Get("Cache-Control"); cacheControl != "no-store" {
t.Fatalf("bootstrap cache control = %q, want no-store", cacheControl)
}
}
func TestClientBootstrapSupportsConditionalCaching(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -246,6 +339,48 @@ func TestClientBootstrapAndEndpointsShape(t *testing.T) {
}
}
func TestClientReferenceDataSupportsCachingAndBootstrapDiscovery(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
request := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil)
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusOK {
t.Fatalf("reference data returned %d: %s", response.Code, response.Body.String())
}
etag := response.Header().Get("ETag")
if etag == "" {
t.Fatal("reference data did not include an ETag")
}
var ranking map[string]any
if err := json.Unmarshal(response.Body.Bytes(), &ranking); err != nil {
t.Fatal(err)
}
if ranking["kind"] != "gpu" || ranking["items"] == nil {
t.Fatalf("unexpected reference data: %#v", ranking)
}
conditional := httptest.NewRequest(http.MethodGet, "/api/client/reference-data/gpu", nil)
conditional.Header.Set("If-None-Match", etag)
conditionalResponse := httptest.NewRecorder()
handler.ServeHTTP(conditionalResponse, conditional)
if conditionalResponse.Code != http.StatusNotModified {
t.Fatalf("conditional reference data returned %d", conditionalResponse.Code)
}
bootstrapRequest := httptest.NewRequest(http.MethodGet, "/api/client/bootstrap", nil)
bootstrapResponse := httptest.NewRecorder()
handler.ServeHTTP(bootstrapResponse, bootstrapRequest)
var bootstrap map[string]any
if err := json.Unmarshal(bootstrapResponse.Body.Bytes(), &bootstrap); err != nil {
t.Fatal(err)
}
if bootstrap["referenceData"] == nil {
t.Fatalf("bootstrap does not advertise reference data: %#v", bootstrap)
}
}
func TestAdminDeleteSourcePublishesCompatibilityJSON(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
@@ -653,7 +788,7 @@ func TestAdminReleasePackageUploadRejectsMissingAndOversizedFile(t *testing.T) {
{name: "missing", include: false, wantStatus: http.StatusBadRequest, wantCode: "FILE_REQUIRED"},
{name: "empty", include: true, data: []byte{}, wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_EMPTY"},
{name: "oversized", include: true, data: bytes.Repeat([]byte{'x'}, (1<<20)+1), wantStatus: http.StatusRequestEntityTooLarge, wantCode: "PACKAGE_TOO_LARGE"},
{name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_UPLOAD_FAILED"},
{name: "unsafe-name", include: true, data: []byte("package"), wantStatus: http.StatusBadRequest, wantCode: "PACKAGE_NAME_INVALID"},
} {
t.Run(tc.name, func(t *testing.T) {
handler, cleanup := testRouter(t)
@@ -737,8 +872,10 @@ func TestValidateAdminDiskBuildRejectsMissingReferencedAsset(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte(`<script type="module" src="/admin/assets/missing.js"></script>`), 0o644); err != nil {
t.Fatal(err)
}
if err := validateAdminDiskBuild(dir); err == nil || !strings.Contains(err.Error(), "assets/missing.js") {
t.Fatalf("validateAdminDiskBuild returned %v, want missing asset error", err)
writeAdminBuildMetadata(t, dir, "assets/missing.js")
status := adminassets.ValidateDisk(dir, "dev")
if status.Ready || !strings.Contains(status.ValidationError, "assets/missing.js") {
t.Fatalf("ValidateDisk returned %#v, want missing asset error", status)
}
}
@@ -753,7 +890,8 @@ func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "assets", "stale.js"), []byte(`stale disk asset`), 0o644); err != nil {
t.Fatal(err)
}
r := &router{cfg: &config.Config{AdminWebDir: dir}}
writeAdminBuildMetadata(t, dir, "assets/missing.js")
r := &router{cfg: &config.Config{AdminWebDir: dir, AdminAssetMode: adminassets.ModeDisk}, adminAssets: adminassets.New(adminassets.ModeDisk, dir, "dev")}
req := httptest.NewRequest(http.MethodGet, "/admin/assets/stale.js", nil)
res := httptest.NewRecorder()
r.serveAdminAsset(res, req, "assets/stale.js")
@@ -768,6 +906,17 @@ func TestIncompleteAdminDiskBuildDoesNotServeDiskAssets(t *testing.T) {
}
}
func writeAdminBuildMetadata(t *testing.T, dir, output string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil {
t.Fatal(err)
}
manifest := fmt.Sprintf(`{"src/main.ts":{"file":%q,"isEntry":true}}`, output)
if err := os.WriteFile(filepath.Join(dir, "asset-manifest.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
}
func containsAny(value string, needles []string) bool {
for _, needle := range needles {
if strings.Contains(value, needle) {
@@ -913,6 +1062,111 @@ func TestAdminWriteRequiresCSRF(t *testing.T) {
}
}
func TestAdminPageReadRoutesNeverReturnGatewayOrServerErrors(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, _, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
paths := []string{
"/api/admin/dashboard/overview?window=24h",
"/api/admin/feedbacks?page=1&pageSize=20",
"/api/admin/releases",
"/api/admin/releases/notices",
"/api/admin/legacy/update-info",
"/api/admin/legacy/media-types",
"/api/admin/sources",
"/api/admin/sources/check/status",
"/api/admin/endpoints",
"/api/admin/database/status",
"/api/admin/database/sync/jobs/latest",
"/api/admin/system/migration",
"/api/admin/system/branding",
"/api/admin/system/mail/config",
"/api/admin/sync/legacy/preview",
"/api/admin/system/health",
"/api/admin/system/audit?page=1&pageSize=20",
"/api/admin/system/logs?page=1&pageSize=20",
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code >= http.StatusInternalServerError {
t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String())
}
if res.Code == http.StatusUnauthorized || res.Code == http.StatusForbidden {
t.Fatalf("admin page request returned %d: %s", res.Code, res.Body.String())
}
})
}
}
func TestAdminDashboardSnapshotAndHealthExposeDiagnostics(t *testing.T) {
handler, cleanup := testRouter(t)
defer cleanup()
session, _, err := loginForTest(handler)
if err != nil {
t.Fatal(err)
}
request := func(path string) (*httptest.ResponseRecorder, map[string]any) {
req := httptest.NewRequest(http.MethodGet, path, nil)
req.AddCookie(&http.Cookie{Name: auth.SessionCookie, Value: session})
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusOK {
t.Fatalf("%s returned %d: %s", path, res.Code, res.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(res.Body.Bytes(), &payload); err != nil {
t.Fatal(err)
}
return res, payload
}
first, dashboard := request("/api/admin/dashboard/overview?window=6h")
second, _ := request("/api/admin/dashboard/overview?window=6h")
for _, key := range []string{"sourceRows", "sourceCheckJobs", "generatedAt", "warnings", "heartbeats", "averageLatency", "clientCalls"} {
if _, ok := dashboard[key]; !ok {
t.Fatalf("dashboard missing %s: %#v", key, dashboard)
}
}
if first.Header().Get("X-Admin-Cache") != "miss" || second.Header().Get("X-Admin-Cache") != "hit" {
t.Fatalf("cache headers first=%q second=%q", first.Header().Get("X-Admin-Cache"), second.Header().Get("X-Admin-Cache"))
}
_, healthPayload := request("/api/admin/system/health")
assets, ok := healthPayload["adminAssets"].(map[string]any)
if !ok || assets["manifestStatus"] != "valid" || assets["buildId"] != "dev" {
t.Fatalf("admin asset diagnostics missing: %#v", healthPayload["adminAssets"])
}
}
func TestSecurityMiddlewareRecoversAPIPanicAsJSON(t *testing.T) {
handler := withSecurity(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("simulated handler failure")
}))
req := httptest.NewRequest(http.MethodGet, "/api/admin/system/health", nil)
res := httptest.NewRecorder()
handler.ServeHTTP(res, req)
if res.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d: %s", res.Code, http.StatusInternalServerError, res.Body.String())
}
if contentType := res.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") {
t.Fatalf("content type = %q, want JSON", contentType)
}
if !strings.Contains(res.Body.String(), "INTERNAL_SERVER_ERROR") {
t.Fatalf("response does not contain stable error code: %s", res.Body.String())
}
}
func loginForTest(handler http.Handler) (string, string, error) {
captchaReq := httptest.NewRequest(http.MethodGet, "/api/admin/auth/captcha", nil)
captchaRes := httptest.NewRecorder()
@@ -1091,6 +1345,12 @@ func testRouter(t *testing.T) (http.Handler, func()) {
if err := os.WriteFile(filepath.Join(adminDist, "assets", "admin.js"), []byte(`console.log("admin")`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(adminDist, "admin-build.json"), []byte(`{"buildId":"dev"}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(adminDist, "asset-manifest.json"), []byte(`{"src/main.ts":{"file":"assets/admin.js","css":["assets/admin.css"],"isEntry":true}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(noticeDir, 0o755); err != nil {
t.Fatal(err)
}
@@ -1125,6 +1385,7 @@ func testRouter(t *testing.T) (http.Handler, func()) {
UpdateNoticeDir: noticeDir,
DownloadsDir: filepath.Join(public, "downloads"),
AdminWebDir: adminDist,
AdminAssetMode: adminassets.ModeDisk,
PortalWebDir: portalDist,
SourceCheckSeconds: 3600,
ClientSignatureKey: "ymhut-box-feedback-client-v1",
@@ -3,8 +3,7 @@ package web
import (
"bytes"
"errors"
"fmt"
"log"
"html"
"mime"
"net/http"
"os"
@@ -63,18 +62,10 @@ func (r *router) serveAdminAsset(w http.ResponseWriter, req *http.Request, asset
return
}
setStaticCacheHeaders(w, assetPath)
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
if tryServeDiskFile(w, req, r.cfg.AdminWebDir, assetPath) {
return
}
log.Printf("admin web disk build is missing requested asset: %s", assetPath)
} else {
if !errors.Is(err, os.ErrNotExist) {
log.Printf("admin web disk build is incomplete; serving embedded assets: %v", err)
}
if serveEmbeddedFile(w, req, "admin/dist/"+filepath.ToSlash(assetPath)) {
return
}
data, err := r.adminAssets.ReadFile(filepath.ToSlash(assetPath))
if err == nil {
serveAssetContent(w, req, assetPath, data)
return
}
w.Header().Set("Cache-Control", "no-store")
http.NotFound(w, req)
@@ -136,52 +127,29 @@ func (r *router) servePortal(w http.ResponseWriter, req *http.Request) {
func (r *router) serveAdmin(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Cache-Control", "no-store, must-revalidate")
index := filepath.Join(r.cfg.AdminWebDir, "index.html")
if err := validateAdminDiskBuild(r.cfg.AdminWebDir); err == nil {
http.ServeFile(w, req, index)
return
} else if !errors.Is(err, os.ErrNotExist) {
log.Printf("admin web disk build is incomplete: %v", err)
}
if serveEmbeddedFile(w, req, "admin/dist/index.html") {
data, err := r.adminAssets.ReadFile("index.html")
if err == nil {
serveAssetContent(w, req, "index.html", data)
return
}
status := r.adminAssets.Diagnostics()
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(`<!doctype html><html><head><meta charset="utf-8"><title>YMhut Admin</title></head><body><main><h1>YMhut Admin</h1><p>Build web/admin to enable the Vue console.</p></main></body></html>`))
w.WriteHeader(http.StatusServiceUnavailable)
_, _ = w.Write([]byte(`<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>管理后台资源不可用</title><style>body{margin:0;background:#f5f7f7;color:#253131;font:14px/1.6 "Segoe UI",sans-serif}main{max-width:680px;margin:12vh auto;padding:28px;background:#fff;border:1px solid #d9e0df;border-radius:8px}h1{font-size:22px;margin:0 0 12px}code{display:block;margin-top:16px;padding:12px;background:#f1f4f3;border-radius:6px;overflow-wrap:anywhere}</style></head><body><main><h1>管理后台资源不可用</h1><p>服务拒绝混用不完整或版本不一致的后台资源。请重新发布同一构建生成的服务二进制。</p><code>` + html.EscapeString(status.Mode+" / "+status.BuildID+" / "+status.ValidationError) + `</code></main></body></html>`))
}
var adminAssetReferencePattern = regexp.MustCompile(`(?:src|href)=["'](/admin/assets/[^"'?#]+)`)
func validateAdminDiskBuild(root string) error {
index := filepath.Join(root, "index.html")
data, err := os.ReadFile(index)
if err != nil {
return err
func serveAssetContent(w http.ResponseWriter, req *http.Request, name string, data []byte) {
if contentType := mime.TypeByExtension(filepath.Ext(name)); contentType != "" {
w.Header().Set("Content-Type", contentType)
}
matches := adminAssetReferencePattern.FindAllSubmatch(data, -1)
if len(matches) == 0 {
return errors.New("index.html does not reference any admin assets")
}
for _, match := range matches {
assetPath := strings.TrimPrefix(string(match[1]), "/admin/")
if strings.Contains(assetPath, "..") || strings.ContainsAny(assetPath, `\`) {
return fmt.Errorf("invalid admin asset reference %s", assetPath)
}
path := filepath.Join(root, filepath.FromSlash(assetPath))
info, statErr := os.Stat(path)
if statErr != nil {
return fmt.Errorf("missing %s: %w", assetPath, statErr)
}
if info.IsDir() {
return fmt.Errorf("%s is not a file", assetPath)
}
}
return nil
http.ServeContent(w, req, filepath.Base(name), time.Time{}, bytes.NewReader(data))
}
var hashedAssetPattern = regexp.MustCompile(`-[A-Za-z0-9_-]{8,}\.[A-Za-z0-9]+$`)
func setStaticCacheHeaders(w http.ResponseWriter, assetPath string) {
extension := strings.ToLower(filepath.Ext(assetPath))
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" {
if strings.HasPrefix(filepath.ToSlash(assetPath), "assets/") && extension != ".ico" && hashedAssetPattern.MatchString(filepath.Base(assetPath)) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
return
}