Files
go_blog/handlers/attachment.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

290 lines
8.8 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"
"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
}