完善站点运营设置:分路由管理、登录可见评论、邮件模板内联与关闭注册 SSR。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 00:17:00 +08:00
parent acde7ee288
commit 6f054a903c
98 changed files with 8239 additions and 2073 deletions

View File

@@ -85,6 +85,7 @@ func clearAuthCookies(c *gin.Context, secure bool) {
// RegisterRequest 注册请求
type RegisterRequest struct {
Code string `json:"code"`
Username string `json:"username" binding:"required,min=3,max=32"`
Email string `json:"email" binding:"omitempty,email"`
Password string `json:"password" binding:"required,min=6,max=64"`
@@ -92,8 +93,8 @@ type RegisterRequest struct {
// LoginRequest 登录请求
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
Username string `json:"username" binding:"required,max=128"`
Password string `json:"password" binding:"required,max=128"`
}
// Register 用户注册:成功后直接签发登录态(注册即登录,免去手动再登一次)
@@ -110,6 +111,17 @@ func (h *Handlers) Register(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
security, err := h.Ops.Security()
if err != nil {
c.JSON(503, gin.H{"error": "注册暂不可用"})
return
}
if security.VerifyEmail {
if err = h.Ops.ConsumeCode(req.Email, "register", req.Code); err != nil {
c.JSON(400, gin.H{"error": "请先完成邮箱验证"})
return
}
}
user, err := h.Auth.Register(req.Username, req.Email, req.Password)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -146,8 +158,17 @@ func (h *Handlers) Login(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
cfg, e := h.Ops.Security()
if !h.quotaResponse(c, 0, e) {
return
}
wait, e := h.Ops.FailureWait(req.Username, c.ClientIP(), cfg)
if !h.quotaResponse(c, wait, e) {
return
}
accessToken, refreshToken, user, err := h.Auth.Login(req.Username, req.Password, c.ClientIP(), c.Request.UserAgent())
if err != nil {
h.Ops.RecordFailure(req.Username, c.ClientIP(), cfg)
// 登录失败也留痕:用户存在时带 user_id(封禁/错密),不存在时为 0
h.AdminUser.RecordLogin(
h.Auth.GetUserIDByUsername(req.Username), req.Username,
@@ -164,6 +185,7 @@ func (h *Handlers) Login(c *gin.Context) {
// 登录成功留痕(IP/UA/时间)
h.AdminUser.RecordLogin(user.ID, req.Username, c.ClientIP(), c.Request.UserAgent(), true)
// dev 模式不设 Secure,生产环境需 HTTPS
h.Ops.ClearFailure(req.Username)
setAuthCookies(c, accessToken, refreshToken, !h.Cfg.DevMode)
c.JSON(http.StatusOK, gin.H{
"user": gin.H{

View File

@@ -50,6 +50,20 @@ func (h *Handlers) PostComments(c *gin.Context) {
})
return
}
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
return
} else if requireLogin && viewerID == 0 {
c.JSON(http.StatusOK, gin.H{
"comments": []any{},
"total": 0,
"total_comments": 0,
"page": page,
"size": size,
"require_login": true,
})
return
}
boardID, _ := h.Post.GetBoardID(uint(id))

View File

@@ -10,6 +10,7 @@ import (
// Handlers 聚合所有服务引用
type Handlers struct {
Ops *service.Operations
Cfg *config.Config
Hub *realtime.Hub
Auth *service.AuthService

View File

@@ -0,0 +1,355 @@
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": "不支持该维护操作"})
}
}

View File

@@ -1,11 +1,14 @@
package handler
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
@@ -446,6 +449,20 @@ func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
if att.ObjectID != "" {
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
r, _, err := h.Ops.OpenObject(ctx, att.ObjectID, false)
if err != nil {
c.JSON(503, gin.H{"error": "文件暂不可用"})
return
}
defer r.Close()
c.Header("Cache-Control", "private, no-store")
c.Status(200)
_, _ = io.Copy(c.Writer, r)
return
}
c.File(path)
_ = filepath.Base(path)
}

View File

@@ -27,6 +27,7 @@ type updateSettingsRequest struct {
SiteDescription *string `json:"site_description"`
AllowRegister *bool `json:"allow_register"`
AllowComments *bool `json:"allow_comments"`
CommentsRequireLogin *bool `json:"comments_require_login"`
AllowMessages *bool `json:"allow_messages"`
PostCooldownHours *int `json:"post_cooldown_hours"`
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
@@ -55,7 +56,6 @@ type updateSettingsRequest struct {
BrandLogoSize *string `json:"brand_logo_size"`
BrandLogoFit *string `json:"brand_logo_fit"`
FooterLinks *[]service.FooterLink `json:"footer_links"`
TimelineGitImport *string `json:"timeline_git_import"` // 超管专用;不进公开 settings / WS 广播
}
func settingsPayload(saved service.PublicSiteSettings) gin.H {
@@ -65,8 +65,9 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"site_name": saved.SiteName,
"site_description": saved.SiteDescription,
"allow_register": saved.AllowRegister,
"allow_comments": saved.AllowComments,
"allow_messages": saved.AllowMessages,
"allow_comments": saved.AllowComments,
"comments_require_login": saved.CommentsRequireLogin,
"allow_messages": saved.AllowMessages,
"post_cooldown_hours": saved.PostCooldownHours,
"code_block_auto_fold": saved.CodeBlockAutoFold,
"code_block_fold_lines": saved.CodeBlockFoldLines,
@@ -100,7 +101,7 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
func (req *updateSettingsRequest) hasAny() bool {
return req.Accent != nil || req.TrustReviewedPublish != nil || req.SiteName != nil ||
req.SiteDescription != nil || req.AllowRegister != nil || req.AllowComments != nil ||
req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
req.CommentsRequireLogin != nil || req.AllowMessages != nil || req.PostCooldownHours != nil || req.CodeBlockAutoFold != nil ||
req.CodeBlockFoldLines != nil || req.UIAnimations != nil || req.AnimCodeFold != nil ||
req.AnimSmoothScroll != nil || req.AnimChrome != nil || req.PostLinkNewTab != nil ||
req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil ||
@@ -110,24 +111,17 @@ func (req *updateSettingsRequest) hasAny() bool {
req.SiteWordmark != nil || req.SiteSlogan != nil || req.SiteKeywords != nil ||
req.LogoLightURL != nil || req.LogoDarkURL != nil || req.FaviconURL != nil ||
req.BrandMark != nil || req.BrandLogoSize != nil || req.BrandLogoFit != nil ||
req.FooterLinks != nil || req.TimelineGitImport != nil
req.FooterLinks != nil
}
// AdminGetSettings 超管读取站点设置(含 timeline_git_import,不进公开 /api/settings)
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
func (h *Handlers) AdminGetSettings(c *gin.Context) {
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
return
}
adapter, err := h.Setting.TimelineGitAdapterJSON()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取提交导入配置失败"})
return
}
out := settingsPayload(saved)
out["timeline_git_import"] = adapter
c.JSON(http.StatusOK, out)
c.JSON(http.StatusOK, settingsPayload(saved))
}
// PUT /api/admin/settings
@@ -142,6 +136,10 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
if req.AllowRegister != nil || req.AttachmentExtLimit != nil || req.AttachmentExts != nil || req.AttachmentMaxMB != nil || req.AttachmentMaxCount != nil || req.ImageMaxMB != nil {
c.JSON(400, gin.H{"error": "请在访问与安全或文件与存储页通过版本校验保存这些设置"})
return
}
if req.Accent != nil {
if err := h.Setting.SetAccent(*req.Accent); err != nil {
if errors.Is(err, service.ErrInvalidAccent) {
@@ -190,6 +188,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
if req.CommentsRequireLogin != nil {
if err := h.Setting.SetCommentsRequireLogin(*req.CommentsRequireLogin); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
return
}
}
if req.AllowMessages != nil {
if err := h.Setting.SetAllowMessages(*req.AllowMessages); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
@@ -419,16 +423,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
}
}
var savedAdapter string
if req.TimelineGitImport != nil {
normalized, err := h.Setting.SetTimelineGitAdapterJSON(*req.TimelineGitImport)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
savedAdapter = normalized
}
saved, err := h.Setting.Public()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"})
@@ -442,9 +436,6 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
})
out := settingsPayload(saved)
out["ok"] = true
if req.TimelineGitImport != nil {
out["timeline_git_import"] = savedAdapter
}
c.JSON(http.StatusOK, out)
}

View File

@@ -165,6 +165,21 @@ func (h *Handlers) UserComments(c *gin.Context) {
})
return
}
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
return
} else if requireLogin && middleware.CurrentUser(c) == nil {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
c.JSON(http.StatusOK, gin.H{
"comments": []any{},
"total": 0,
"page": page,
"size": size,
"require_login": true,
})
return
}
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))