- 所有格式(JPEG/PNG/GIF/WebP)上传后强制解码验证是否为有效图片 - 解码失败直接拒绝,不再回退原样存储 - 消除 .php 改名为 .jpg 绕过检查的安全漏洞 - JPEG/PNG 解码与 WebP 编码合并为一次解码,避免重复读
477 lines
13 KiB
Go
477 lines
13 KiB
Go
package controller
|
||
|
||
import (
|
||
"bytes"
|
||
"errors"
|
||
"fmt"
|
||
"image"
|
||
_ "image/gif"
|
||
_ "image/jpeg"
|
||
_ "image/png"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"metazone.cc/metalab/internal/common"
|
||
"metazone.cc/metalab/internal/model"
|
||
"metazone.cc/metalab/internal/service"
|
||
|
||
"github.com/chai2010/webp"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// PostController 帖子控制器(SSR 页面 + API)
|
||
type PostController struct {
|
||
postService postUseCase
|
||
shortcodeSvc *service.ShortcodeService
|
||
}
|
||
|
||
// NewPostController 构造函数
|
||
func NewPostController(ps postUseCase, scs *service.ShortcodeService) *PostController {
|
||
return &PostController{postService: ps, shortcodeSvc: scs}
|
||
}
|
||
|
||
// ListPage 帖子列表页
|
||
func (ctrl *PostController) ListPage(c *gin.Context) {
|
||
keyword := c.Query("keyword")
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
|
||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||
if err != nil {
|
||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||
"Title": "社区帖子",
|
||
"Error": "加载帖子列表失败",
|
||
}))
|
||
return
|
||
}
|
||
|
||
totalPages := common.PageCount(total, pageSize)
|
||
prevPage := page - 1
|
||
if prevPage < 1 {
|
||
prevPage = 1
|
||
}
|
||
nextPage := page + 1
|
||
if nextPage > totalPages {
|
||
nextPage = totalPages
|
||
}
|
||
|
||
c.HTML(http.StatusOK, "posts/index.html", common.BuildPageData(c, gin.H{
|
||
"Title": "社区帖子",
|
||
"Posts": posts,
|
||
"Total": total,
|
||
"Page": page,
|
||
"TotalPages": totalPages,
|
||
"PrevPage": prevPage,
|
||
"NextPage": nextPage,
|
||
"Keyword": keyword,
|
||
"ExtraCSS": "/static/css/posts.css",
|
||
}))
|
||
}
|
||
|
||
// ShowPage 帖子详情页
|
||
func (ctrl *PostController) ShowPage(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到",
|
||
}))
|
||
return
|
||
}
|
||
|
||
post, err := ctrl.postService.GetByID(uint(id))
|
||
if err != nil {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到",
|
||
}))
|
||
return
|
||
}
|
||
|
||
uid, role, _ := common.GetGinUser(c)
|
||
|
||
// 仅 approved 公开可见(作者本人 + moderator+ 可预览其他状态)
|
||
if post.Status != model.PostStatusApproved && !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到",
|
||
}))
|
||
return
|
||
}
|
||
|
||
ctrl.applyShortcode(post)
|
||
|
||
// Open Graph 社交分享数据
|
||
ogImage := common.ExtractFirstImage(post.Body)
|
||
if ogImage != "" && !strings.HasPrefix(ogImage, "http") {
|
||
prefix := ""
|
||
if !strings.HasPrefix(ogImage, "/") {
|
||
prefix = "/"
|
||
}
|
||
ogImage = "https://" + c.Request.Host + prefix + ogImage
|
||
}
|
||
ogURL := fmt.Sprintf("https://%s/posts/%d", c.Request.Host, id)
|
||
|
||
c.HTML(http.StatusOK, "posts/show.html", common.BuildPageData(c, gin.H{
|
||
"Title": post.Title,
|
||
"Post": post,
|
||
"UID": uid,
|
||
"StatusNames": common.PostStatusDisplayNames,
|
||
"ExtraCSS": "/static/css/posts.css",
|
||
"OgTitle": post.Title,
|
||
"OgDescription": post.Excerpt,
|
||
"OgImage": ogImage,
|
||
"OgURL": ogURL,
|
||
}))
|
||
}
|
||
|
||
// NewPage 发帖页面
|
||
func (ctrl *PostController) NewPage(c *gin.Context) {
|
||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||
"Title": "撰写帖子",
|
||
"ExtraCSS": "/static/css/posts.css",
|
||
}))
|
||
}
|
||
|
||
// EditPage 编辑页面
|
||
func (ctrl *PostController) EditPage(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到",
|
||
}))
|
||
return
|
||
}
|
||
|
||
post, err := ctrl.postService.GetByID(uint(id))
|
||
if err != nil || post.Status == model.PostStatusPending || post.IsLocked {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到或无法编辑",
|
||
}))
|
||
return
|
||
}
|
||
|
||
uid, role, _ := common.GetGinUser(c)
|
||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||
c.HTML(http.StatusNotFound, "posts/404.html", common.BuildPageData(c, gin.H{
|
||
"Title": "未找到",
|
||
}))
|
||
return
|
||
}
|
||
|
||
// 已通过帖子有待审修订时,编辑页展示待审内容
|
||
if post.PendingBody != "" {
|
||
post.Title = post.PendingTitle
|
||
post.Body = post.PendingBody
|
||
}
|
||
|
||
c.HTML(http.StatusOK, "posts/new.html", common.BuildPageData(c, gin.H{
|
||
"Title": "编辑帖子",
|
||
"Post": post,
|
||
"ExtraCSS": "/static/css/posts.css",
|
||
}))
|
||
}
|
||
|
||
// Create API 发帖
|
||
func (ctrl *PostController) Create(c *gin.Context) {
|
||
uid, _, ok := common.GetGinUser(c)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
var req model.PostCreateRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||
return
|
||
}
|
||
|
||
post, err := ctrl.postService.Create(uid, req.Title, req.Body)
|
||
if err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "发帖失败")
|
||
return
|
||
}
|
||
|
||
common.OkWithMessage(c, post, "发布成功")
|
||
}
|
||
|
||
// Update API 编辑帖子
|
||
func (ctrl *PostController) Update(c *gin.Context) {
|
||
uid, role, ok := common.GetGinUser(c)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||
return
|
||
}
|
||
|
||
var req model.PostUpdateRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
common.Error(c, http.StatusBadRequest, "标题和正文不能为空")
|
||
return
|
||
}
|
||
|
||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
common.OkMessage(c, "编辑成功")
|
||
}
|
||
|
||
// Delete API 删除帖子
|
||
func (ctrl *PostController) Delete(c *gin.Context) {
|
||
uid, role, ok := common.GetGinUser(c)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||
return
|
||
}
|
||
|
||
if _, ok := ctrl.getPostAndCheckAccess(uint(id), c, uid, role); !ok {
|
||
return
|
||
}
|
||
|
||
if err := ctrl.postService.Delete(uint(id)); err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "删除失败")
|
||
return
|
||
}
|
||
|
||
common.OkMessage(c, "删除成功")
|
||
}
|
||
|
||
// getPostAndCheckAccess 获取帖子并检查当前用户权限
|
||
// 返回 (*model.Post, true) 表示通过;返回 (nil, false) 表示已写入错误响应
|
||
func (ctrl *PostController) getPostAndCheckAccess(id uint, c *gin.Context, uid uint, role string) (*model.Post, bool) {
|
||
post, err := ctrl.postService.GetByID(id)
|
||
if err != nil {
|
||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||
return nil, false
|
||
}
|
||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||
return nil, false
|
||
}
|
||
return post, true
|
||
}
|
||
|
||
// Submit API 提交审核
|
||
func (ctrl *PostController) Submit(c *gin.Context) {
|
||
uid, _, ok := common.GetGinUser(c)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||
return
|
||
}
|
||
|
||
post, err := ctrl.postService.GetByID(uint(id))
|
||
if err != nil {
|
||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||
return
|
||
}
|
||
|
||
if post.UserID != uid {
|
||
common.Error(c, http.StatusForbidden, "无权操作此帖子")
|
||
return
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
common.OkMessage(c, "已提交审核")
|
||
}
|
||
|
||
// ListAPI 帖子列表 JSON API
|
||
func (ctrl *PostController) ListAPI(c *gin.Context) {
|
||
keyword := c.Query("keyword")
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
pageSize, _ := strconv.Atoi(c.DefaultQuery("page_size", "20"))
|
||
|
||
posts, total, err := ctrl.postService.List(keyword, page, pageSize)
|
||
if err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "获取帖子列表失败")
|
||
return
|
||
}
|
||
|
||
common.Ok(c, model.PostListResult{
|
||
Items: posts,
|
||
Total: total,
|
||
Page: page,
|
||
TotalPages: common.PageCount(total, pageSize),
|
||
})
|
||
}
|
||
|
||
// ShowAPI 帖子详情 JSON API
|
||
func (ctrl *PostController) ShowAPI(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "无效的帖子 ID")
|
||
return
|
||
}
|
||
|
||
post, err := ctrl.postService.GetByID(uint(id))
|
||
if err != nil {
|
||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||
return
|
||
}
|
||
|
||
// 权限控制:非 approved 帖子仅作者和 moderator+ 可见
|
||
if post.Status != model.PostStatusApproved {
|
||
uid, role, _ := common.GetGinUser(c)
|
||
if !ctrl.postService.IsPostAccessible(uid, role, post.UserID) {
|
||
common.Error(c, http.StatusNotFound, "帖子不存在")
|
||
return
|
||
}
|
||
}
|
||
|
||
ctrl.applyShortcode(post)
|
||
|
||
common.Ok(c, post)
|
||
}
|
||
|
||
// allowedImageExt 允许的图片扩展名
|
||
var allowedImageExt = map[string]bool{
|
||
".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true,
|
||
}
|
||
|
||
// UploadImage 上传帖子图片(需登录,multipart/form-data)
|
||
// JPEG/PNG 自动转 WebP quality 80 存储,保留原尺寸不 resize
|
||
func (ctrl *PostController) UploadImage(c *gin.Context) {
|
||
uid, _, ok := common.GetGinUser(c)
|
||
if !ok {
|
||
common.Error(c, http.StatusUnauthorized, "请先登录")
|
||
return
|
||
}
|
||
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "请选择文件")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 检查扩展名
|
||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||
if !allowedImageExt[ext] {
|
||
common.Error(c, http.StatusBadRequest, "仅支持 jpg / jpeg / png / gif / webp 格式")
|
||
return
|
||
}
|
||
|
||
// 限制 5MB
|
||
maxSize := int64(5 << 20)
|
||
if header.Size > maxSize {
|
||
common.Error(c, http.StatusBadRequest, "图片大小不能超过 5MB")
|
||
return
|
||
}
|
||
|
||
// 读取全部数据(后续 WebP 转换需要)
|
||
data, err := io.ReadAll(file)
|
||
if err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "读取文件失败")
|
||
return
|
||
}
|
||
|
||
// 存储路径
|
||
storageDir := "storage/uploads/posts"
|
||
if err := os.MkdirAll(storageDir, 0755); err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "存储初始化失败")
|
||
return
|
||
}
|
||
|
||
ts := time.Now().UnixMilli()
|
||
var savePath, url string
|
||
|
||
isJPEGPNG := ext == ".jpg" || ext == ".jpeg" || ext == ".png"
|
||
isWebP := ext == ".webp"
|
||
isGIF := ext == ".gif"
|
||
|
||
// 强制解码验证文件是否为有效图片(防伪扩展名、图片投毒),验证失败直接拒绝
|
||
// JPEG/PNG:解码后顺便做 WebP 压缩
|
||
if isJPEGPNG {
|
||
img, _, err := image.Decode(bytes.NewReader(data))
|
||
if err != nil {
|
||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||
return
|
||
}
|
||
// WebP 压缩存储(保留原尺寸,quality 80)
|
||
var buf bytes.Buffer
|
||
if err := webp.Encode(&buf, img, &webp.Options{Quality: 80}); err == nil {
|
||
if buf.Len() > 0 && buf.Len() < len(data) {
|
||
filename := fmt.Sprintf("%d_%d.webp", uid, ts)
|
||
savePath = filepath.Join(storageDir, filename)
|
||
if err := os.WriteFile(savePath, buf.Bytes(), 0644); err == nil {
|
||
url = "/uploads/posts/" + filename
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// GIF:仅解码验证,不转换
|
||
if isGIF {
|
||
if _, _, err := image.Decode(bytes.NewReader(data)); err != nil {
|
||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||
return
|
||
}
|
||
}
|
||
|
||
// WebP:仅解码验证,不重复编码
|
||
if isWebP {
|
||
if _, err := webp.Decode(bytes.NewReader(data)); err != nil {
|
||
common.Error(c, http.StatusBadRequest, "图片格式无效,请上传有效的图片文件")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 原格式存储(WebP / GIF / 转换后反而更大)
|
||
if savePath == "" {
|
||
filename := fmt.Sprintf("%d_%d%s", uid, ts, ext)
|
||
savePath = filepath.Join(storageDir, filename)
|
||
if err := os.WriteFile(savePath, data, 0644); err != nil {
|
||
common.Error(c, http.StatusInternalServerError, "保存图片失败")
|
||
return
|
||
}
|
||
url = "/uploads/posts/" + filename
|
||
}
|
||
|
||
common.VditorUploadOk(c, map[string]string{header.Filename: url})
|
||
}
|
||
|
||
// applyShortcode 处理帖子正文中的 shortcode 标记,替换为 HTML 占位符
|
||
func (ctrl *PostController) applyShortcode(post *model.Post) {
|
||
if ctrl.shortcodeSvc != nil {
|
||
result := ctrl.shortcodeSvc.Process(post.Body)
|
||
post.Body = result.ProcessedBody
|
||
}
|
||
}
|
||
|