Files
go_blog/handlers/attachment.go
dsh 970dbd4c5b refactor: 上传/下载接口全面迁移到统一 files 表,移除 attachments 模型
- 删除 models/attachment.go,Attachment 模型由 File(Type=attachments)替代
- handlers/attachment.go:上传/删除/列表/绑定全部改读写 files 表,
  查询按 type='attachments' 过滤(为 avatar/logo 等类型预留隔离)
- models/db.go:AutoMigrate 移除 Attachment;启动迁移仅当旧表仍存在时执行
  (升级路径保障,已删除则为无操作)
- 测试同步切换到 File 模型(security_test/bodylimit_test)
- scripts/drop_attachments_table.sql:旧表删除脚本(含前置校验说明)
2026-08-28 19:23:50 +08:00

302 lines
9.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"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 内容寻址,实现磁盘去重。
//
// 记录写入全站统一的 files 表(Type=attachments),与历史数据同属一个表。
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.File{
Type: models.FileTypeAttachment,
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 软删除附件记录(files 表,Type=attachments),仅当
// 没有其余记录引用时才删除磁盘文件(引用计数,因为内容寻址的文件可能
// 被共享)。只有管理员、上传者或文件所在文章的作者可以删除。
func DeleteAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
id := parseUintParam(c, "id")
var att models.File
if err := db.First(&att, "id = ? AND type = ?", id, models.FileTypeAttachment).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.File{}).Where("stored_name = ? AND type = ?", stored, models.FileTypeAttachment).Count(&count)
if count == 0 {
os.Remove(filepath.Join(attachmentsDir(storagePath), stored)) // 忽略错误
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
}
// ---------------- 列表 ----------------
// ListAttachments 以 JSON 返回文章的附件(供编辑页加载时重新填充列表)。
// 只读取 files 表中 Type=attachments 的记录。
// 只有文章作者(或管理员)可以列出。
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.File
db.Where("article_id = ? AND type = ?", articleID, models.FileTypeAttachment).
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.File{}).
Where("session_token = ? AND article_id = 0 AND type = ?", token, models.FileTypeAttachment).
Updates(map[string]interface{}{"article_id": articleID, "session_token": ""}).Error
}
// ---------------- 辅助函数 ----------------
// parseUintStrict 严格解析十进制 uint:空值、非数字、前缀数字("5abc"
// 与超出范围的值一律返回 0SECURITY_TODO #32——旧的 Sscanf("%d") 会把
// "5abc" 宽松解析为 5,掩盖非法输入)。
func parseUintStrict(s string) uint {
if s == "" {
return 0
}
n, err := strconv.ParseUint(s, 10, 64)
if err != nil || n > uint64(^uint(0)) {
return 0
}
return uint(n)
}
// parseUintForm 解析 uint 表单字段(严格;空/非法输入返回 0)。
func parseUintForm(c *gin.Context, field string) uint {
return parseUintStrict(strings.TrimSpace(c.PostForm(field)))
}
// parseUintParam 解析 uint 路由参数(严格;空/非法输入返回 0)。
func parseUintParam(c *gin.Context, name string) uint {
return parseUintStrict(c.Param(name))
}