356 lines
10 KiB
Go
356 lines
10 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"github.com/freefire/jiang13-bbs/middleware"
|
|
"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)
|
|
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 {
|
|
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": "不支持该维护操作"})
|
|
}
|
|
}
|