feat: complete 2.0.7.12 platform overhaul
This commit is contained in:
@@ -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