新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
376 lines
11 KiB
Go
376 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
|
||
}
|
||
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": "不支持该维护操作"})
|
||
}
|
||
}
|