From 942e3174b996ecbbaad96dd7a1054c99558da616 Mon Sep 17 00:00:00 2001 From: dsh Date: Fri, 28 Aug 2026 20:13:13 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=B4=E5=83=8F=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E7=BA=B3=E5=85=A5=E7=BB=9F=E4=B8=80=E4=B8=8A=E4=BC=A0=E5=BC=95?= =?UTF-8?q?=E6=93=8E=E2=80=94=E2=80=94files=20=E8=A1=A8=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=20type=3Davatars?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 提取共享上传引擎 saveUploadedFile(handlers/attachment.go): 内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用 - UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记 models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希, 公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由) - 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时 删除旧磁盘文件(与附件一致的引用计数语义) - 既有头像安全测试断言从 .jpg 改为 64 位哈希; 新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程 --- handlers/attachment.go | 42 +++--- handlers/avatar_files_test.go | 167 +++++++++++++++++++++++ handlers/profile.go | 56 +++++--- handlers/session_upload_security_test.go | 9 +- models/file.go | 4 + 5 files changed, 239 insertions(+), 39 deletions(-) create mode 100644 handlers/avatar_files_test.go diff --git a/handlers/attachment.go b/handlers/attachment.go index 5742081..76a56b5 100644 --- a/handlers/attachment.go +++ b/handlers/attachment.go @@ -139,20 +139,6 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { 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, @@ -166,8 +152,9 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { 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"}) + // 统一上传引擎:磁盘去重写入(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 } @@ -182,6 +169,29 @@ func UploadAttachment(db *gorm.DB, storagePath string) gin.HandlerFunc { } } +// 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),仅当 diff --git a/handlers/avatar_files_test.go b/handlers/avatar_files_test.go new file mode 100644 index 0000000..7f13631 --- /dev/null +++ b/handlers/avatar_files_test.go @@ -0,0 +1,167 @@ +package handlers + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "image" + "image/color" + "image/png" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "go_blog/models" +) + +// newTestPNG 生成一张指定大小的纯色 PNG。 +func newTestPNG(t *testing.T, w, h int, c color.RGBA) []byte { + t.Helper() + img := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + img.Set(x, y, c) + } + } + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + t.Fatalf("encode png: %v", err) + } + return buf.Bytes() +} + +// isHexString 报告字符串是否全部为十六进制字符。 +func isHexString(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if !strings.ContainsRune("0123456789abcdef", r) { + return false + } + } + return true +} + +// avatarPOST 以 multipart 提交头像上传(表单字段名 avatar)。 +func avatarPOST(e *securityTestEnv, cookie, csrf string, img []byte, filename string) *httptest.ResponseRecorder { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + fw, _ := mw.CreateFormFile("avatar", filename) + _, _ = fw.Write(img) + _ = mw.Close() + req := httptest.NewRequest(http.MethodPost, "/api/profile/avatar", &buf) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("X-CSRF-Token", csrf) + req.Header.Set("Cookie", cookie) + w := httptest.NewRecorder() + e.router.ServeHTTP(w, req) + return w +} + +// TestAvatarUploadRegistersFileRow 验证头像上传走统一上传引擎: +// files 表登记 type=avatars、stored_name=处理内容 SHA-256 哈希、 +// 用户 Avatar 字段与磁盘文件 avatars/<哈希>、公开 URL /uploads/avatars/<哈希>。 +// 更换头像时:旧登记行软删除;旧磁盘文件无其他引用即清理。 +func TestAvatarUploadRegistersFileRow(t *testing.T) { + e := newSecurityTestEnv(t) + + // 允许 .png(平台策略允许的图片类型)。 + _ = e.db.Create(&models.UploadFileType{Extension: ".png", MimeType: "image/png", Category: models.CategoryImage, Enabled: true}) + models.LoadConfigCache(e.db) + + alice := e.login(t, "alice") + token := e.csrfTokenFor(t, alice) + aliceID := userIDByUsername(t, e.db, "alice") + + // --- 第一次上传 --- + img1 := newTestPNG(t, 16, 16, color.RGBA{R: 200, G: 30, B: 30, A: 255}) + w := avatarPOST(e, alice, token, img1, "avatar.png") + if w.Code != http.StatusOK { + t.Fatalf("avatar upload: status = %d, body %s", w.Code, w.Body.String()) + } + var resp struct { + Avatar string `json:"avatar"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(resp.Avatar) != 64 || !isHexString(resp.Avatar) { + t.Fatalf("avatar stored name = %q, want 64-char sha256 hex", resp.Avatar) + } + name1 := resp.Avatar + + // files 表记录:type=avatars + 哈希存储名 + 归属上传者。 + var f models.File + if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&f).Error; err != nil { + t.Fatalf("files row (type=avatars) not found: %v", err) + } + if f.UploaderID != aliceID { + t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID) + } + if f.Category != models.CategoryImage || f.Ext != ".jpg" || f.MIME != "image/jpeg" { + t.Fatalf("avatar row category/ext/mime = %q/%q/%q", f.Category, f.Ext, f.MIME) + } + + // 用户记录头像 = 哈希。 + var u models.User + if err := e.db.First(&u, aliceID).Error; err != nil { + t.Fatalf("load user: %v", err) + } + if u.Avatar != name1 { + t.Fatalf("user.Avatar = %q, want %q", u.Avatar, name1) + } + + // 磁盘文件位于 avatars/<哈希>,内容为处理后的 256x256 JPEG,且与哈希一致。 + diskPath := filepath.Join(e.storageDir, "avatars", name1) + raw, err := os.ReadFile(diskPath) + if err != nil { + t.Fatalf("avatar disk file missing: %v", err) + } + if len(raw) == 0 { + t.Fatal("avatar disk file is empty") + } + if sum := sha256.Sum256(raw); hex.EncodeToString(sum[:]) != name1 { + t.Fatal("disk file content does not match stored_name (sha256)") + } + + // --- 更换头像 --- + img2 := newTestPNG(t, 16, 16, color.RGBA{R: 30, G: 30, B: 200, A: 255}) + w = avatarPOST(e, alice, token, img2, "avatar2.png") + if w.Code != http.StatusOK { + t.Fatalf("second avatar upload: status = %d, body %s", w.Code, w.Body.String()) + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode second response: %v", err) + } + if resp.Avatar == "" || resp.Avatar == name1 { + t.Fatalf("second avatar name = %q, want a new hash", resp.Avatar) + } + name2 := resp.Avatar + + // 旧的 avatars 登记行已软删除(活动查询不可见,Unscoped 可见且 DeletedAt 非空)。 + var oldRow models.File + if err := e.db.Unscoped().Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name1).First(&oldRow).Error; err != nil { + t.Fatalf("old avatar row not found (unscoped): %v", err) + } + if !oldRow.DeletedAt.Valid { + t.Fatal("old avatar row should be soft-deleted") + } + var active models.File + if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, name2).First(&active).Error; err != nil { + t.Fatalf("new avatar row not found: %v", err) + } + + // 旧磁盘文件无其他引用,应被清理。 + if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name1)); !os.IsNotExist(err) { + t.Fatal("old avatar disk file should have been removed") + } + if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", name2)); err != nil { + t.Fatalf("new avatar disk file missing: %v", err) + } +} diff --git a/handlers/profile.go b/handlers/profile.go index 716749d..3246b6e 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -2,6 +2,8 @@ package handlers import ( "bytes" + "crypto/sha256" + "encoding/hex" "fmt" "image" "image/jpeg" @@ -219,33 +221,51 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc { return } - // 确保头像目录存在。 - avatarDir := filepath.Join(storagePath, "avatars") - os.MkdirAll(avatarDir, 0755) + // 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表 + // 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。 + sum := sha256.Sum256(processedBytes) + storedName := hex.EncodeToString(sum[:]) - // 删除旧头像文件。 - if user.Avatar != "" { - oldPath := filepath.Join(avatarDir, user.Avatar) - os.Remove(oldPath) + oldAvatar := user.Avatar // 替换前的旧头像(旧命名 .jpg 或哈希) + + f := models.File{ + Type: models.FileTypeAvatar, + UploaderID: user.ID, + Filename: header.Filename, + StoredName: storedName, + Ext: finalExt, + MIME: "image/jpeg", + Size: int64(len(processedBytes)), + Category: models.CategoryImage, } - - // 保存处理后的头像。 - savedName := fmt.Sprintf("%d%s", user.ID, finalExt) - savedPath := filepath.Join(avatarDir, savedName) - if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil { + if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), processedBytes); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"}) return } - // 更新用户记录。 - user.Avatar = savedName + // 更新用户记录与会话(Avatar 存哈希,公开 URL /uploads/avatars/<哈希>)。 + user.Avatar = storedName db.Save(&user) - - // 更新会话。 - session.Set("avatar", savedName) + session.Set("avatar", storedName) session.Save() - c.JSON(http.StatusOK, gin.H{"avatar": savedName}) + // 清理旧的头像记录与文件: + // - 软删除旧的 avatars 登记行(保留历史查询痕迹,仅从活动查询隐藏); + // - 仅当无其他 files 记录或其他用户引用时删除磁盘文件, + // 避免误删被共享的内容寻址文件(引用计数语义与附件一致)。 + if oldAvatar != "" && oldAvatar != storedName { + db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar). + Delete(&models.File{}) + var refs int64 + db.Model(&models.File{}).Where("type = ? AND stored_name = ?", models.FileTypeAvatar, oldAvatar).Count(&refs) + var otherUsers int64 + db.Model(&models.User{}).Where("avatar = ? AND id <> ?", oldAvatar, user.ID).Count(&otherUsers) + if refs == 0 && otherUsers == 0 { + os.Remove(filepath.Join(storagePath, "avatars", oldAvatar)) // 忽略错误 + } + } + + c.JSON(http.StatusOK, gin.H{"avatar": storedName}) } } diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index cccde78..c19c94f 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -2,7 +2,6 @@ package handlers import ( "bytes" - "fmt" "image" "image/color" "image/png" @@ -239,8 +238,8 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) { t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String()) } alice := reloadAlice(t, e) - if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want { - t.Fatalf("avatar = %q, want %q", alice.Avatar, want) + if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) { + t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar) } if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil { t.Fatalf("processed avatar file missing: %v", err) @@ -272,8 +271,8 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) { t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String()) } alice := reloadAlice(t, e) - if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want { - t.Fatalf("avatar = %q, want %q", alice.Avatar, want) + if len(alice.Avatar) != 64 || !isHexString(alice.Avatar) { + t.Fatalf("avatar = %q, want 64-char sha256 hex (hash-based name)", alice.Avatar) } if _, err := os.Stat(filepath.Join(e.storageDir, "avatars", alice.Avatar)); err != nil { t.Fatalf("processed avatar file missing: %v", err) diff --git a/models/file.go b/models/file.go index 2791363..d84947a 100644 --- a/models/file.go +++ b/models/file.go @@ -45,6 +45,10 @@ func (File) TableName() string { // FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。 const FileTypeAttachment = "attachments" +// FileTypeAvatar 是文件归属类型常量:用户头像(存储于 avatars/ 目录, +// 公开下载链接为 /uploads/avatars/)。 +const FileTypeAvatar = "avatars" + // IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。 func (f *File) IsImage() bool { return f.Category == CategoryImage -- 2.47.3