Files
jiang13-bbs/backend/handler/operations.go
freefire 46e0cdc0b3 feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
2026-10-03 02:38:11 +08:00

376 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
urlStyle, e := h.Setting.UrlStyle()
if e != nil {
urlStyle = service.DefaultUrlStyle
}
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, "url_style": urlStyle})
}
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 errors.Is(e, service.ErrEmailRegistered) {
c.JSON(http.StatusBadRequest, gin.H{"error": e.Error()})
return
}
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")
// 对象 ID 按上传随机生成不覆盖,直出内容可短缓存(302 预签名分支不缓存,防过期地址复用)
c.Header("Cache-Control", "public, max-age=86400")
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": "不支持该维护操作"})
}
}