301 lines
11 KiB
Go
301 lines
11 KiB
Go
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)
|
|
}
|