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