package web import ( "context" "encoding/json" "errors" "net" "net/http" "strings" "time" "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/legacy" "ymhut-box/server/unified-management/internal/notices" "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 syncer *synclegacy.Service publicSnapshots *publicSnapshotService } 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, publicSnapshots: newPublicSnapshotService(60 * time.Second), } for _, item := range optional { switch typed := item.(type) { case *notices.Service: r.notices = typed case *synclegacy.Service: r.syncer = typed } } return withSecurity(r) } func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { path := cleanPath(req.URL.Path) 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() 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 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 } 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) { payload, err := r.auth.Bootstrap(req.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "BOOTSTRAP_FAILED", err) return } 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 (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) }