package handler import ( "context" "encoding/json" "errors" "github.com/freefire/jiang13-bbs/middleware" "github.com/freefire/jiang13-bbs/model" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" "io" "net/http" "strconv" "strings" "time" ) type moduleRequest struct { Version int64 `json:"version"` Data json.RawMessage `json:"data"` Clear []string `json:"clear"` Action string `json:"action"` Recipient string `json:"recipient"` Text string `json:"text"` Scope string `json:"scope"` } func (h *Handlers) ReadModule(c *gin.Context) { v, e := h.Ops.Read(c.Param("module")) if e != nil { c.JSON(503, gin.H{"error": "配置读取失败,请稍后重试"}) return } c.Header("Cache-Control", "no-store") c.JSON(200, v) } func (h *Handlers) SaveModule(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) var req moduleRequest if c.ShouldBindJSON(&req) != nil { c.JSON(400, gin.H{"error": "表单格式无效"}) return } name := c.Param("module") actor := middleware.CurrentUser(c).ID if e := h.Ops.ProbeBeforeSave(c.Request.Context(), name, req.Data, req.Clear); e != nil { h.Ops.Audit(actor, name, "save", "服务验证失败") c.JSON(400, gin.H{"error": safeConfigError(e)}) return } e := h.Ops.Save(name, req.Version, req.Data, req.Clear, actor) if e != nil { status := 400 if errors.Is(e, service.ErrConfigConflict) { status = 409 } h.Ops.Audit(actor, name, "save", "失败") c.JSON(status, gin.H{"error": safeConfigError(e)}) return } h.ReadModule(c) } func safeConfigError(e error) string { s := e.Error() if strings.Contains(s, "SQLSTATE") || strings.Contains(s, "sql:") || strings.Contains(s, "failed to connect") { return "数据库暂不可用,配置未保存" } return s } func (h *Handlers) TestModule(c *gin.Context) { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) var req moduleRequest if c.ShouldBindJSON(&req) != nil { c.JSON(400, gin.H{"error": "表单格式无效"}) return } actor := middleware.CurrentUser(c).ID if wait, e := h.Ops.Quota("admin-test:"+strconv.Itoa(int(actor)), 6, 60); !h.quotaResponse(c, wait, e) { return } switch c.Param("module") { case "mail": if req.Action != "connection" && req.Action != "send" { c.JSON(400, gin.H{"error": "测试类型无效"}) return } e := h.Ops.TestMail(c.Request.Context(), req.Data, req.Clear, req.Action == "send", req.Recipient, actor) if e != nil { c.JSON(400, gin.H{"error": e.Error()}) return } message := "连接、TLS 与认证通过" if req.Action == "send" { message = "服务器已接受测试邮件,不代表最终送达" } c.JSON(200, gin.H{"message": message, "tested_at": time.Now()}) case "storage": e := h.Ops.TestStorage(c.Request.Context(), req.Data, req.Clear) if e != nil { h.Ops.Audit(actor, "storage", "test", "失败") c.JSON(400, gin.H{"error": e.Error()}) return } h.Ops.Audit(actor, "storage", "test", "读写清理通过") c.JSON(200, gin.H{"message": "读写与清理测试通过", "tested_at": time.Now()}) case "filter": result, e := h.Ops.TestFilter(req.Data, req.Scope, req.Text) if e != nil { c.JSON(400, gin.H{"error": e.Error()}) return } c.JSON(200, result) default: c.JSON(404, gin.H{"error": "该模块没有测试操作"}) } } func (h *Handlers) ModuleRecords(c *gin.Context) { var data any var e error switch c.Param("module") { case "mail": data, e = h.Ops.MailRows() case "storage": data, e = h.Ops.StorageReferences() case "security", "filter", "maintenance": data, e = h.Ops.AuditRows(c.Param("module")) default: c.JSON(404, gin.H{"error": "该模块没有记录"}) return } if e != nil { c.JSON(503, gin.H{"error": "记录读取失败"}) return } c.Header("Cache-Control", "no-store") c.JSON(200, gin.H{"records": data}) } func (h *Handlers) quotaResponse(c *gin.Context, wait int, e error) bool { if e != nil { c.AbortWithStatusJSON(503, gin.H{"error": "安全检查暂不可用,请稍后重试"}) return false } if wait > 0 { c.Header("Retry-After", strconv.Itoa(wait)) c.AbortWithStatusJSON(429, gin.H{"error": "操作频繁,请 " + strconv.Itoa(wait) + " 秒后重试", "retry_after": wait}) return false } return true } func (h *Handlers) administrator(c *gin.Context) bool { user := middleware.CurrentUser(c) if user == nil { return false } a, e := h.Auth.LoadActor(user.ID) return e == nil && a.HasPerm(service.PermSettings) } func authRecoveryPath(p string) bool { switch p { case "/api/login", "/api/logout", "/api/auth/refresh", "/api/me", "/api/settings", "/api/site-state", "/api/auth/code", "/api/auth/reset-password": return true } return strings.HasPrefix(p, "/api/admin/") } // Registered after OptionalAuth so bypass always depends on validated live permissions. func (h *Handlers) RuntimeGuard(c *gin.Context) { p := c.Request.URL.Path if p == "/health" || strings.HasPrefix(p, "/uploads/") || authRecoveryPath(p) { c.Next() return } if h.administrator(c) { c.Next() return } mode, e := h.Ops.Maintenance() if e != nil { c.AbortWithStatusJSON(503, gin.H{"error": "站点状态暂不可用"}) return } safe := authRecoveryPath(p) if !safe && mode.Mode == "paused" { c.Header("Retry-After", strconv.Itoa(mode.RetryAfter)) c.Header("Cache-Control", "no-store") c.AbortWithStatusJSON(503, gin.H{"error": mode.Title, "maintenance": mode}) return } if !safe && mode.Mode == "readonly" && c.Request.Method != "GET" && c.Request.Method != "HEAD" && c.Request.Method != "OPTIONS" { c.AbortWithStatusJSON(503, gin.H{"error": "站点处于只读模式,暂不能提交修改"}) return } c.Next() } func (h *Handlers) BusinessQuota(c *gin.Context) { if c.FullPath() != "/api/posts" && c.FullPath() != "/api/posts/:id/comments" { c.Next() return } cfg, e := h.Ops.Security() if !h.quotaResponse(c, 0, e) { return } p := c.FullPath() user := middleware.CurrentUser(c) if user != nil && model.IsStaff(model.Role(user.Role)) { c.Next() // 管理角色不受发帖/评论间隔与搜索频控限制 return } identity := "ip:" + c.ClientIP() if user != nil { identity = "user:" + strconv.Itoa(int(user.ID)) } seconds, limit, kind := 0, 1, "" if c.Request.Method == "GET" && p == "/api/posts" && c.Query("q") != "" { seconds = 60 limit = cfg.SearchMinute kind = "search" } if c.Request.Method == "POST" && user != nil { switch p { case "/api/posts": seconds = cfg.PostInterval kind = "post" case "/api/posts/:id/comments": seconds = cfg.CommentInterval kind = "comment" } } if seconds > 0 { if wait, e := h.Ops.Quota(kind+":"+identity, limit, seconds); !h.quotaResponse(c, wait, e) { return } } c.Next() } func (h *Handlers) SiteState(c *gin.Context) { cfg, e := h.Ops.Security() if e != nil { c.JSON(503, gin.H{"error": "状态暂不可用"}) return } m, e := h.Ops.Maintenance() if e != nil { c.JSON(503, gin.H{"error": "状态暂不可用"}) return } c.Header("Cache-Control", "no-store") c.JSON(200, gin.H{"allow_register": cfg.AllowRegister, "register_notice": cfg.RegisterNotice, "verify_email": cfg.VerifyEmail, "password_reset": cfg.PasswordReset, "maintenance": m, "bypass": h.administrator(c), "site_url": h.Cfg.SiteURL}) } func (h *Handlers) SendEmailCode(c *gin.Context) { var req struct { Email string `json:"email"` Purpose string `json:"purpose"` } if c.ShouldBindJSON(&req) != nil { c.JSON(400, gin.H{"error": "请求格式无效"}) return } wait, e := h.Ops.SendCode(req.Email, req.Purpose, c.ClientIP()) if !h.quotaResponse(c, wait, e) { return } c.JSON(200, gin.H{"message": "如果该邮箱可用于此操作,验证邮件将进入发送队列"}) } func (h *Handlers) ResetPassword(c *gin.Context) { var req struct { Email string `json:"email"` Code string `json:"code"` Password string `json:"password"` } if c.ShouldBindJSON(&req) != nil { c.JSON(400, gin.H{"error": "请求格式无效"}) return } if wait, e := h.Ops.Quota("reset:"+c.ClientIP(), 10, 600); !h.quotaResponse(c, wait, e) { return } if e := h.Ops.ResetPassword(req.Email, req.Code, req.Password); e != nil { var te *service.CodeThrottleError if errors.As(e, &te) { c.Header("Retry-After", strconv.Itoa(te.Wait)) c.JSON(429, gin.H{"error": te.Error(), "retry_after": te.Wait}) } else { c.JSON(400, gin.H{"error": "验证码无效、已过期或密码格式不符合要求"}) } return } c.JSON(200, gin.H{"message": "密码已更新,请重新登录"}) } func (h *Handlers) PublicObject(c *gin.Context) { location, err := h.Ops.PublicObjectLocation(c.Param("object")) if err != nil { c.JSON(404, gin.H{"error": "文件不存在"}) return } if location != "" { c.Header("Cache-Control", "private, no-store") c.Redirect(302, location) return } ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second) defer cancel() r, m, e := h.Ops.OpenObject(ctx, c.Param("object"), true) if e != nil { c.JSON(404, gin.H{"error": "文件暂不可用"}) return } defer r.Close() c.Header("Content-Type", m) c.Header("X-Content-Type-Options", "nosniff") c.Header("Cache-Control", "private, no-store") c.Status(200) _, _ = io.Copy(c.Writer, r) } func (h *Handlers) Diagnostics(c *gin.Context) { c.Header("Cache-Control", "no-store") c.JSON(200, h.Ops.Diagnostics(c.Request.Context())) } func (h *Handlers) MaintenanceAction(c *gin.Context) { var req struct { Action string `json:"action"` Confirm bool `json:"confirm"` IDs []string `json:"ids"` } if c.ShouldBindJSON(&req) != nil { c.JSON(400, gin.H{"error": "请求无效"}) return } actor := middleware.CurrentUser(c).ID switch req.Action { case "scan": r, e := h.Ops.ScanTemporary() if e != nil { c.JSON(503, gin.H{"error": "扫描失败"}) return } c.JSON(200, r) case "clean-temporary": if !req.Confirm { c.JSON(400, gin.H{"error": "请先扫描并确认清理范围"}) return } r, e := h.Ops.CleanTemporary(req.IDs) if e != nil { c.JSON(400, gin.H{"error": "清理失败,请重新扫描"}) return } h.Ops.Audit(actor, "maintenance", "clean-temporary", "完成") c.JSON(200, r) case "clear-mail-logs": if !req.Confirm { c.JSON(400, gin.H{"error": "请确认仅清理过期发送记录"}) return } n, e := h.Ops.ClearMailLogs() if e != nil { c.JSON(503, gin.H{"error": "清理失败"}) return } h.Ops.Audit(actor, "maintenance", req.Action, "完成") c.JSON(200, gin.H{"message": "已清理过期终态发送记录", "count": n}) default: c.JSON(400, gin.H{"error": "不支持该维护操作"}) } }