Author SHA1 Message Date
dsh b6264aa555 feat: 头像支持 GIF——跳过裁剪直接上传,原始字节存储保留动画
- 客户端(profile.html):选中 GIF 时不进入裁剪弹窗,直接上传原始文件
  (不做 canvas 转码,保留动画);上传前按 10 MiB 做大小判断(服务端仍
  按平台策略强制校验);把上传+预览+导航头像更新逻辑抽为
  uploadAvatarBytes(blob, filename),裁剪流与 GIF 直传共用
- 服务端(UploadAvatar):GIF(配置 MIME=image/gif,已过白名单/魔数校验)
  跳过 256x256 缩放与 JPEG 重编码,原字节直存,files 行
  ext=.gif / mime=image/gif;其他类型行为不变
- 新增 TestAvatarUploadGifStoredRaw:原始字节一致性/哈希命名/
  files 行元数据/归属者断言
2026-08-28 20:33:40 +08:00
dsh cc49152f1b fix: 更换头像后 header 头像样式异常——只替换头像容器内容
旧逻辑在头像上传成功后把整个 avatarBtn 按钮的 innerHTML 换成单个
<img>:头像圆形容器(w-8 h-8 rounded-full)、用户名 span 全部丢失,
头像失去圆形裁剪与边框、布局错乱,直到刷新后服务端重新渲染才恢复。

- base.html:头像是像圆形容器加 id="headerAvatarWrap"(服务器渲染结构
  不变,仅加标记)
- profile.html:上传成功后仅更新容器内容(img/svg → 新头像),
  保留按钮结构与用户名文本
2026-08-28 20:26:00 +08:00
kevin a3122b04a4 Merge pull request 'feat: 头像上传纳入统一上传引擎——files 表登记 type=avatars,链接 /uploads/avatars/哈希' (#8) from dsh/go_blog:feat/avatar-unified-upload into main
Reviewed-on: kevin/go_blog#8
2026-08-28 20:18:04 +08:00
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
kevin a6c1f92197 Merge pull request 'fix: attachments→files 迁移增加内容回退策略,防重建表后 id 撞号漏搬' (#7) from dsh/go_blog:fix/attachment-migration-content-fallback into main
Reviewed-on: kevin/go_blog#7
2026-08-28 20:04:57 +08:00
kevin 73412e51b4 Merge pull request 'feat: 文章编辑体验升级——编辑器图片上传、作者附件区、作者编辑按钮、表单模板统一' (#6) from dsh/go_blog:feat/article-upload-ui into main
Reviewed-on: kevin/go_blog#6
2026-08-28 20:04:47 +08:00
dsh 30af358dd7 fix: 迁移 attachments→files 增加内容回退策略,防重建表后 id 撞号漏搬
场景:中途部署过旧版二进制时 AutoMigrate 会把 attachments 以
id=1 重新编号;此前按 id 对齐的 NOT EXISTS 会误判已迁移,
导致新上传行静默丢失。现在第二段按 stored_name(内容 SHA-256)
补齐 id 冲突的行(省略 id 由数据库重新分配,内容已登记则跳过)。
2026-08-28 20:03:39 +08:00
8 changed files with 414 additions and 91 deletions
+26 -16
View File
@@ -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),仅当
+229
View File
@@ -0,0 +1,229 @@
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"
)
// TestAvatarUploadGifStoredRaw 验证 GIF 头像跳过裁剪/缩放/重编码:
// 原始字节原样存储(保留动画),stored_name 为其 SHA-256
// files 行为 ext=.gif / mime=image/gif,磁盘内容与上传字节完全一致。
func TestAvatarUploadGifStoredRaw(t *testing.T) {
e := newSecurityTestEnv(t)
_ = e.db.Create(&models.UploadFileType{Extension: ".gif", MimeType: "image/gif", 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")
// 1x1 GIF89a43 字节)。
gifBytes := []byte{
0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x01, 0x00, 0x01, 0x00, 0x80, 0x00, 0x00,
0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0x21, 0xF9, 0x04, 0x00, 0x00, 0x00, 0x00,
0x00, 0x2C, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x02, 0x02,
0x44, 0x01, 0x00, 0x3B,
}
w := avatarPOST(e, alice, token, gifBytes, "avatar.gif")
if w.Code != http.StatusOK {
t.Fatalf("gif 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)
}
// 原始字节原样存储:磁盘内容 == 上传的 GIF 字节,stored_name 为其哈希。
raw, err := os.ReadFile(filepath.Join(e.storageDir, "avatars", resp.Avatar))
if err != nil {
t.Fatalf("gif avatar disk file missing: %v", err)
}
if !bytes.Equal(raw, gifBytes) {
t.Fatalf("gif bytes must be stored raw (len %d -> %d)", len(gifBytes), len(raw))
}
if sum := sha256.Sum256(gifBytes); hex.EncodeToString(sum[:]) != resp.Avatar {
t.Fatal("stored_name must be sha256 of raw gif bytes")
}
// files 行:type=avatars、ext=.gif、mime=image/gif、大小=原始字节数。
var f models.File
if err := e.db.Where("type = ? AND stored_name = ?", models.FileTypeAvatar, resp.Avatar).First(&f).Error; err != nil {
t.Fatalf("files row not found: %v", err)
}
if f.Ext != ".gif" || f.MIME != "image/gif" {
t.Fatalf("avatar row ext/mime = %q/%q, want .gif/image/gif", f.Ext, f.MIME)
}
if f.Size != int64(len(gifBytes)) {
t.Fatalf("avatar size = %d, want %d", f.Size, len(gifBytes))
}
if f.UploaderID != aliceID {
t.Fatalf("uploader_id = %d, want %d", f.UploaderID, aliceID)
}
}
// 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)
}
}
+57 -25
View File
@@ -2,6 +2,8 @@ package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/jpeg"
@@ -210,42 +212,72 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
return
}
// 解码、缩放并重新编码图像。
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
if err != nil {
// SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节
// 会让非图像内容进入 avatars/ 目录。
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
return
// GIF 直接存储原始字节(保留动画,不做裁剪/缩放/重编码);
// 其余图片类型解码、缩放 256x256 并重编码为 JPEG。
var storedBytes []byte
var finalExt, finalMIME string
if check.Type.MimeType == "image/gif" {
storedBytes = imgBytes
finalExt = ".gif"
finalMIME = "image/gif"
} else {
processedBytes, extOut, err := processAvatar(imgBytes, ext)
if err != nil {
// SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节
// 会让非图像内容进入 avatars/ 目录。
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
return
}
storedBytes = processedBytes
finalExt = extOut
finalMIME = "image/jpeg"
}
// 确保头像目录存在。
avatarDir := filepath.Join(storagePath, "avatars")
os.MkdirAll(avatarDir, 0755)
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
sum := sha256.Sum256(storedBytes)
storedName := hex.EncodeToString(sum[:])
// 删除旧头像文件。
if user.Avatar != "" {
oldPath := filepath.Join(avatarDir, user.Avatar)
os.Remove(oldPath)
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
f := models.File{
Type: models.FileTypeAvatar,
UploaderID: user.ID,
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: finalMIME,
Size: int64(len(storedBytes)),
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"), storedBytes); 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})
}
}
+4 -5
View File
@@ -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)
+27 -2
View File
@@ -142,12 +142,18 @@ func InitDB(cfg *config.Config) *gorm.DB {
// files 表,type 一律标记为 "attachments"。仅当 attachments 表仍存在时
// 执行——新安装从未创建过该表,而已经切换的部署会将其删除。
//
// 幂等策略:以主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过,
// 因此 InitDB 每次启动重复执行也不会产生重复数据(含软删除行一并复制)。
// 幂等与异常恢复策略:
// 1. 按主键 id 对齐——files 中已存在同 id 的行视为已迁移并跳过;
// 2. 若 attachments 表被重建(例如中途部署过旧版二进制,AutoMigrate 把表
// 从 id=1 重新编号),旧 id 已被其他内容占用,改用 stored_name(内容
// SHA-256)比对补齐,避免新上传被静默漏搬。
// 含软删除行一并复制;重复执行不会产生重复数据。
func migrateAttachmentsToFiles(db *gorm.DB) {
if !db.Migrator().HasTable("attachments") {
return
}
// 1) id 对齐迁移(常规升级路径)。
res := db.Exec(`
INSERT INTO files
(id, type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
@@ -163,4 +169,23 @@ WHERE NOT EXISTS (SELECT 1 FROM files f WHERE f.id = a.id)`)
if res.RowsAffected > 0 {
log.Printf("Migration: copied %d attachment(s) into files table (type=attachments)", res.RowsAffected)
}
// 2) 内容补齐:id 已存在但指向不同内容(重建表后 id 撞号)的行,
// 省略 id 让数据库重新分配,且跳过内容已在 files 中登记的行(去重)。
res = db.Exec(`
INSERT INTO files
(type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
SELECT
'attachments', a.article_id, a.session_token, a.uploader_id, a.filename,
a.stored_name, a.ext, a.mime, a.size, a.category, a.created_at, a.updated_at, a.deleted_at
FROM attachments a
WHERE EXISTS (SELECT 1 FROM files f WHERE f.id = a.id AND f.stored_name <> a.stored_name)
AND NOT EXISTS (SELECT 1 FROM files f WHERE f.stored_name = a.stored_name)`)
if res.Error != nil {
log.Printf("Migration attachments -> files (content fallback) failed: %v", res.Error)
return
}
if res.RowsAffected > 0 {
log.Printf("Migration: recovered %d colliding attachment(s) into files table by content", res.RowsAffected)
}
}
+4
View File
@@ -45,6 +45,10 @@ func (File) TableName() string {
// FileTypeAttachment 是文件归属类型常量:文章附件(原 attachments 表历史数据)。
const FileTypeAttachment = "attachments"
// FileTypeAvatar 是文件归属类型常量:用户头像(存储于 avatars/ 目录,
// 公开下载链接为 /uploads/avatars/<stored_name>)。
const FileTypeAvatar = "avatars"
// IsImage 报告该文件是否为图片(用于决定 Markdown 插入形式:![]() 还是 []())。
func (f *File) IsImage() bool {
return f.Category == CategoryImage
+1 -1
View File
@@ -58,7 +58,7 @@
<!-- Avatar Dropdown (click to toggle) -->
<div class="relative" id="avatarDropdown">
<button type="button" id="avatarBtn" class="flex items-center gap-2 hover:opacity-80 transition-opacity cursor-pointer" onclick="toggleDropdown()">
<div class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
<div id="headerAvatarWrap" class="w-8 h-8 rounded-full overflow-hidden border-2 border-gray-300 hover:border-blue-500 transition-colors flex items-center justify-center bg-gray-200 text-gray-600 font-bold text-sm">
{{if .Avatar}}
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
+66 -42
View File
@@ -142,6 +142,58 @@
var csrfTokenEl = document.querySelector('meta[name="csrf-token"]');
var csrfToken = csrfTokenEl ? csrfTokenEl.getAttribute('content') : '';
// 头像大小上限(与服务端默认单文件限制一致;服务端仍会强制校验)。
var MAX_AVATAR_SIZE = 10 * 1024 * 1024;
var i18nTooLarge = "{{index .Tr "profile_upload_too_large"}}";
// 上传头像字节(multipart,表单字段名 avatar);成功后同步更新
// 资料页预览与导航头像,并复位裁剪弹窗/输入框。
function uploadAvatarBytes(blob, filename) {
var formData = new FormData();
formData.append('avatar', blob, filename);
fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: formData,
credentials: 'same-origin'
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.avatar) {
var ts = Date.now();
var avatarUrl = '/uploads/avatars/' + data.avatar + '?t=' + ts;
// Update profile page preview
previewContainer.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
// Update nav bar avatar:只替换头像圆形容器内的内容,
// 保留按钮结构与用户名文本,避免样式被破坏。
var navAvatarWrap = document.getElementById('headerAvatarWrap');
if (navAvatarWrap) {
navAvatarWrap.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
}
showStatus(i18nCropSuccess, 'green');
} else {
showStatus(data.error || i18nCropError, 'red');
}
})
.catch(function() {
showStatus(i18nCropError, 'red');
})
.finally(function() {
if (cropper) { cropper.destroy(); cropper = null; }
modal.classList.add('hidden');
modal.classList.remove('flex');
fileInput.value = '';
confirmBtn.disabled = false;
confirmBtn.textContent = i18nCropConfirm;
});
}
fileInput.addEventListener('change', function() {
var file = this.files[0];
if (!file) return;
@@ -152,6 +204,19 @@
return;
}
// GIF:跳过裁剪,直接上传原始文件(保留动画,无需转码);
// 仅做大小判断,服务端仍按平台策略强制校验。
if (file.type === 'image/gif' || /\.gif$/i.test(file.name)) {
if (file.size > MAX_AVATAR_SIZE) {
showStatus(i18nTooLarge.replace('%s', '10 MiB'), 'red');
fileInput.value = '';
return;
}
showStatus(i18nCropUploading, 'gray');
uploadAvatarBytes(file, file.name);
return;
}
var reader = new FileReader();
reader.onload = function(e) {
cropImage.src = e.target.result;
@@ -193,48 +258,7 @@
});
canvas.toBlob(function(blob) {
var formData = new FormData();
formData.append('avatar', blob, 'avatar.jpg');
fetch('/api/profile/avatar', {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
body: formData,
credentials: 'same-origin'
})
.then(function(res) { return res.json(); })
.then(function(data) {
if (data.avatar) {
var ts = Date.now();
var avatarUrl = '/uploads/avatars/' + data.avatar + '?t=' + ts;
// Update profile page preview
previewContainer.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
// Update nav bar avatar
var navAvatarBtn = document.getElementById('avatarBtn');
if (navAvatarBtn) {
navAvatarBtn.innerHTML =
'<img src="' + avatarUrl + '" alt="avatar" class="w-full h-full object-cover">';
}
showStatus(i18nCropSuccess, 'green');
} else {
showStatus(data.error || i18nCropError, 'red');
}
})
.catch(function() {
showStatus(i18nCropError, 'red');
})
.finally(function() {
if (cropper) { cropper.destroy(); cropper = null; }
modal.classList.add('hidden');
modal.classList.remove('flex');
fileInput.value = '';
confirmBtn.disabled = false;
confirmBtn.textContent = i18nCropConfirm;
});
uploadAvatarBytes(blob, 'avatar.jpg');
}, 'image/jpeg', 0.92);
});