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:
@ -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("仅锁定状态可解锁")
|
||||
)
|
||||
|
||||
@ -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 {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -31,5 +31,14 @@ type userAdminStore interface {
|
||||
IncrementTokenVersion(userID uint) error
|
||||
}
|
||||
|
||||
|
||||
|
||||
// postStore PostService 所需的最小仓储接口(ISP:8 个方法)
|
||||
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
|
||||
}
|
||||
@ -282,4 +282,3 @@
|
||||
</script>
|
||||
<script src="/static/js/common.js"></script>
|
||||
<script type="module" src="/static/js/editor.js"></script>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user