Files
YMhut-box-C-/server/unified-management/internal/web/router.go
T

384 lines
14 KiB
Go

package web
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"
)
type router struct {
cfg *config.Config
store *db.Store
auth *auth.Service
feedback *feedback.Service
releases *releases.Service
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
func NewRouter(cfg *config.Config, store *db.Store, authService *auth.Service, feedbackService *feedback.Service, releaseService *releases.Service, sourceService *sources.Service, legacyService *legacy.Service, optional ...any) http.Handler {
r := &router{
cfg: cfg,
store: store,
auth: authService,
feedback: feedbackService,
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) {
case *notices.Service:
r.notices = typed
case *synclegacy.Service:
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" &&
req.Method != http.MethodGet && req.Method != http.MethodHead {
captured := &mutationResponseWriter{ResponseWriter: w, status: http.StatusOK}
w = captured
defer func() {
if captured.status >= http.StatusBadRequest {
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)})
}
}()
}
switch {
case path == "/" && req.Method == http.MethodPost:
r.handleFeedbackSubmit(w, req)
case path == "/" && req.URL.Query().Get("api") == "status":
r.handleFeedbackStatus(w, req)
case isPortalRoute(path):
r.servePortal(w, req)
case path == "/api/auth/bootstrap" || path == "/api/admin/auth/bootstrap":
r.handleAuthBootstrap(w, req)
case path == "/api/auth/captcha" || path == "/api/admin/auth/captcha":
r.handleCaptcha(w, req)
case path == "/api/auth/login" || path == "/api/admin/auth/login":
r.handleLogin(w, req)
case path == "/api/auth/logout" || path == "/api/admin/auth/logout":
r.auth.Require(http.HandlerFunc(r.handleLogout)).ServeHTTP(w, req)
case path == "/api/admin/auth/password":
r.auth.Require(http.HandlerFunc(r.handleChangePassword)).ServeHTTP(w, req)
case path == "/api/client/bootstrap":
r.handleClientBootstrap(w, req)
case path == "/favicon.ico" || path == "/admin/favicon.ico":
r.serveServerAsset(w, req, "favicon.ico")
case path == "/assets/favicon.ico":
r.serveServerAsset(w, req, "favicon.ico")
case path == "/assets/developer-avatar.png":
r.serveServerAsset(w, req, "developer-avatar.png")
case path == "/api/client/releases" || path == "/api/releases" || path == "/api/update-info":
baseURL := requestBaseURL(req, r.cfg.BaseURL)
snapshot := r.publicSnapshots.Get("releases|"+baseURL, func(_ time.Time) any { return r.releases.Manifest(req) })
writePublicSnapshot(w, req, snapshot)
case path == "/api/client/sources":
r.handleClientSources(w, req)
case path == "/api/client/endpoints":
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":
writeJSON(w, http.StatusOK, r.releases.LegacyUpdateInfo(req))
case path == "/tool-status.json" || path == "/tool-status":
writeJSON(w, http.StatusOK, r.releases.StaticJSON("tool-status.json"))
case path == "/modules.json" || path == "/modules" || path == "/api/modules":
writeJSON(w, http.StatusOK, r.releases.StaticJSON("modules.json"))
case path == "/media-types.json" || path == "/media-types":
r.handleLegacyMediaTypes(w, req)
case strings.HasPrefix(path, "/downloads/"):
r.handleDownload(w, req)
case strings.HasPrefix(path, "/admin/assets/"):
r.serveAdminAsset(w, req, strings.TrimPrefix(path, "/admin/"))
case strings.HasPrefix(path, "/assets/"):
serveStaticAsset(w, req, r.cfg.PortalWebDir, "portal/dist", strings.TrimPrefix(path, "/"))
case strings.HasPrefix(path, "/api/admin/feedbacks"):
r.auth.Require(http.HandlerFunc(r.handleAdminFeedbacks)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/dashboard"):
r.auth.Require(http.HandlerFunc(r.handleAdminDashboard)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/sync"):
r.auth.Require(http.HandlerFunc(r.handleAdminSync)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/releases"):
r.auth.Require(http.HandlerFunc(r.handleAdminReleases)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/sources"):
r.auth.Require(http.HandlerFunc(r.handleAdminSources)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/endpoints"):
r.auth.Require(http.HandlerFunc(r.handleAdminEndpoints)).ServeHTTP(w, req)
case path == "/api/admin/events":
r.auth.Require(http.HandlerFunc(r.handleAdminEvents)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/legacy"):
r.auth.Require(http.HandlerFunc(r.handleAdminLegacy)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/database"):
r.auth.Require(http.HandlerFunc(r.handleAdminDatabase)).ServeHTTP(w, req)
case strings.HasPrefix(path, "/api/admin/system"):
r.auth.Require(http.HandlerFunc(r.handleAdminSystem)).ServeHTTP(w, req)
case path == "/admin" || path == "/admin/":
http.Redirect(w, req, "/admin/dashboard", http.StatusFound)
case path == "/admin/login" || strings.HasPrefix(path, "/admin/"):
r.serveAdmin(w, req)
default:
http.NotFound(w, req)
}
}
type mutationResponseWriter struct {
http.ResponseWriter
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)
}
func adminMutationEvent(path string) string {
switch {
case strings.HasPrefix(path, "/api/admin/releases"), strings.HasPrefix(path, "/api/admin/legacy"):
return "release.changed"
case strings.HasPrefix(path, "/api/admin/sources"), strings.HasPrefix(path, "/api/admin/endpoints"):
return "source.changed"
case path == "/api/admin/system/branding":
return "branding.changed"
case strings.HasPrefix(path, "/api/admin/feedbacks"):
return "feedback.changed"
default:
return "cache.invalidated"
}
}
func (r *router) handleAuthBootstrap(w http.ResponseWriter, req *http.Request) {
_, _, 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)
}
func (r *router) handleCaptcha(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("GET required"))
return
}
w.Header().Set("Cache-Control", "no-store")
captcha, err := r.auth.NewCaptcha()
if err != nil {
writeError(w, http.StatusInternalServerError, "CAPTCHA_FAILED", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "captchaId": captcha.ID, "image": captcha.Image})
}
func (r *router) handleLogin(w http.ResponseWriter, req *http.Request) {
if req.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", errors.New("POST required"))
return
}
w.Header().Set("Cache-Control", "no-store")
req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
var body struct {
Username string `json:"username"`
Password string `json:"password"`
CaptchaID string `json:"captchaId"`
Captcha string `json:"captcha"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
if body.Username == "" {
body.Username = "admin"
}
ctx, cancel := context.WithTimeout(req.Context(), loginRequestTimeout)
defer cancel()
clientAddress := remoteHost(req.RemoteAddr)
sessionID, csrf, failure, err := r.auth.LoginDetailed(ctx, body.Username, body.Password, body.CaptchaID, body.Captcha, clientAddress)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
writeError(w, http.StatusGatewayTimeout, "LOGIN_TIMEOUT", errors.New("login verification timed out"))
return
}
writeError(w, http.StatusInternalServerError, "LOGIN_FAILED", err)
return
}
if failure != auth.LoginFailureNone {
code := "LOGIN_FAILED"
message := "invalid username or password"
switch failure {
case auth.LoginFailureLocked:
code = "LOGIN_LOCKED"
message = "too many login attempts; try again later"
case auth.LoginFailureCaptcha:
code = "CAPTCHA_INVALID"
message = "captcha is invalid or expired"
case auth.LoginFailureCredentials:
code = "CREDENTIALS_INVALID"
}
writeError(w, http.StatusOK, code, errors.New(message))
return
}
auth.SetSessionCookieForRequest(w, req, sessionID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "csrfToken": csrf, "user": map[string]any{"username": body.Username}})
go r.recordLoginAudit(body.Username, clientAddress, req.UserAgent())
}
func remoteHost(remoteAddress string) string {
host, _, err := net.SplitHostPort(strings.TrimSpace(remoteAddress))
if err == nil && host != "" {
return host
}
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()
_ = r.store.InsertAuditContext(ctx, db.AuditLog{
Actor: username, Type: "auth.login", Target: "admin", Message: "管理员登录", IP: remoteAddr, UserAgent: userAgent,
})
}
func (r *router) handleLogout(w http.ResponseWriter, req *http.Request) {
r.auth.Logout(w, req)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (r *router) handleChangePassword(w http.ResponseWriter, req *http.Request) {
var body struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if err := json.NewDecoder(req.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "INVALID_PAYLOAD", err)
return
}
warning, err := r.store.ChangeAdminPasswordWithWarning(req.Context(), "admin", body.CurrentPassword, body.NewPassword)
if err != nil {
writeError(w, http.StatusBadRequest, "PASSWORD_CHANGE_FAILED", err)
return
}
_ = r.store.InsertAudit(db.AuditLog{Actor: "admin", Type: "auth.password_changed", Target: "admin", Message: "后台密码已修改", IP: req.RemoteAddr, UserAgent: req.UserAgent()})
payload := map[string]any{"ok": true, "isDefaultPassword": false}
if warning != "" {
payload["warning"] = warning
}
writeJSON(w, http.StatusOK, payload)
}