fix: 修复帖子相关逻辑的严重安全与错误处理问题

- 修复 Service 层所有 FindByID 错误均返回 ErrUserNotFound 的严重 bug,新增 findPost/findPostWithAuthor 正确区分 gorm.ErrRecordNotFound 和数据库错误
- 新增帖子相关错误哨兵:ErrPostNotFound/ErrPostCannotEdit/ErrPostCannotSubmit/ErrPostCannotApprove/ErrPostCannotReject/ErrPostCannotUnlock
- 修复 ShowAPI 缺少权限控制,非 approved 帖子可被任意用户通过 API 获取
- 修复 EditPage 缺少权限检查,非作者可访问编辑页面
- 修复 Update/Delete API 中 uid 断言未做 ok 检查
- Controller 层区分业务错误和内部错误,避免透传 Service 层错误信息给客户端
- 将 postStore 接口从 post_service.go 移至 repository.go,与其他仓储接口保持一致
- 移除 new.html 中孤立的 </template> 标签
This commit is contained in:
2026-05-28 14:44:22 +08:00
parent 36b18c0ffe
commit e45d66fb77
6 changed files with 141 additions and 47 deletions

View File

@ -35,4 +35,12 @@ var (
ErrNeedsConfirmRestore = errors.New("需要二次确认恢复账号")
ErrOwnerCannotDelete = errors.New("站长不可自主注销,请先转让站长权限")
ErrRegistrationDisabled = errors.New("注册功能已关闭")
// 帖子相关
ErrPostNotFound = errors.New("帖子不存在")
ErrPostCannotEdit = errors.New("当前状态不允许编辑")
ErrPostCannotSubmit = errors.New("仅草稿和已退回帖子可提交审核")
ErrPostCannotApprove = errors.New("仅待审核帖子可通过")
ErrPostCannotReject = errors.New("仅待审核和已发布帖子可退回")
ErrPostCannotUnlock = errors.New("仅锁定状态可解锁")
)

View File

@ -1,6 +1,7 @@
package admin
import (
"errors"
"net/http"
"strconv"
@ -72,7 +73,11 @@ func (ctrl *AdminPostController) Approve(c *gin.Context) {
}
if err := ctrl.postService.Approve(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostCannotApprove) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "审核失败")
}
return
}
@ -94,7 +99,11 @@ func (ctrl *AdminPostController) Reject(c *gin.Context) {
}
if err := ctrl.postService.Reject(uint(id), req.Reason); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostCannotReject) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "退回失败")
}
return
}
@ -110,7 +119,11 @@ func (ctrl *AdminPostController) Lock(c *gin.Context) {
}
if err := ctrl.postService.Lock(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "锁定失败")
}
return
}
@ -126,7 +139,11 @@ func (ctrl *AdminPostController) Unlock(c *gin.Context) {
}
if err := ctrl.postService.Unlock(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostCannotUnlock) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "解锁失败")
}
return
}
@ -142,7 +159,7 @@ func (ctrl *AdminPostController) Restore(c *gin.Context) {
}
if err := ctrl.postService.Restore(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
common.Error(c, http.StatusInternalServerError, "恢复失败")
return
}

View File

@ -1,6 +1,7 @@
package controller
import (
"errors"
"fmt"
"html/template"
"mime/multipart"
@ -144,6 +145,22 @@ func (ctrl *PostController) EditPage(c *gin.Context) {
return
}
// 权限检查:仅作者和 moderator+ 可编辑
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
if post.UserID != uid && !model.HasMinRole(role, model.RoleModerator) {
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
"Title": "未找到",
}))
return
}
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
"Title": "编辑帖子",
"Post": post,
@ -178,7 +195,11 @@ func (ctrl *PostController) Create(c *gin.Context) {
// Update API 编辑帖子
func (ctrl *PostController) Update(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, _ := uidObj.(uint)
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@ -206,7 +227,11 @@ func (ctrl *PostController) Update(c *gin.Context) {
}
if err := ctrl.postService.Update(uint(id), req.Title, req.Body); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostCannotEdit) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "编辑失败")
}
return
}
@ -216,7 +241,11 @@ func (ctrl *PostController) Update(c *gin.Context) {
// Delete API 删除帖子
func (ctrl *PostController) Delete(c *gin.Context) {
uidObj, _ := c.Get("uid")
uid, _ := uidObj.(uint)
uid, ok := uidObj.(uint)
if !ok {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
@ -254,7 +283,11 @@ func (ctrl *PostController) Submit(c *gin.Context) {
}
if err := ctrl.postService.SubmitForAudit(uint(id)); err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
if errors.Is(err, common.ErrPostCannotSubmit) || errors.Is(err, common.ErrPostNotFound) {
common.Error(c, http.StatusBadRequest, err.Error())
} else {
common.Error(c, http.StatusInternalServerError, "提交审核失败")
}
return
}
@ -303,6 +336,24 @@ func (ctrl *PostController) ShowAPI(c *gin.Context) {
return
}
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
if post.Status != model.PostStatusApproved {
var uid uint
var role string
if uidObj, exists := c.Get("uid"); exists {
uid, _ = uidObj.(uint)
}
if roleObj, exists := c.Get("role"); exists {
role, _ = roleObj.(string)
}
isAuthor := uid == post.UserID
isModerator := model.HasMinRole(role, model.RoleModerator)
if !isAuthor && !isModerator {
common.Error(c, http.StatusNotFound, "帖子不存在")
return
}
}
common.Ok(c, post)
}

View File

@ -1,6 +1,7 @@
package service
import (
"errors"
"fmt"
"regexp"
@ -9,6 +10,7 @@ import (
"metazone.cc/metalab/internal/model"
"github.com/microcosm-cc/bluemonday"
"gorm.io/gorm"
)
// sanitizePolicy Tiptap 输出 HTML 的消毒策略
@ -24,18 +26,6 @@ var sanitizePolicy = func() *bluemonday.Policy {
return p
}()
// postStore 帖子服务所需的最小仓储接口ISP
type postStore interface {
Create(post *model.Post) error
FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error
SoftDelete(id uint) error
Restore(id uint) error
}
// PostService 帖子业务逻辑
type PostService struct {
repo postStore
@ -55,6 +45,30 @@ func (s *PostService) auditEnabled() bool {
return s.ss.IsAuditEnabled()
}
// findPost 按 ID 查找帖子,区分"未找到"和"数据库错误"
func (s *PostService) findPost(id uint) (*model.Post, error) {
post, err := s.repo.FindByID(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPostNotFound
}
return nil, fmt.Errorf("查询帖子失败: %w", err)
}
return post, nil
}
// findPostWithAuthor 按 ID 查找帖子(含作者名),区分"未找到"和"数据库错误"
func (s *PostService) findPostWithAuthor(id uint) (*model.Post, error) {
post, err := s.repo.FindByIDWithAuthor(id)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, common.ErrPostNotFound
}
return nil, fmt.Errorf("查询帖子失败: %w", err)
}
return post, nil
}
// sanitizeHTML 对 Tiptap 输出的 HTML 进行消毒
// 前端 WYSIWYG 编辑器直接输出 HTML后端仅做安全消毒
func (s *PostService) sanitizeHTML(body string) string {
@ -86,11 +100,7 @@ func (s *PostService) Create(userID uint, title, body string) (*model.Post, erro
// GetByID 按 ID 获取帖子(含作者名,仅 approved 公开可见)
func (s *PostService) GetByID(id uint) (*model.Post, error) {
post, err := s.repo.FindByIDWithAuthor(id)
if err != nil {
return nil, common.ErrUserNotFound // 复用哨兵表示资源不存在
}
return post, nil
return s.findPostWithAuthor(id)
}
// List 公开帖子列表(仅 approved
@ -109,13 +119,13 @@ func (s *PostService) ListAdmin(keyword, status string, page, pageSize int) ([]m
// Update 编辑帖子(权限在 controller 层检查)
func (s *PostService) Update(postID uint, title, body string) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
if post.Status == model.PostStatusPending || post.Status == model.PostStatusLocked {
return fmt.Errorf("当前状态不允许编辑")
return common.ErrPostCannotEdit
}
post.Title = title
@ -138,12 +148,12 @@ func (s *PostService) Delete(postID uint) error {
// SubmitForAudit 提交审核draft → pending
func (s *PostService) SubmitForAudit(postID uint) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
if post.Status != model.PostStatusDraft && post.Status != model.PostStatusRejected {
return fmt.Errorf("仅草稿和已退回帖子可提交审核")
return common.ErrPostCannotSubmit
}
post.Status = model.PostStatusPending
return s.repo.Update(post)
@ -151,12 +161,12 @@ func (s *PostService) SubmitForAudit(postID uint) error {
// Approve 审核通过pending → approved
func (s *PostService) Approve(postID uint) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
if post.Status != model.PostStatusPending {
return fmt.Errorf("仅待审核帖子可通过")
return common.ErrPostCannotApprove
}
post.Status = model.PostStatusApproved
post.RejectReason = ""
@ -165,23 +175,23 @@ func (s *PostService) Approve(postID uint) error {
// Reject 退回pending/approved → rejected
func (s *PostService) Reject(postID uint, reason string) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
if post.Status != model.PostStatusPending && post.Status != model.PostStatusApproved {
return fmt.Errorf("仅待审核和已发布帖子可退回")
return common.ErrPostCannotReject
}
post.Status = model.PostStatusRejected
post.RejectReason = reason
return s.repo.Update(post)
}
// Lock 锁定:任意状态 → locked
// Lock 锁定:任意状态 → locked(管理员可随时锁定)
func (s *PostService) Lock(postID uint) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
post.Status = model.PostStatusLocked
return s.repo.Update(post)
@ -189,12 +199,12 @@ func (s *PostService) Lock(postID uint) error {
// Unlock 解锁locked → 草稿
func (s *PostService) Unlock(postID uint) error {
post, err := s.repo.FindByID(postID)
post, err := s.findPost(postID)
if err != nil {
return common.ErrUserNotFound
return err
}
if post.Status != model.PostStatusLocked {
return fmt.Errorf("仅锁定状态可解锁")
return common.ErrPostCannotUnlock
}
post.Status = model.PostStatusDraft
return s.repo.Update(post)

View File

@ -31,5 +31,14 @@ type userAdminStore interface {
IncrementTokenVersion(userID uint) error
}
// postStore PostService 所需的最小仓储接口ISP8 个方法)
type postStore interface {
Create(post *model.Post) error
FindByID(id uint) (*model.Post, error)
FindByIDWithAuthor(id uint) (*model.Post, error)
FindPageable(keyword string, offset, limit int) ([]model.Post, int64, error)
FindAdminPageable(keyword, status string, offset, limit int) ([]model.Post, int64, error)
Update(post *model.Post) error
SoftDelete(id uint) error
Restore(id uint) error
}

View File

@ -282,4 +282,3 @@
</script>
<script src="/static/js/common.js"></script>
<script type="module" src="/static/js/editor.js"></script>
</template>