YMhut Admin
Build web/admin to enable the Vue console.
diff --git a/installer/ymhut_box_winui.iss b/installer/ymhut_box_winui.iss index 2356ce2..f1b3590 100644 --- a/installer/ymhut_box_winui.iss +++ b/installer/ymhut_box_winui.iss @@ -56,8 +56,12 @@ DefaultGroupName={#MyAppName} OutputDir=..\installer_output OutputBaseFilename=YMhut_Box_WinUI_Setup_{#MyAppVersion} Compression=lzma2 -CompressionThreads=auto -LZMADictionarySize=1048576 +; Preserve a high compression ratio without returning to the 1 GiB dictionary, +; which requires more than 11 GiB of working memory for this solid payload. +CompressionThreads=1 +LZMADictionarySize=262144 +LZMANumFastBytes=273 +LZMAUseSeparateProcess=yes SolidCompression=yes WizardStyle=modern ShowLanguageDialog=yes @@ -1059,7 +1063,10 @@ begin if (CurProgress > 0) then begin - ProgressPercent := CurProgress * 100 div MaxProgress; + { CurProgress can represent tens or hundreds of millions of bytes. Cast + before multiplying so large payloads do not raise an integer-overflow + runtime error from the progress callback. } + ProgressPercent := (Int64(CurProgress) * 100) div MaxProgress; if ProgressPercent <> LastInstallProgressPercent then begin LastInstallProgressPercent := ProgressPercent; diff --git a/scripts/build-winui.ps1 b/scripts/build-winui.ps1 index a7f5fee..5544594 100644 --- a/scripts/build-winui.ps1 +++ b/scripts/build-winui.ps1 @@ -43,7 +43,6 @@ $ToolStateRoot = Join-Path $Root '.cache\tool_state' $NuGetRoot = Join-Path $Root '.cache\nuget' $LocalNuGetFeedRoot = Join-Path $NuGetRoot 'feed' $AppDataRoot = Join-Path $ToolStateRoot 'appdata' -$HomeRoot = Join-Path $ToolStateRoot 'home' $PackageIdentityName = 'YMhut.Box' $PackagePublisher = 'CN=YMhut' @@ -1259,10 +1258,46 @@ function Build-InstallerBootstrap([object] $VersionInfo, [string] $SignTool) { Invoke-ToolQuiet $setupPath @('/SELFTEST') 'WinUI installer bootstrap self-test failed' " Installer self-test: $setupPath" Invoke-ToolQuiet $setupPath @('/WINDOWSELFTEST') 'WinUI installer window navigation self-test failed' " Installer window self-test: $setupPath" } + +function Wait-FileWritable([string] $Path, [int] $Attempts = 20, [int] $DelayMilliseconds = 250) { + for ($attempt = 1; $attempt -le $Attempts; $attempt++) { + $stream = $null + try { + $stream = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::ReadWrite, [IO.FileShare]::None) + return + } catch { + if ($attempt -eq $Attempts) { + throw "File remained locked after $Attempts attempts: $Path" + } + Start-Sleep -Milliseconds $DelayMilliseconds + } finally { + if ($stream) { + $stream.Dispose() + } + } + } +} + function Sign-Artifact([string] $SignTool, [string] $Path) { Ensure-LocalDeveloperCertificate - Invoke-ToolQuiet $SignTool @('sign', '/fd', 'SHA256', '/f', $PfxPath, '/p', $PfxPassword, $Path) "Signing failed for $Path" " Signed: $Path" - Assert-ArtifactSignatureMatchesCertificate $Path + $lastError = $null + for ($attempt = 1; $attempt -le 5; $attempt++) { + try { + Wait-FileWritable $Path + Invoke-ToolQuiet $SignTool @('sign', '/fd', 'SHA256', '/f', $PfxPath, '/p', $PfxPassword, $Path) "Signing failed for $Path" " Signed: $Path" + Assert-ArtifactSignatureMatchesCertificate $Path + return + } catch { + $lastError = $_ + if ($attempt -eq 5) { + throw + } + Write-Warning "Signing attempt $attempt failed for '$Path'; retrying after Windows releases the file handle." + Start-Sleep -Milliseconds (500 * $attempt) + } + } + + throw $lastError } function Clear-PeCertificateDirectory([string] $Path) { @@ -1432,13 +1467,11 @@ pause } New-Directory $AppDataRoot -New-Directory $HomeRoot New-Directory $NuGetRoot New-Directory $LocalNuGetFeedRoot New-Directory $OutputRoot $env:APPDATA = $AppDataRoot $env:LOCALAPPDATA = $AppDataRoot -$env:HOME = $HomeRoot $env:DOTNET_CLI_HOME = Join-Path $ToolStateRoot 'dotnet' $env:NUGET_PACKAGES = $NuGetRoot $env:DOTNET_CLI_TELEMETRY_OPTOUT = '1' @@ -1456,7 +1489,7 @@ Ensure-MsixAssets Invoke-DotNet @( 'restore', $Solution, - '--source', $LocalNuGetFeedRoot, + '--configfile', $NuGetConfig, '--ignore-failed-sources', '--disable-parallel' ) @@ -1465,7 +1498,7 @@ foreach ($ridProject in @($Project, $InstallerBootstrapProject)) { 'restore', $ridProject, '-r', 'win-x64', - '--source', $LocalNuGetFeedRoot, + '--configfile', $NuGetConfig, '--ignore-failed-sources', '--disable-parallel' ) diff --git a/server/unified-management/README.md b/server/unified-management/README.md index eb702bd..b2f4204 100644 --- a/server/unified-management/README.md +++ b/server/unified-management/README.md @@ -2,6 +2,12 @@ This service unifies the old `server/update` and `server/feedback-mailer` projects. +## Nginx Reverse Proxy + +Production deployments should start from `deploy/nginx/unified-management.conf.example`. Set its domain, TLS settings, upstream port, and `/downloads/` alias for the current machine. Keep `client_max_body_size` at least 8 MiB above `YMHUT_RELEASE_UPLOAD_MAX_BYTES` for multipart overhead; the default service limit is 1 GiB. + +The upload location disables request buffering so packages stream directly to Go and uses a 30-minute timeout. The download location lets Nginx send immutable package files without routing their bytes through the service. Run `nginx -t` before reloading a control-panel generated configuration. + ## What It Provides - Go backend with SQLite by default. @@ -54,26 +60,29 @@ $env:YMHUT_GOPROXY="https://your-proxy.example,direct" ## Frontend Build Check -Startup preflight checks both built web apps: +Startup preflight checks the selected admin asset source and both other built web apps. The admin build must include: - `web/admin/dist/index.html` -- `web/admin/dist/assets` +- `web/admin/dist/asset-manifest.json` +- `web/admin/dist/admin-build.json` +- every entry, CSS file, asset, import, and dynamic import named by the manifest - `web/portal/dist/index.html` - `web/portal/dist/assets` -If either frontend is missing, the service still starts, but the preflight log tells you to run the frontend build. Release binaries built with `embed_web` report that embedded frontend assets are being used instead. Build the local dist files with: +Release binaries built with `embed_web` default to the embedded admin build and never mix it with files from disk. Non-embedded development builds default to disk. Override this explicitly with `YMHUT_ADMIN_ASSET_MODE=embedded|disk`; an invalid or incomplete selected source is reported through `/api/admin/system/health` and is not replaced by files from the other source. Build the local dist files with: ```powershell cd server\unified-management\web\admin npm install npm run build +npm run validate:build cd ..\portal npm install npm run build ``` -The Go server serves portal assets from `/assets/*` and admin assets from `/admin/assets/*`. Disk `dist` files are preferred during development; embedded assets are used as the release fallback. +The Go server serves portal assets from `/assets/*` and admin assets from `/admin/assets/*`. Admin HTML is always `no-store`, hashed assets are immutable, and missing assets return 404 rather than the SPA shell. Production deployment replaces the single embedded service binary; do not copy `web/admin/dist` over a production binary. ## Release Binaries diff --git a/server/unified-management/cmd/unified-management/app/app.go b/server/unified-management/cmd/unified-management/app/app.go index 2284a89..d9b1d96 100644 --- a/server/unified-management/cmd/unified-management/app/app.go +++ b/server/unified-management/cmd/unified-management/app/app.go @@ -54,6 +54,8 @@ func Run() { log.Printf("update notice import skipped: %v", err) } releaseService := releases.NewService(cfg, store, noticeService) + releaseService.Start(context.Background()) + defer releaseService.Stop() sourceService := sources.NewService(cfg, store) feedbackService := feedback.NewService(cfg, store) legacyService := legacy.NewService(cfg, store) diff --git a/server/unified-management/deploy/nginx/unified-management.conf.example b/server/unified-management/deploy/nginx/unified-management.conf.example new file mode 100644 index 0000000..9dbe2b8 --- /dev/null +++ b/server/unified-management/deploy/nginx/unified-management.conf.example @@ -0,0 +1,86 @@ +upstream ymhut_unified_management { + server 127.0.0.1:33550; + keepalive 16; +} + +server { + listen 443 ssl http2; + server_name update.example.com; + + # Default package limit (1 GiB) plus multipart form overhead. + # Increase both this value and YMHUT_RELEASE_UPLOAD_MAX_BYTES together. + client_max_body_size 1032m; + + location /downloads/ { + # Replace this path with the configured DownloadsDir. + alias /srv/ymhut/data/update/public/downloads/; + sendfile on; + tcp_nopush on; + etag on; + add_header Cache-Control "public, max-age=300" always; + } + + location = /api/admin/releases/packages { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_request_buffering off; + proxy_buffering off; + proxy_read_timeout 30m; + proxy_send_timeout 30m; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location = /api/admin/events { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 1h; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Hashed Vite assets are immutable. A missing asset remains a real 404; + # never route it back to the SPA HTML shell. + location ~* ^/admin/assets/.+-[A-Za-z0-9_-]{8,}\.(?:js|css|woff2?|ttf|eot|svg|png|webp)$ { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_intercept_errors off; + add_header Cache-Control "public, max-age=31536000, immutable"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /admin/ { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_intercept_errors off; + add_header Cache-Control "no-store" always; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location / { + proxy_pass http://ymhut_unified_management; + proxy_http_version 1.1; + proxy_read_timeout 60s; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/server/unified-management/internal/adminassets/adminassets.go b/server/unified-management/internal/adminassets/adminassets.go new file mode 100644 index 0000000..564344a --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets.go @@ -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) +} diff --git a/server/unified-management/internal/adminassets/adminassets_embed_test.go b/server/unified-management/internal/adminassets/adminassets_embed_test.go new file mode 100644 index 0000000..9327bb0 --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets_embed_test.go @@ -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) + } +} diff --git a/server/unified-management/internal/adminassets/adminassets_test.go b/server/unified-management/internal/adminassets/adminassets_test.go new file mode 100644 index 0000000..de844f7 --- /dev/null +++ b/server/unified-management/internal/adminassets/adminassets_test.go @@ -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", ``) + 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", "") + 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) + } +} diff --git a/server/unified-management/internal/auth/auth.go b/server/unified-management/internal/auth/auth.go index ed353b8..ada82c7 100644 --- a/server/unified-management/internal/auth/auth.go +++ b/server/unified-management/internal/auth/auth.go @@ -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 } diff --git a/server/unified-management/internal/auth/auth_test.go b/server/unified-management/internal/auth/auth_test.go index 6e0b9e0..c67a28a 100644 --- a/server/unified-management/internal/auth/auth_test.go +++ b/server/unified-management/internal/auth/auth_test.go @@ -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) } } diff --git a/server/unified-management/internal/config/config.go b/server/unified-management/internal/config/config.go index 5e06b93..a97c336 100644 --- a/server/unified-management/internal/config/config.go +++ b/server/unified-management/internal/config/config.go @@ -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") } diff --git a/server/unified-management/internal/config/config_test.go b/server/unified-management/internal/config/config_test.go index 00258ad..76a30ed 100644 --- a/server/unified-management/internal/config/config_test.go +++ b/server/unified-management/internal/config/config_test.go @@ -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] diff --git a/server/unified-management/internal/config/preflight.go b/server/unified-management/internal/config/preflight.go index 048c4c8..5841a32 100644 --- a/server/unified-management/internal/config/preflight.go +++ b/server/unified-management/internal/config/preflight.go @@ -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 { diff --git a/server/unified-management/internal/db/admin_performance_test.go b/server/unified-management/internal/db/admin_performance_test.go new file mode 100644 index 0000000..40bddfe --- /dev/null +++ b/server/unified-management/internal/db/admin_performance_test.go @@ -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 +} diff --git a/server/unified-management/internal/db/audit_store.go b/server/unified-management/internal/db/audit_store.go index 2de4bca..ae15b68 100644 --- a/server/unified-management/internal/db/audit_store.go +++ b/server/unified-management/internal/db/audit_store.go @@ -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) } diff --git a/server/unified-management/internal/db/feedback_store.go b/server/unified-management/internal/db/feedback_store.go index eeee1c4..082a38d 100644 --- a/server/unified-management/internal/db/feedback_store.go +++ b/server/unified-management/internal/db/feedback_store.go @@ -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 } diff --git a/server/unified-management/internal/db/release_store.go b/server/unified-management/internal/db/release_store.go index 64e6542..8fe69f5 100644 --- a/server/unified-management/internal/db/release_store.go +++ b/server/unified-management/internal/db/release_store.go @@ -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) diff --git a/server/unified-management/internal/db/scanner_util.go b/server/unified-management/internal/db/scanner_util.go index 0089f63..0962741 100644 --- a/server/unified-management/internal/db/scanner_util.go +++ b/server/unified-management/internal/db/scanner_util.go @@ -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, diff --git a/server/unified-management/internal/db/schema.go b/server/unified-management/internal/db/schema.go index b1f8345..614aed0 100644 --- a/server/unified-management/internal/db/schema.go +++ b/server/unified-management/internal/db/schema.go @@ -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 } diff --git a/server/unified-management/internal/db/system_logs.go b/server/unified-management/internal/db/system_logs.go index 2591997..c6b9006 100644 --- a/server/unified-management/internal/db/system_logs.go +++ b/server/unified-management/internal/db/system_logs.go @@ -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 } diff --git a/server/unified-management/internal/health/health.go b/server/unified-management/internal/health/health.go index 7750031..36f80b3 100644 --- a/server/unified-management/internal/health/health.go +++ b/server/unified-management/internal/health/health.go @@ -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() +} diff --git a/server/unified-management/internal/health/health_test.go b/server/unified-management/internal/health/health_test.go new file mode 100644 index 0000000..3ff3008 --- /dev/null +++ b/server/unified-management/internal/health/health_test.go @@ -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) + } +} diff --git a/server/unified-management/internal/reference/data/cpu-ranking.json b/server/unified-management/internal/reference/data/cpu-ranking.json new file mode 100644 index 0000000..70fe67b --- /dev/null +++ b/server/unified-management/internal/reference/data/cpu-ranking.json @@ -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" } + ] +} diff --git a/server/unified-management/internal/reference/data/gpu-ranking.json b/server/unified-management/internal/reference/data/gpu-ranking.json new file mode 100644 index 0000000..dfb494d --- /dev/null +++ b/server/unified-management/internal/reference/data/gpu-ranking.json @@ -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" } + ] +} diff --git a/server/unified-management/internal/reference/reference.go b/server/unified-management/internal/reference/reference.go new file mode 100644 index 0000000..ebd621d --- /dev/null +++ b/server/unified-management/internal/reference/reference.go @@ -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 + } +} diff --git a/server/unified-management/internal/reference/reference_test.go b/server/unified-management/internal/reference/reference_test.go new file mode 100644 index 0000000..024b4b4 --- /dev/null +++ b/server/unified-management/internal/reference/reference_test.go @@ -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) + } +} diff --git a/server/unified-management/internal/releases/releases.go b/server/unified-management/internal/releases/releases.go index 2bbe1fa..2fd5f31 100644 --- a/server/unified-management/internal/releases/releases.go +++ b/server/unified-management/internal/releases/releases.go @@ -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 { diff --git a/server/unified-management/internal/releases/releases_test.go b/server/unified-management/internal/releases/releases_test.go index 2754eaa..ab09b62 100644 --- a/server/unified-management/internal/releases/releases_test.go +++ b/server/unified-management/internal/releases/releases_test.go @@ -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{ diff --git a/server/unified-management/internal/sources/sources.go b/server/unified-management/internal/sources/sources.go index 342874b..a223b85 100644 --- a/server/unified-management/internal/sources/sources.go +++ b/server/unified-management/internal/sources/sources.go @@ -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()) } diff --git a/server/unified-management/internal/web/admin_feedback_routes.go b/server/unified-management/internal/web/admin_feedback_routes.go index 5c72550..3ee613a 100644 --- a/server/unified-management/internal/web/admin_feedback_routes.go +++ b/server/unified-management/internal/web/admin_feedback_routes.go @@ -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"), diff --git a/server/unified-management/internal/web/admin_release_routes.go b/server/unified-management/internal/web/admin_release_routes.go index 6f73377..9164f5a 100644 --- a/server/unified-management/internal/web/admin_release_routes.go +++ b/server/unified-management/internal/web/admin_release_routes.go @@ -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) { diff --git a/server/unified-management/internal/web/admin_system_routes.go b/server/unified-management/internal/web/admin_system_routes.go index e8313c3..004e375 100644 --- a/server/unified-management/internal/web/admin_system_routes.go +++ b/server/unified-management/internal/web/admin_system_routes.go @@ -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 { diff --git a/server/unified-management/internal/web/client_routes.go b/server/unified-management/internal/web/client_routes.go index 341d4e7..aedacf0 100644 --- a/server/unified-management/internal/web/client_routes.go +++ b/server/unified-management/internal/web/client_routes.go @@ -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 { diff --git a/server/unified-management/internal/web/dashboard_cache.go b/server/unified-management/internal/web/dashboard_cache.go new file mode 100644 index 0000000..690e204 --- /dev/null +++ b/server/unified-management/internal/web/dashboard_cache.go @@ -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() +} diff --git a/server/unified-management/internal/web/dashboard_cache_test.go b/server/unified-management/internal/web/dashboard_cache_test.go new file mode 100644 index 0000000..3e70243 --- /dev/null +++ b/server/unified-management/internal/web/dashboard_cache_test.go @@ -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) + } +} diff --git a/server/unified-management/internal/web/public_snapshot.go b/server/unified-management/internal/web/public_snapshot.go index 33d6d66..30b38ca 100644 --- a/server/unified-management/internal/web/public_snapshot.go +++ b/server/unified-management/internal/web/public_snapshot.go @@ -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) diff --git a/server/unified-management/internal/web/response.go b/server/unified-management/internal/web/response.go index 0b97872..243da1f 100644 --- a/server/unified-management/internal/web/response.go +++ b/server/unified-management/internal/web/response.go @@ -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) != "" { diff --git a/server/unified-management/internal/web/router.go b/server/unified-management/internal/web/router.go index 1dec8ab..134b021 100644 --- a/server/unified-management/internal/web/router.go +++ b/server/unified-management/internal/web/router.go @@ -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() diff --git a/server/unified-management/internal/web/router_test.go b/server/unified-management/internal/web/router_test.go index 1caddcf..b4b26ae 100644 --- a/server/unified-management/internal/web/router_test.go +++ b/server/unified-management/internal/web/router_test.go @@ -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(``), 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", diff --git a/server/unified-management/internal/web/static_routes.go b/server/unified-management/internal/web/static_routes.go index 31910bb..749fc3d 100644 --- a/server/unified-management/internal/web/static_routes.go +++ b/server/unified-management/internal/web/static_routes.go @@ -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(`
Build web/admin to enable the Vue console.
服务拒绝混用不完整或版本不一致的后台资源。请重新发布同一构建生成的服务二进制。
` + html.EscapeString(status.Mode+" / "+status.BuildID+" / "+status.ValidationError) + `{{ branding.siteName }}
-{{ branding.adminSubtitle }}。验证码和密码都由服务端校验,登录后写操作继续要求 CSRF Token。
-- 当前使用默认账号:{{ authBootstrap.defaultUsername || "admin" }} / {{ authBootstrap.defaultPassword || "admin" }} -
- -