Files
go_blog/handlers/attachment.go
T
dsh 942e3174b9 feat: 头像上传纳入统一上传引擎——files 表登记 type=avatars
- 提取共享上传引擎 saveUploadedFile(handlers/attachment.go):
  内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用
- UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记
  models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希,
  公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由)
- 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时
  删除旧磁盘文件(与附件一致的引用计数语义)
- 既有头像安全测试断言从 <uid>.jpg 改为 64 位哈希;
  新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程
2026-08-28 20:13:13 +08:00

312 lines
10 KiB
Go
Raw 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[:])
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,
}
// 统一上传引擎:磁盘去重写入(SHA-256 内容寻址)+ files 表登记。
if _, err := saveUploadedFile(db, att, attachmentsDir(storagePath), content); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to save 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(),
})
}
}
// saveUploadedFile 是全站统一上传引擎(files 表 + 内容寻址磁盘存储):
// - 确保 dir 存在;
// - 以 f.StoredName(内容 SHA-256 十六进制)为磁盘文件名,文件已存在则
// 跳过写入(磁盘去重,内容寻址文件可被多行记录共享);
// - 在 files 表登记一条记录(Type/UploaderID/ArticleID 等由调用方给定)。
//
// 附件、头像等所有上传类型共用本引擎。
func saveUploadedFile(db *gorm.DB, f models.File, dir string, content []byte) (models.File, error) {
if err := os.MkdirAll(dir, 0755); err != nil {
return f, err
}
dstPath := filepath.Join(dir, f.StoredName)
if _, err := os.Stat(dstPath); os.IsNotExist(err) {
if err := os.WriteFile(dstPath, content, 0644); err != nil {
return f, err
}
}
if err := db.Create(&f).Error; err != nil {
return f, err
}
return f, nil
}
// ---------------- 删除 ----------------
// 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))
}