- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文 - 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等 - 代码、字符串字面量、日志消息保持英文原文,零逻辑改动 - go build/vet 通过,go test -count=1 ./... 全绿
290 lines
8.8 KiB
Go
290 lines
8.8 KiB
Go
package handlers
|
||
|
||
import (
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"gorm.io/gorm"
|
||
|
||
"go_blog/models"
|
||
)
|
||
|
||
// attachmentsDir 返回给定存储路径下附件的磁盘目录,遵循配置的 storage_dir。
|
||
func attachmentsDir(storagePath string) string {
|
||
dir := models.GetUploadConfig().StorageDir
|
||
if dir == "" {
|
||
dir = "attachments"
|
||
}
|
||
return filepath.Join(storagePath, dir)
|
||
}
|
||
|
||
// attachmentRelPath 返回附件相对于存储根目录的路径,
|
||
// 例如 "attachments/<stored>"——用于构建 /uploads URL。
|
||
func attachmentRelPath(stored string) string {
|
||
dir := models.GetUploadConfig().StorageDir
|
||
if dir == "" {
|
||
dir = "attachments"
|
||
}
|
||
return dir + "/" + stored
|
||
}
|
||
|
||
// attachmentURL 构建附件的公开 URL:默认下载基础 URL(若已配置)
|
||
// 与相对路径拼接,否则使用应用提供的本地 /uploads 路径。
|
||
func attachmentURL(stored string) string {
|
||
rel := attachmentRelPath(stored)
|
||
if base := models.DefaultDownloadBaseURL(); base != "" {
|
||
return strings.TrimRight(base, "/") + "/" + rel
|
||
}
|
||
return "/uploads/" + rel
|
||
}
|
||
|
||
// ---------------- 上传 ----------------
|
||
|
||
// currentUserIsAdmin 基于 SetUserContext 中间件填充的上下文,
|
||
// 报告已认证用户是否具有管理员角色。
|
||
func currentUserIsAdmin(c *gin.Context) bool {
|
||
role, _ := c.Get("role")
|
||
r, _ := role.(string)
|
||
return r == models.RoleAdmin
|
||
}
|
||
|
||
// canManageArticle 报告当前用户是否为给定文章附加文件(或管理其附件)的
|
||
// 合法用户:管理员始终允许,否则仅允许文章作者。
|
||
func canManageArticle(c *gin.Context, db *gorm.DB, articleID uint) bool {
|
||
if currentUserIsAdmin(c) {
|
||
return true
|
||
}
|
||
uid, ok := sessionAuthorID(c)
|
||
if !ok || articleID == 0 {
|
||
return false
|
||
}
|
||
var article models.Article
|
||
if err := db.First(&article, "id = ?", articleID).Error; err != nil {
|
||
return false
|
||
}
|
||
return article.AuthorID == uid
|
||
}
|
||
|
||
// UploadAttachment 处理来自文章创建/编辑表单的 AJAX 附件上传。
|
||
// 请求携带真实的 article_id(编辑页)或 session_token(创建页,待绑定)。
|
||
// 文件按 SHA-256 内容寻址,实现磁盘去重。
|
||
func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
uploaderID, ok := sessionAuthorID(c)
|
||
if !ok {
|
||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||
return
|
||
}
|
||
|
||
articleID := parseUintForm(c, "article_id")
|
||
token := strings.TrimSpace(c.PostForm("session_token"))
|
||
// 创建页面上文章尚不存在;要求提供令牌。
|
||
if articleID == 0 && token == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "missing session_token"})
|
||
return
|
||
}
|
||
|
||
// 所有权检查:非管理员只能附加到自己的文章。
|
||
if articleID != 0 && !canManageArticle(c, db, articleID) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||
return
|
||
}
|
||
|
||
file, header, err := c.Request.FormFile("file")
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "no file provided"})
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
// 依据平台上传策略校验(总开关 + 类型 + 大小)。
|
||
check := ValidateUpload(header)
|
||
if !check.OK {
|
||
if !models.GetUploadConfig().Enabled {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "uploads are disabled"})
|
||
return
|
||
}
|
||
if check.Type != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
|
||
return
|
||
}
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
||
return
|
||
}
|
||
|
||
// 完整读取:用于内容哈希(去重)和魔数内容校验(#14)。
|
||
content, err := io.ReadAll(file)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to read file"})
|
||
return
|
||
}
|
||
|
||
// SECURITY_TODO #14:文件字节必须与声明扩展名配置的 MIME 类型匹配
|
||
//(按头部策略进行魔数校验)。名为 .txt 却携带 PNG 字节的文件将被拒绝。
|
||
if !contentMatchesType(check.Type, content) {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"})
|
||
return
|
||
}
|
||
|
||
sum := sha256.Sum256(content)
|
||
stored := hex.EncodeToString(sum[:])
|
||
|
||
// 磁盘去重:仅在文件不存在时才写入。
|
||
dir := attachmentsDir(storagePath)
|
||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to create storage dir"})
|
||
return
|
||
}
|
||
dstPath := filepath.Join(dir, stored)
|
||
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
|
||
if err := os.WriteFile(dstPath, content, 0644); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save file"})
|
||
return
|
||
}
|
||
}
|
||
|
||
ext := strings.ToLower(filepath.Ext(header.Filename))
|
||
att := models.Attachment{
|
||
ArticleID: articleID,
|
||
SessionToken: token,
|
||
UploaderID: uploaderID,
|
||
Filename: header.Filename,
|
||
StoredName: stored,
|
||
Ext: ext,
|
||
MIME: header.Header.Get("Content-Type"),
|
||
Size: header.Size,
|
||
Category: check.Type.Category,
|
||
}
|
||
if err := db.Create(&att).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to record attachment"})
|
||
return
|
||
}
|
||
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"id": att.ID,
|
||
"filename": att.Filename,
|
||
"size": att.Size,
|
||
"category": att.Category,
|
||
"url": attachmentURL(att.StoredName),
|
||
"is_image": att.IsImage(),
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------- 删除 ----------------
|
||
|
||
// DeleteAttachment 软删除附件记录,仅当没有其余记录引用时才删除磁盘文件
|
||
// (引用计数,因为内容寻址的文件可能被共享)。只有管理员、上传者或
|
||
// 文件所在文章的作者可以删除。
|
||
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
id := parseUintParam(c, "id")
|
||
var att models.Attachment
|
||
if err := db.First(&att, id).Error; err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "not found"})
|
||
return
|
||
}
|
||
|
||
// 所有权检查:管理员、上传者或所属文章的作者。
|
||
if !currentUserIsAdmin(c) {
|
||
uid, ok := sessionAuthorID(c)
|
||
owned := ok && att.UploaderID == uid
|
||
if !owned && att.ArticleID != 0 {
|
||
owned = canManageArticle(c, db, att.ArticleID)
|
||
}
|
||
if !owned {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||
return
|
||
}
|
||
}
|
||
stored := att.StoredName
|
||
|
||
if err := db.Delete(&att).Error; err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete"})
|
||
return
|
||
}
|
||
|
||
// 引用计数:还有任何其他(未删除)行指向此文件吗?
|
||
var count int64
|
||
db.Model(&models.Attachment{}).Where("stored_name = ?", stored).Count(&count)
|
||
if count == 0 {
|
||
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||
}
|
||
}
|
||
|
||
// ---------------- 列表 ----------------
|
||
|
||
// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。
|
||
// 只有文章作者(或管理员)可以列出。
|
||
func ListAttachments(db *gorm.DB) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
articleID := parseUintParam(c, "id")
|
||
if articleID == 0 {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid article id"})
|
||
return
|
||
}
|
||
if !canManageArticle(c, db, articleID) {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "forbidden"})
|
||
return
|
||
}
|
||
var atts []models.Attachment
|
||
db.Where("article_id = ?", articleID).Order("created_at ASC").Find(&atts)
|
||
|
||
out := make([]gin.H, 0, len(atts))
|
||
for _, a := range atts {
|
||
out = append(out, gin.H{
|
||
"id": a.ID,
|
||
"filename": a.Filename,
|
||
"size": a.Size,
|
||
"category": a.Category,
|
||
"url": attachmentURL(a.StoredName),
|
||
"is_image": a.IsImage(),
|
||
})
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"attachments": out})
|
||
}
|
||
}
|
||
|
||
// ---------------- 绑定(方案 A)----------------
|
||
|
||
// BindPendingAttachments 将文章创建期间上传的附件
|
||
// (由 session_token 持有、article_id=0)绑定到新创建的文章。
|
||
// 由 ArticleCreate 在保存文章行之后调用。
|
||
func BindPendingAttachments(db *gorm.DB, token string, articleID uint) error {
|
||
if token == "" {
|
||
return nil
|
||
}
|
||
return db.Model(&models.Attachment{}).
|
||
Where("session_token = ? AND article_id = 0", token).
|
||
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
|
||
}
|
||
|
||
// ---------------- 辅助函数 ----------------
|
||
|
||
// parseUintForm 解析 uint 表单字段,容忍空/非法输入。
|
||
func parseUintForm(c *gin.Context, field string) uint {
|
||
v := strings.TrimSpace(c.PostForm(field))
|
||
if v == "" {
|
||
return 0
|
||
}
|
||
var n uint
|
||
_, _ = fmt.Sscanf(v, "%d", &n)
|
||
return n
|
||
}
|
||
|
||
// parseUintParam 解析 uint 路由参数。
|
||
func parseUintParam(c *gin.Context, name string) uint {
|
||
var n uint
|
||
_, _ = fmt.Sscanf(c.Param(name), "%d", &n)
|
||
return n
|
||
}
|