diff --git a/internal/controller/admin/site_setting_controller.go b/internal/controller/admin/site_setting_controller.go index d45f14e..fcd53b3 100644 --- a/internal/controller/admin/site_setting_controller.go +++ b/internal/controller/admin/site_setting_controller.go @@ -21,6 +21,15 @@ func NewSiteSettingController(settingService siteSettingUseCase, defaults *servi return &SiteSettingController{settingService: settingService, defaults: defaults} } +// SiteSettingsPage 站点设置管理页面 +func (sc *SiteSettingController) SiteSettingsPage(c *gin.Context) { + c.HTML(http.StatusOK, "admin/site-settings/index.html", common.BuildAdminPageData(c, gin.H{ + "Title": "站点设置", + "ExtraCSS": "/admin/static/css/site-settings.css", + "ExtraJS": "/admin/static/js/site-settings.js", + })) +} + // GetSettings 获取所有站点设置 func (sc *SiteSettingController) GetSettings(c *gin.Context) { all := sc.settingService.GetSettings() diff --git a/internal/middleware/auth.go b/internal/middleware/auth.go index 8fbe3ef..56ea0b8 100644 --- a/internal/middleware/auth.go +++ b/internal/middleware/auth.go @@ -1,16 +1,10 @@ package middleware import ( - "log" - "net/http" - "strings" - "time" - "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" "github.com/gin-gonic/gin" - "github.com/golang-jwt/jwt/v5" ) // AuthMiddleware 认证中间件(结构体模式,持有 DB 依赖用于实时令牌吊销校验) @@ -25,7 +19,6 @@ func NewAuthMiddleware(cfg *config.Config, userRepo tokenVersionStore) *AuthMidd } // Required 登录认证中间件:校验 JWT → 检查 token_version → 注入用户信息 -// 令牌版本与 DB 不匹配时即时拒绝,实现实时吊销 func (am *AuthMiddleware) Required() gin.HandlerFunc { return func(c *gin.Context) { claims, err := am.authenticateToken(c) @@ -36,122 +29,20 @@ func (am *AuthMiddleware) Required() gin.HandlerFunc { }) return } - injectUserContext(c, claims) c.Next() } } -// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行(仅清除过期 Cookie) +// Optional 可选认证:已登录且版本通过则注入,未登录或版本不匹配也放行 func (am *AuthMiddleware) Optional() gin.HandlerFunc { return func(c *gin.Context) { claims, err := am.authenticateToken(c) if err != nil { - log.Printf("[AuthOptional] token invalid for %s: %v", c.Request.URL.Path, err) c.Next() return } - - log.Printf("[AuthOptional] OK: uid=%v username=%v path=%s", claims["uid"], claims["username"], c.Request.URL.Path) injectUserContext(c, claims) c.Next() } } - -// AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON -func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc { - return func(c *gin.Context) { - claims, err := am.authenticateToken(c) - if err != nil { - log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err) - common.ClearAuthCookies(c, am.cfg) - c.Redirect(http.StatusFound, "/") - c.Abort() - return - } - - log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path) - injectUserContext(c, claims) - c.Next() - } -} - -// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version -// 成功返回 claims,失败返回 error(调用方自行决定如何响应) -func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) { - tokenStr, err := c.Cookie(common.CookieName) - if err != nil { - return nil, err - } - - claims, err := parseToken(tokenStr, am.cfg.JWT.Secret) - if err != nil { - return nil, err - } - - uid := uint(claims["uid"].(float64)) - tokenVer := int(claims["ver"].(float64)) - - currentVer, err := am.userRepo.FindTokenVersion(uid) - if err != nil { - return nil, err - } - if tokenVer != currentVer { - log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer) - return nil, common.ErrTokenRevoked - } - - return claims, nil -} - -// injectUserContext 将 JWT claims 中的用户信息注入 gin context -func injectUserContext(c *gin.Context, claims jwt.MapClaims) { - if claims == nil { - return - } - uid, ok := claims["uid"].(float64) - if !ok { - return - } - c.Set("uid", uint(uid)) - c.Set("email", claims["email"]) - c.Set("username", claims["username"]) - c.Set("role", claims["role"]) -} - -// parseToken 解析并验证 JWT -func parseToken(tokenStr, secret string) (jwt.MapClaims, error) { - now := time.Now() - token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { - return []byte(secret), nil - }) - if err != nil || !token.Valid { - log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)", - now, err, len(secret), len(tokenStr)) - return nil, err - } - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - return nil, jwt.ErrSignatureInvalid - } - // Debug: 打印 exp/iat 对比当前时间 - if exp, exists := claims["exp"]; exists { - var expTime time.Time - switch v := exp.(type) { - case float64: - expTime = time.Unix(int64(v), 0) - case *jwt.NumericDate: - expTime = v.Time - } - if !expTime.IsZero() { - log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v", - expTime, expTime.Unix(), now, now.After(expTime)) - } - } - return claims, nil -} - -// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页 -func IsLoginPage(c *gin.Context) bool { - return strings.HasPrefix(c.Request.URL.Path, "/auth/") -} diff --git a/internal/middleware/auth_admin.go b/internal/middleware/auth_admin.go new file mode 100644 index 0000000..778a312 --- /dev/null +++ b/internal/middleware/auth_admin.go @@ -0,0 +1,27 @@ +package middleware + +import ( + "log" + "net/http" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" +) + +// AdminAuth 管理后台页面认证:失败时 302 跳首页而非返回 JSON +func (am *AuthMiddleware) AdminAuth() gin.HandlerFunc { + return func(c *gin.Context) { + claims, err := am.authenticateToken(c) + if err != nil { + log.Printf("[AdminAuth] auth failed path=%s err=%v → 302", c.Request.URL.Path, err) + common.ClearAuthCookies(c, am.cfg) + c.Redirect(http.StatusFound, "/") + c.Abort() + return + } + log.Printf("[AdminAuth] OK: uid=%v role=%v path=%s", claims["uid"], claims["role"], c.Request.URL.Path) + injectUserContext(c, claims) + c.Next() + } +} diff --git a/internal/middleware/auth_parser.go b/internal/middleware/auth_parser.go new file mode 100644 index 0000000..353ed3a --- /dev/null +++ b/internal/middleware/auth_parser.go @@ -0,0 +1,46 @@ +package middleware + +import ( + "log" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// parseToken 解析并验证 JWT +func parseToken(tokenStr, secret string) (jwt.MapClaims, error) { + now := time.Now() + token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) { + return []byte(secret), nil + }) + if err != nil || !token.Valid { + log.Printf("[parseToken] FAIL: now=%v err=%v (secret_len=%d token_len=%d)", + now, err, len(secret), len(tokenStr)) + return nil, err + } + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return nil, jwt.ErrSignatureInvalid + } + if exp, exists := claims["exp"]; exists { + var expTime time.Time + switch v := exp.(type) { + case float64: + expTime = time.Unix(int64(v), 0) + case *jwt.NumericDate: + expTime = v.Time + } + if !expTime.IsZero() { + log.Printf("[parseToken] OK: exp=%v (%d) now=%v isExpired=%v", + expTime, expTime.Unix(), now, now.After(expTime)) + } + } + return claims, nil +} + +// IsLoginPage 检查是否已在登录状态,已登录用户跳过登录/注册页 +func IsLoginPage(c *gin.Context) bool { + return strings.HasPrefix(c.Request.URL.Path, "/auth/") +} diff --git a/internal/middleware/auth_token.go b/internal/middleware/auth_token.go new file mode 100644 index 0000000..fe6e332 --- /dev/null +++ b/internal/middleware/auth_token.go @@ -0,0 +1,48 @@ +package middleware + +import ( + "log" + + "metazone.cc/metalab/internal/common" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +// authenticateToken 统一的认证核心流程:读 Cookie → 解析 JWT → 检查 token_version +func (am *AuthMiddleware) authenticateToken(c *gin.Context) (jwt.MapClaims, error) { + tokenStr, err := c.Cookie(common.CookieName) + if err != nil { + return nil, err + } + claims, err := parseToken(tokenStr, am.cfg.JWT.Secret) + if err != nil { + return nil, err + } + uid := uint(claims["uid"].(float64)) + tokenVer := int(claims["ver"].(float64)) + currentVer, err := am.userRepo.FindTokenVersion(uid) + if err != nil { + return nil, err + } + if tokenVer != currentVer { + log.Printf("[authenticateToken] REVOKED: uid=%d tokenVer=%d dbVer=%d", uid, tokenVer, currentVer) + return nil, common.ErrTokenRevoked + } + return claims, nil +} + +// injectUserContext 将 JWT claims 中的用户信息注入 gin context +func injectUserContext(c *gin.Context, claims jwt.MapClaims) { + if claims == nil { + return + } + uid, ok := claims["uid"].(float64) + if !ok { + return + } + c.Set("uid", uint(uid)) + c.Set("email", claims["email"]) + c.Set("username", claims["username"]) + c.Set("role", claims["role"]) +} diff --git a/internal/middleware/csrf.go b/internal/middleware/csrf.go index 75b3cc6..7a8e533 100644 --- a/internal/middleware/csrf.go +++ b/internal/middleware/csrf.go @@ -1,8 +1,6 @@ package middleware import ( - "crypto/rand" - "encoding/hex" "net/http" "metazone.cc/metalab/internal/config" @@ -14,15 +12,11 @@ const ( csrfCookieName = "mlb_csrf" csrfHeaderName = "X-CSRF-Token" csrfMetaName = "csrf_token" - csrfTokenLen = 32 // 字节 ) // CSRF 中间件:Double Submit Cookie 模式 -// 前端 JS 从 Cookie 中读取 token 并放入 X-CSRF-Token 请求头,后端验证两者一致 -// GET / HEAD / OPTIONS 请求自动放行 func CSRF(cfg *config.Config) gin.HandlerFunc { return func(c *gin.Context) { - // 安全读取豁免 if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodHead || c.Request.Method == http.MethodOptions { @@ -39,7 +33,6 @@ func CSRF(cfg *config.Config) gin.HandlerFunc { } headerToken := c.GetHeader(csrfHeaderName) - // Fallback:如果请求头没有,尝试从表单字段读取(纯 HTML form 提交通道) if headerToken == "" { headerToken = c.PostForm(csrfMetaName) } @@ -50,7 +43,6 @@ func CSRF(cfg *config.Config) gin.HandlerFunc { return } - // 恒定时间比较防时序攻击 if !constantTimeEq(cookieToken, headerToken) { c.AbortWithStatusJSON(http.StatusForbidden, gin.H{ "success": false, "message": "CSRF 验证失败", @@ -58,55 +50,7 @@ func CSRF(cfg *config.Config) gin.HandlerFunc { return } - // 注入到上下文,供前端 使用 c.Set(csrfMetaName, cookieToken) c.Next() } } - -// SetCSRFToken 在首次页面访问时下发 CSRF Cookie(由页面路由中间件调用) -// 注意:此 Cookie HttpOnly=false,JS 可读——这是 Double Submit 模式的必要条件 -func SetCSRFToken(c *gin.Context, cfg *config.Config) string { - secure := cfg.Server.Mode != "debug" - - // 如果已有 token 且未过期,复用(但必须注入 context,供模板 meta 标签使用) - if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" { - c.Set(csrfMetaName, existing) - return existing - } - - token, err := generateCSRFToken() - if err != nil { - // 极端情况:随机数生成失败,使用短 token - token = "fallback-" + hex.EncodeToString([]byte("metazone")) - } - - c.SetSameSite(http.SameSiteStrictMode) - // 30 天有效期,与 refresh token 对齐 - maxAge := int(cfg.JWT.RememberExpire * 3600) - c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false) - - c.Set(csrfMetaName, token) - return token -} - -// generateCSRFToken 生成 64 字符十六进制随机 CSRF token -func generateCSRFToken() (string, error) { - b := make([]byte, csrfTokenLen) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - -// constantTimeEq 恒定时间字符串比较(防时序攻击) -func constantTimeEq(a, b string) bool { - if len(a) != len(b) { - return false - } - var result byte - for i := 0; i < len(a); i++ { - result |= a[i] ^ b[i] - } - return result == 0 -} diff --git a/internal/middleware/csrf_token.go b/internal/middleware/csrf_token.go new file mode 100644 index 0000000..f37795d --- /dev/null +++ b/internal/middleware/csrf_token.go @@ -0,0 +1,54 @@ +package middleware + +import ( + "crypto/rand" + "encoding/hex" + "net/http" + + "metazone.cc/metalab/internal/config" + + "github.com/gin-gonic/gin" +) + +const csrfTokenLen = 32 + +// SetCSRFToken 在首次页面访问时下发 CSRF Cookie(由页面路由中间件调用) +func SetCSRFToken(c *gin.Context, cfg *config.Config) string { + secure := cfg.Server.Mode != "debug" + if existing, err := c.Cookie(csrfCookieName); err == nil && existing != "" { + c.Set(csrfMetaName, existing) + return existing + } + + token, err := generateCSRFToken() + if err != nil { + token = "fallback-" + hex.EncodeToString([]byte("metazone")) + } + + c.SetSameSite(http.SameSiteStrictMode) + maxAge := int(cfg.JWT.RememberExpire * 3600) + c.SetCookie(csrfCookieName, token, maxAge, "/", "", secure, false) + c.Set(csrfMetaName, token) + return token +} + +// generateCSRFToken 生成 64 字符十六进制随机 CSRF token +func generateCSRFToken() (string, error) { + b := make([]byte, csrfTokenLen) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// constantTimeEq 恒定时间字符串比较(防时序攻击) +func constantTimeEq(a, b string) bool { + if len(a) != len(b) { + return false + } + var result byte + for i := 0; i < len(a); i++ { + result |= a[i] ^ b[i] + } + return result == 0 +} diff --git a/internal/middleware/ratelimit.go b/internal/middleware/ratelimit.go index 1a084d6..366fb20 100644 --- a/internal/middleware/ratelimit.go +++ b/internal/middleware/ratelimit.go @@ -1,47 +1,16 @@ package middleware -import ( - "fmt" - "sync" - "time" -) +import "sync" // RateLimiter 双维度滑动窗口登录限流 // 维度一(账户):同邮箱 1 分钟内失败 3 次 → 锁定 1 分钟 // 维度二(IP):同 IP 1 分钟内失败 10 次 → 锁定 1 分钟 type RateLimiter struct { - mu sync.RWMutex - // accountFailures key = 邮箱(小写) + mu sync.RWMutex accountFailures map[string]*windowState - // ipFailures key = IP - ipFailures map[string]*windowState + ipFailures map[string]*windowState } -// windowState 单个维度的限流状态 -type windowState struct { - count int - windowStart time.Time - blockedUntil time.Time // 零值为未封锁 -} - -// RateLimitResult 限流检查结果 -type RateLimitResult struct { - Blocked bool - RetryAfter int // 剩余封锁秒数 - Message string -} - -const ( - accountWindow = 1 * time.Minute - accountMaxFails = 3 - accountBlockDur = 1 * time.Minute - ipWindow = 1 * time.Minute - ipMaxFails = 10 - ipBlockDur = 1 * time.Minute - cleanupInterval = 2 * time.Minute - maxEntries = 10000 // 单维度最大条目数(防内存耗尽) -) - // NewRateLimiter 创建限流器并启动后台清理 func NewRateLimiter() *RateLimiter { rl := &RateLimiter{ @@ -53,7 +22,6 @@ func NewRateLimiter() *RateLimiter { } // AllowAccount 检查账户维度是否允许登录尝试 -// 返回 (result, 登录失败时应调用的记录函数) func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) { return rl.check(rl.accountFailures, email, accountWindow, accountBlockDur, accountMaxFails, true) } @@ -62,88 +30,3 @@ func (rl *RateLimiter) AllowAccount(email string) (RateLimitResult, func()) { func (rl *RateLimiter) AllowIP(ip string) (RateLimitResult, func()) { return rl.check(rl.ipFailures, ip, ipWindow, ipBlockDur, ipMaxFails, false) } - -// check 核心检查逻辑 -// lowKey: 是否需要脱敏日志(true = 暗示账户存在,仅泄漏给已知该邮箱的人) -func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) { - rl.mu.Lock() - defer rl.mu.Unlock() - - now := time.Now() - state, exists := m[key] - - if !exists { - state = &windowState{} - if len(m) < maxEntries { - m[key] = state - } - } - - // 检查是否处于封锁期 - if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) { - retry := int(state.blockedUntil.Sub(now).Seconds()) + 1 - msg := "请求过于频繁,请稍后重试" - if lowKey { - msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry) - } - return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil - } - - // 窗口过期 → 重置 - if now.Sub(state.windowStart) > window { - state.count = 0 - state.windowStart = now - state.blockedUntil = time.Time{} - } - - // 记录失败(由调用方在登录失败时调用)的闭包 - recordFail := func() { - rl.mu.Lock() - defer rl.mu.Unlock() - s := m[key] - if s == nil { - return - } - if now.Sub(s.windowStart) > window { - s.count = 1 - s.windowStart = now - return - } - s.count++ - if s.count >= maxFails { - s.blockedUntil = now.Add(blockDur) - } - } - - return RateLimitResult{Blocked: false}, recordFail -} - -// Clear 登录成功后清除该 email 和 IP 的失败计数 -func (rl *RateLimiter) Clear(email, ip string) { - rl.mu.Lock() - defer rl.mu.Unlock() - delete(rl.accountFailures, email) - delete(rl.ipFailures, ip) -} - -// cleanupLoop 定期清理过期条目 -func (rl *RateLimiter) cleanupLoop() { - ticker := time.NewTicker(cleanupInterval) - defer ticker.Stop() - for range ticker.C { - rl.mu.Lock() - now := time.Now() - clean := func(m map[string]*windowState) { - for k, v := range m { - // 封锁期已过且窗口已过期 → 删除 - if (v.blockedUntil.IsZero() || now.After(v.blockedUntil)) && - now.Sub(v.windowStart) > accountWindow+ipBlockDur { - delete(m, k) - } - } - } - clean(rl.accountFailures) - clean(rl.ipFailures) - rl.mu.Unlock() - } -} diff --git a/internal/middleware/ratelimit_cleanup.go b/internal/middleware/ratelimit_cleanup.go new file mode 100644 index 0000000..1ec0fe7 --- /dev/null +++ b/internal/middleware/ratelimit_cleanup.go @@ -0,0 +1,32 @@ +package middleware + +import "time" + +// Clear 登录成功后清除该 email 和 IP 的失败计数 +func (rl *RateLimiter) Clear(email, ip string) { + rl.mu.Lock() + defer rl.mu.Unlock() + delete(rl.accountFailures, email) + delete(rl.ipFailures, ip) +} + +// cleanupLoop 定期清理过期条目 +func (rl *RateLimiter) cleanupLoop() { + ticker := time.NewTicker(cleanupInterval) + defer ticker.Stop() + for range ticker.C { + rl.mu.Lock() + now := time.Now() + clean := func(m map[string]*windowState) { + for k, v := range m { + if (v.blockedUntil.IsZero() || now.After(v.blockedUntil)) && + now.Sub(v.windowStart) > accountWindow+ipBlockDur { + delete(m, k) + } + } + } + clean(rl.accountFailures) + clean(rl.ipFailures) + rl.mu.Unlock() + } +} diff --git a/internal/middleware/ratelimit_core.go b/internal/middleware/ratelimit_core.go new file mode 100644 index 0000000..44ef9dc --- /dev/null +++ b/internal/middleware/ratelimit_core.go @@ -0,0 +1,81 @@ +package middleware + +import ( + "fmt" + "time" +) + +// RateLimitResult 限流检查结果 +type RateLimitResult struct { + Blocked bool + RetryAfter int + Message string +} + +const ( + accountWindow = 1 * time.Minute + accountMaxFails = 3 + accountBlockDur = 1 * time.Minute + ipWindow = 1 * time.Minute + ipMaxFails = 10 + ipBlockDur = 1 * time.Minute + cleanupInterval = 2 * time.Minute + maxEntries = 10000 +) + +// windowState 单个维度的限流状态 +type windowState struct { + count int + windowStart time.Time + blockedUntil time.Time +} + +// check 核心检查逻辑 +func (rl *RateLimiter) check(m map[string]*windowState, key string, window, blockDur time.Duration, maxFails int, lowKey bool) (RateLimitResult, func()) { + rl.mu.Lock() + defer rl.mu.Unlock() + + now := time.Now() + state, exists := m[key] + if !exists { + state = &windowState{} + if len(m) < maxEntries { + m[key] = state + } + } + + if !state.blockedUntil.IsZero() && now.Before(state.blockedUntil) { + retry := int(state.blockedUntil.Sub(now).Seconds()) + 1 + msg := "请求过于频繁,请稍后重试" + if lowKey { + msg = fmt.Sprintf("该账号登录尝试过于频繁,请 %d 秒后重试", retry) + } + return RateLimitResult{Blocked: true, RetryAfter: retry, Message: msg}, nil + } + + if now.Sub(state.windowStart) > window { + state.count = 0 + state.windowStart = now + state.blockedUntil = time.Time{} + } + + recordFail := func() { + rl.mu.Lock() + defer rl.mu.Unlock() + s := m[key] + if s == nil { + return + } + if now.Sub(s.windowStart) > window { + s.count = 1 + s.windowStart = now + return + } + s.count++ + if s.count >= maxFails { + s.blockedUntil = now.Add(blockDur) + } + } + + return RateLimitResult{Blocked: false}, recordFail +} diff --git a/internal/router/admin.go b/internal/router/admin.go new file mode 100644 index 0000000..7ec1818 --- /dev/null +++ b/internal/router/admin.go @@ -0,0 +1,57 @@ +package router + +import ( + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/middleware" + "metazone.cc/metalab/internal/model" + + "github.com/gin-gonic/gin" +) + +// setupAdminRoutes 注册管理后台页面和 API 路由 +func setupAdminRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // --- 管理后台 SSR 页面 --- + adminPages := r.Group("/admin") + adminPages.Use(d.authMdw.AdminAuth()) + adminPages.Use(middleware.RequirePageRole(model.RoleModerator)) + adminPages.Use(middleware.CSRF(cfg)) + adminPages.Use(func(c *gin.Context) { + middleware.SetCSRFToken(c, cfg) + c.Next() + }) + { + adminPages.GET("/", d.adminCtrl.Dashboard) + adminPages.GET("/users", d.adminCtrl.UsersPage, + middleware.RequirePageRole(model.RoleAdmin)) + adminPages.GET("/audits", d.auditCtrl.AuditPage, + middleware.RequirePageRole(model.RoleAdmin)) + adminPages.GET("/site-settings", d.siteSettingCtrl.SiteSettingsPage, + middleware.RequirePageRole(model.RoleOwner)) + } + + // --- 管理后台 API --- + adminAPI := r.Group("/api/admin") + adminAPI.Use(d.authMdw.Required()) + adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) + adminAPI.Use(middleware.CSRF(cfg)) + { + adminAPI.GET("/users", d.adminCtrl.ListUsers) + adminAPI.PUT("/users/:uid/status", d.adminCtrl.UpdateUserStatus) + adminAPI.POST("/users/:uid/reset-token", d.adminCtrl.ResetToken) + adminAPI.GET("/audits", d.auditCtrl.ListAudits) + adminAPI.POST("/audits/:id/approve", d.auditCtrl.Approve) + adminAPI.POST("/audits/:id/reject", d.auditCtrl.Reject) + } + + // --- 站点设置 API(仅 owner) --- + siteSettingsAPI := r.Group("/api/admin/site-settings") + siteSettingsAPI.Use(d.authMdw.Required()) + siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner)) + siteSettingsAPI.Use(middleware.CSRF(cfg)) + { + siteSettingsAPI.GET("", d.siteSettingCtrl.GetSettings) + siteSettingsAPI.PUT("", d.siteSettingCtrl.UpdateSetting) + siteSettingsAPI.PUT("/bool", d.siteSettingCtrl.UpdateBoolSetting) + siteSettingsAPI.GET("/bool", d.siteSettingCtrl.GetBoolSetting) + } +} diff --git a/internal/router/api.go b/internal/router/api.go new file mode 100644 index 0000000..841999a --- /dev/null +++ b/internal/router/api.go @@ -0,0 +1,45 @@ +package router + +import ( + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupAPIRoutes 注册 API 路由 +func setupAPIRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // --- 设置页 API(需登录) --- + settingsAPI := r.Group("/api/settings") + settingsAPI.Use(d.authMdw.Required()) + settingsAPI.Use(middleware.CSRF(cfg)) + { + settingsAPI.PUT("/profile", d.settingsCtrl.UpdateProfile) + settingsAPI.POST("/avatar", d.settingsCtrl.UploadAvatar) + settingsAPI.PUT("/password", d.settingsCtrl.ChangePassword) + settingsAPI.POST("/delete-account", d.settingsCtrl.DeleteAccount) + settingsAPI.GET("/audit-status", d.settingsCtrl.AuditStatus) + } + + // --- 消息中心 API(需登录) --- + messagesAPI := r.Group("/api/messages") + messagesAPI.Use(d.authMdw.Required()) + messagesAPI.Use(middleware.CSRF(cfg)) + { + messagesAPI.GET("/unread", d.messageCtrl.UnreadCount) + messagesAPI.POST("/:id/read", d.messageCtrl.MarkRead) + messagesAPI.POST("/read-all", d.messageCtrl.MarkAllRead) + } + + // --- 认证 API --- + api := r.Group("/api") + api.Use(middleware.CSRF(cfg)) + { + api.POST("/auth/check-email", d.tokenCtrl.CheckEmail) + api.POST("/auth/register", d.tokenCtrl.Register) + api.POST("/auth/login", d.tokenCtrl.Login) + api.POST("/auth/confirm-restore", d.tokenCtrl.ConfirmRestore) + api.POST("/auth/logout", d.tokenCtrl.Logout) + api.POST("/auth/refresh", d.tokenCtrl.RefreshToken) + } +} diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go new file mode 100644 index 0000000..8ae1d4c --- /dev/null +++ b/internal/router/deps_core.go @@ -0,0 +1,39 @@ +package router + +import ( + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/controller" + adminCtrl "metazone.cc/metalab/internal/controller/admin" + "metazone.cc/metalab/internal/middleware" + "metazone.cc/metalab/internal/repository" + "metazone.cc/metalab/internal/service" + + "gorm.io/gorm" +) + +type dependencies struct { + authCtrl *controller.AuthController + authMdw *middleware.AuthMiddleware + settingsCtrl *controller.SettingsController + messageCtrl *controller.MessageController + adminCtrl *adminCtrl.AdminController + auditCtrl *adminCtrl.AuditController + siteSettingCtrl *adminCtrl.SiteSettingController + tokenCtrl *controller.AuthController +} + +func buildDepsCore(db *gorm.DB, cfg *config.Config) ( + *repository.UserRepo, *service.TokenService, *service.AuthService, + *middleware.RateLimiter, *controller.AuthController, + *service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController, +) { + userRepo := repository.NewUserRepo(db) + tokenSvc := service.NewTokenService(cfg, userRepo) + authService := service.NewAuthService(userRepo, tokenSvc, cfg) + rateLimiter := middleware.NewRateLimiter() + authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) + avatarSvc := service.NewAvatarService(userRepo) + authMdw := middleware.NewAuthMiddleware(cfg, userRepo) + adminController := adminCtrl.NewAdminController(service.NewAdminService(userRepo)) + return userRepo, tokenSvc, authService, rateLimiter, authCtrl, avatarSvc, authMdw, adminController +} diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go new file mode 100644 index 0000000..d8683e8 --- /dev/null +++ b/internal/router/deps_extra.go @@ -0,0 +1,38 @@ +package router + +import ( + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/controller" + adminCtrl "metazone.cc/metalab/internal/controller/admin" + "metazone.cc/metalab/internal/repository" + "metazone.cc/metalab/internal/service" + + "gorm.io/gorm" +) + +func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies { + userRepo, _, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg) + + auditRepo := repository.NewAuditRepo(db) + notificationRepo := repository.NewNotificationRepo(db) + notificationService := service.NewNotificationService(notificationRepo) + auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService) + auditController := adminCtrl.NewAuditController(auditService, userRepo) + + messageController := controller.NewMessageController(notificationService) + + settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService) + siteSettingController := adminCtrl.NewSiteSettingController( + service.NewSiteSettingService(siteSettings), + &service.AuditDefaults{ + Enabled: cfg.Audit.Enabled, UsernameAudit: cfg.Audit.UsernameAudit, + AvatarAudit: cfg.Audit.AvatarAudit, BioAudit: cfg.Audit.BioAudit, + }, + ) + + return &dependencies{ + authCtrl: authCtrl, authMdw: authMdw, settingsCtrl: settingsCtrl, + messageCtrl: messageController, adminCtrl: adminController, + auditCtrl: auditController, siteSettingCtrl: siteSettingController, tokenCtrl: authCtrl, + } +} diff --git a/internal/router/frontend.go b/internal/router/frontend.go new file mode 100644 index 0000000..ffd9ae3 --- /dev/null +++ b/internal/router/frontend.go @@ -0,0 +1,50 @@ +package router + +import ( + "net/http" + + "metazone.cc/metalab/internal/common" + "metazone.cc/metalab/internal/config" + "metazone.cc/metalab/internal/middleware" + + "github.com/gin-gonic/gin" +) + +// setupFrontendRoutes 注册前端页面路由 +func setupFrontendRoutes(r *gin.Engine, cfg *config.Config, d *dependencies) { + // --- 页面路由(CSRF 仅下发 token,不验证) --- + pages := r.Group("/") + pages.Use(d.authMdw.Optional()) + pages.Use(middleware.CSRF(cfg)) + pages.Use(func(c *gin.Context) { + middleware.SetCSRFToken(c, cfg) + c.Next() + }) + { + pages.GET("/", func(c *gin.Context) { + c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{ + "Title": "首页", + "ExtraCSS": "/static/css/home.css", + })) + }) + + pages.GET("/settings", func(c *gin.Context) { + c.Redirect(http.StatusFound, "/settings/profile") + }) + pages.GET("/settings/:tab", d.settingsCtrl.SettingsPage) + pages.GET("/messages", d.messageCtrl.MessagesPage) + } + + // 认证页面(已登录自动跳走) + authPages := r.Group("/auth") + authPages.Use(d.authMdw.Optional()) + authPages.Use(middleware.CSRF(cfg)) + authPages.Use(func(c *gin.Context) { + middleware.SetCSRFToken(c, cfg) + c.Next() + }) + { + authPages.GET("/register", d.authCtrl.RegisterPage) + authPages.GET("/login", d.authCtrl.LoginPage) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 4f00f1f..8c38a18 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -1,16 +1,8 @@ package router import ( - "net/http" - - "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" - "metazone.cc/metalab/internal/controller" - adminCtrl "metazone.cc/metalab/internal/controller/admin" "metazone.cc/metalab/internal/middleware" - "metazone.cc/metalab/internal/model" - "metazone.cc/metalab/internal/repository" - "metazone.cc/metalab/internal/service" "github.com/gin-gonic/gin" "gorm.io/gorm" @@ -18,159 +10,11 @@ import ( // Setup 注册所有路由 func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) { - // --- 全局中间件 --- r.Use(middleware.SecurityHeaders()) - // --- 依赖注入 --- - userRepo := repository.NewUserRepo(db) - tokenSvc := service.NewTokenService(cfg, userRepo) - authService := service.NewAuthService(userRepo, tokenSvc, cfg) - rateLimiter := middleware.NewRateLimiter() - authCtrl := controller.NewAuthController(authService, tokenSvc, rateLimiter, cfg) - avatarSvc := service.NewAvatarService(userRepo) - authMdw := middleware.NewAuthMiddleware(cfg, userRepo) + deps := buildDeps(db, cfg, siteSettings) - // 管理后台 - adminService := service.NewAdminService(userRepo) - adminController := adminCtrl.NewAdminController(adminService) - - // 审核系统 - auditRepo := repository.NewAuditRepo(db) - notificationRepo := repository.NewNotificationRepo(db) - notificationService := service.NewNotificationService(notificationRepo) - auditService := service.NewAuditService(auditRepo, userRepo, siteSettings, cfg.Audit, notificationService) - auditController := adminCtrl.NewAuditController(auditService, userRepo) - - // 消息中心 - messageController := controller.NewMessageController(notificationService) - - // 站点设置 - siteSettingService := service.NewSiteSettingService(siteSettings) - auditDefaults := &service.AuditDefaults{ - Enabled: cfg.Audit.Enabled, - UsernameAudit: cfg.Audit.UsernameAudit, - AvatarAudit: cfg.Audit.AvatarAudit, - BioAudit: cfg.Audit.BioAudit, - } - siteSettingController := adminCtrl.NewSiteSettingController(siteSettingService, auditDefaults) - - // 个人设置(需要审核服务) - settingsCtrl := controller.NewSettingsController(authService, avatarSvc, auditService) - - // --- 页面路由(CSRF 仅下发 token,不验证——页面 GET 被豁免) --- - pages := r.Group("/") - pages.Use(authMdw.Optional()) - pages.Use(middleware.CSRF(cfg)) - pages.Use(func(c *gin.Context) { - // 页面路由:确保每个页面都下发 CSRF Cookie - middleware.SetCSRFToken(c, cfg) - c.Next() - }) - { - pages.GET("/", func(c *gin.Context) { - c.HTML(http.StatusOK, "home/index.html", common.BuildPageData(c, gin.H{ - "Title": "首页", - "ExtraCSS": "/static/css/home.css", - })) - }) - - // 个人设置页(需登录,/:tab 为伪静态子页面) - pages.GET("/settings", func(c *gin.Context) { - c.Redirect(http.StatusFound, "/settings/profile") - }) - pages.GET("/settings/:tab", settingsCtrl.SettingsPage) - - // 消息中心(需登录,noindex) - pages.GET("/messages", messageController.MessagesPage) - } - - // 认证页面(已登录自动跳走) - authPages := r.Group("/auth") - authPages.Use(authMdw.Optional()) - authPages.Use(middleware.CSRF(cfg)) - authPages.Use(func(c *gin.Context) { - middleware.SetCSRFToken(c, cfg) - c.Next() - }) - { - authPages.GET("/register", authCtrl.RegisterPage) - authPages.GET("/login", authCtrl.LoginPage) - } - - // --- 设置页 API(需登录) --- - settingsAPI := r.Group("/api/settings") - settingsAPI.Use(authMdw.Required()) - settingsAPI.Use(middleware.CSRF(cfg)) - { - settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile) - settingsAPI.POST("/avatar", settingsCtrl.UploadAvatar) - settingsAPI.PUT("/password", settingsCtrl.ChangePassword) - settingsAPI.POST("/delete-account", settingsCtrl.DeleteAccount) - // 查询当前用户的待审核状态 - settingsAPI.GET("/audit-status", settingsCtrl.AuditStatus) - } - - // --- 消息中心 API(需登录) --- - messagesAPI := r.Group("/api/messages") - messagesAPI.Use(authMdw.Required()) - messagesAPI.Use(middleware.CSRF(cfg)) - { - messagesAPI.GET("/unread", messageController.UnreadCount) - messagesAPI.POST("/:id/read", messageController.MarkRead) - messagesAPI.POST("/read-all", messageController.MarkAllRead) - } - - // --- 管理后台 SSR 页面(认证失败 302 跳首页) --- - adminPages := r.Group("/admin") - adminPages.Use(authMdw.AdminAuth()) - adminPages.Use(middleware.RequirePageRole(model.RoleModerator)) - adminPages.Use(middleware.CSRF(cfg)) - adminPages.Use(func(c *gin.Context) { - middleware.SetCSRFToken(c, cfg) - c.Next() - }) - { - adminPages.GET("/", adminController.Dashboard) - adminPages.GET("/users", adminController.UsersPage, - middleware.RequirePageRole(model.RoleAdmin)) - adminPages.GET("/audits", auditController.AuditPage, - middleware.RequirePageRole(model.RoleAdmin)) - } - - // --- 管理后台 API(JSON 响应) --- - adminAPI := r.Group("/api/admin") - adminAPI.Use(authMdw.Required()) - adminAPI.Use(middleware.RequireMinRole(model.RoleAdmin)) - adminAPI.Use(middleware.CSRF(cfg)) - { - adminAPI.GET("/users", adminController.ListUsers) - adminAPI.PUT("/users/:uid/status", adminController.UpdateUserStatus) - adminAPI.POST("/users/:uid/reset-token", adminController.ResetToken) - // 审核 API - adminAPI.GET("/audits", auditController.ListAudits) - adminAPI.POST("/audits/:id/approve", auditController.Approve) - adminAPI.POST("/audits/:id/reject", auditController.Reject) - } - - // --- 站点设置 API(仅 owner 可访问) --- - siteSettingsAPI := r.Group("/api/admin/site-settings") - siteSettingsAPI.Use(authMdw.Required()) - siteSettingsAPI.Use(middleware.RequireMinRole(model.RoleOwner)) - siteSettingsAPI.Use(middleware.CSRF(cfg)) - { - siteSettingsAPI.GET("", siteSettingController.GetSettings) - siteSettingsAPI.PUT("", siteSettingController.UpdateSetting) - } - - // --- API 路由(CSRF 严格验证) --- - api := r.Group("/api") - api.Use(middleware.CSRF(cfg)) - { - api.POST("/auth/check-email", authCtrl.CheckEmail) - api.POST("/auth/register", authCtrl.Register) - api.POST("/auth/login", authCtrl.Login) - api.POST("/auth/confirm-restore", authCtrl.ConfirmRestore) - api.POST("/auth/logout", authCtrl.Logout) - api.POST("/auth/refresh", authCtrl.RefreshToken) - } + setupFrontendRoutes(r, cfg, deps) + setupAPIRoutes(r, cfg, deps) + setupAdminRoutes(r, cfg, deps) } diff --git a/templates/admin/html/layout/base.html b/templates/admin/html/layout/base.html index 08fb345..538b24c 100644 --- a/templates/admin/html/layout/base.html +++ b/templates/admin/html/layout/base.html @@ -39,6 +39,12 @@ 审核管理 {{end}} + {{if .CanManageSettings}} + + + 站点设置 + + {{end}}
diff --git a/templates/admin/html/site-settings/index.html b/templates/admin/html/site-settings/index.html new file mode 100644 index 0000000..804bf6b --- /dev/null +++ b/templates/admin/html/site-settings/index.html @@ -0,0 +1,64 @@ +{{template "admin/layout/base.html" .}} + + + +
+
审核设置
+
+
+
+ 启用审核系统 + 启用后,用户名、头像、签名修改将进入审核流程 +
+ +
+ +
+
+ 用户名审核 + 更改用户名需要管理员审核 +
+ +
+ +
+
+ 头像审核 + 更改头像需要管理员审核 +
+ +
+ +
+
+ 签名审核 + 更改个人签名需要管理员审核 +
+ +
+
+
+ + +
+
通用设置
+
+
加载中...
+
+
+{{template "admin/layout/footer.html" .}} diff --git a/templates/admin/static/css/site-settings.css b/templates/admin/static/css/site-settings.css new file mode 100644 index 0000000..815e366 --- /dev/null +++ b/templates/admin/static/css/site-settings.css @@ -0,0 +1,83 @@ +/* ===================================================== + MetaLab 管理面板 - 站点设置 + 单一职责:设置页面布局、表单分组、开关组件 + ===================================================== */ + +/* --- 设置区块 --- */ +.settings-section { + background: var(--bg-card); + border-radius: var(--radius); + box-shadow: var(--shadow); + margin-bottom: 20px; + overflow: hidden; +} +.section-title { + font-size: 15px; font-weight: 600; + color: var(--text); + padding: 16px 20px; + border-bottom: 1px solid var(--border); + background: #fafafa; +} +.section-body { padding: 4px 0; } + +/* --- 设置条目 --- */ +.setting-item { + display: flex; align-items: center; justify-content: space-between; + padding: 14px 20px; + border-bottom: 1px solid #f5f5f5; + gap: 20px; +} +.setting-item:last-child { border-bottom: none; } +.setting-info { flex: 1; min-width: 0; } +.setting-label { + font-size: 13px; font-weight: 500; + color: var(--text); display: block; +} +.setting-desc { + font-size: 12px; color: var(--text-muted); + margin-top: 2px; +} +.setting-value { + font-size: 12px; color: var(--text-muted); + margin-top: 2px; font-family: monospace; + background: #f5f6fa; padding: 2px 6px; border-radius: 3px; + display: inline-block; + word-break: break-all; +} + +/* --- 空状态 --- */ +.settings-empty { + text-align: center; padding: 28px 20px; + color: var(--text-muted); font-size: 13px; +} + +/* --- Toggle 开关 --- */ +.toggle-switch { + position: relative; display: inline-block; + width: 44px; height: 24px; flex-shrink: 0; +} +.toggle-switch input { opacity: 0; width: 0; height: 0; } +.toggle-slider { + position: absolute; cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: #ccc; + border-radius: 24px; + transition: background 0.2s; +} +.toggle-slider::before { + position: absolute; content: ""; + height: 18px; width: 18px; + left: 3px; bottom: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} +.toggle-switch input:checked + .toggle-slider { + background: var(--primary); +} +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} +.toggle-switch input:disabled + .toggle-slider { + opacity: 0.4; cursor: not-allowed; +} diff --git a/templates/admin/static/js/site-settings.js b/templates/admin/static/js/site-settings.js new file mode 100644 index 0000000..a6ac468 --- /dev/null +++ b/templates/admin/static/js/site-settings.js @@ -0,0 +1,87 @@ +/* ===================================================== + MetaLab 管理面板 - 站点设置 + 单一职责:加载/保存站点设置,开关即时生效 + ===================================================== */ + +(function() { + 'use strict'; + + const TOGGLE_KEYS = [ + { id: 'audit-enabled', key: 'audit.enabled' }, + { id: 'audit-username', key: 'audit.username_audit' }, + { id: 'audit-avatar', key: 'audit.avatar_audit' }, + { id: 'audit-bio', key: 'audit.bio_audit' } + ]; + + // --- 初始加载 --- + async function loadSettings() { + try { + const res = await api.get('/api/admin/site-settings'); + if (!res.success) return; + + // 审核开关 + const audit = res.data.audit || {}; + TOGGLE_KEYS.forEach(function(t) { + var el = document.getElementById(t.id); + if (el) { + el.checked = audit[t.key.split('.')[1]] === true; + el.addEventListener('change', function() { + updateBoolSetting(t.key, el.checked, el); + }); + } + }); + + // 通用设置 + renderGeneralSettings(res.data.all || {}); + } catch (e) { + showToast('加载设置失败', 'error'); + } + } + + // --- 更新布尔设置 --- + async function updateBoolSetting(key, value, toggle) { + toggle.disabled = true; + try { + var res = await api.put('/api/admin/site-settings/bool', { + key: key, + value: String(value) + }); + if (res.success) { + showToast('设置已更新', 'success'); + } else { + showToast(res.message || '保存失败', 'error'); + toggle.checked = !value; // 回滚 + } + } catch (e) { + showToast('保存失败', 'error'); + toggle.checked = !value; // 回滚 + } finally { + toggle.disabled = false; + } + } + + // --- 渲染通用设置 --- + function renderGeneralSettings(all) { + var container = document.getElementById('general-settings'); + var keys = Object.keys(all); + if (keys.length === 0) { + container.innerHTML = '
暂无其他设置
'; + return; + } + container.innerHTML = ''; + keys.forEach(function(key) { + var val = all[key] || ''; + var item = document.createElement('div'); + item.className = 'setting-item'; + item.innerHTML = + '
' + + '' + escapeHtml(key) + '' + + '' + escapeHtml(val.length > 80 ? val.slice(0, 80) + '...' : val) + '' + + '
'; + container.appendChild(item); + }); + } + + // --- 入口 --- + loadSettings(); +})();