Files
jiang13-bbs/backend/handler/operations.go
freefire 3e55b5d230 feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
2026-09-24 03:37:44 +08:00

367 lines
11 KiB
Go

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": "不支持该维护操作"})
}
}