- 提取共享上传引擎 saveUploadedFile(handlers/attachment.go): 内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用 - UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记 models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希, 公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由) - 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时 删除旧磁盘文件(与附件一致的引用计数语义) - 既有头像安全测试断言从 <uid>.jpg 改为 64 位哈希; 新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程
312 lines
10 KiB
Go
312 lines
10 KiB
Go
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")
|
||
// 与超出范围的值一律返回 0(SECURITY_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))
|
||
}
|