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
3 changed files with 151 additions and 54 deletions
+62
View File
@@ -19,6 +19,68 @@ import (
"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()
+23 -11
View File
@@ -212,18 +212,30 @@ 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"
}
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
sum := sha256.Sum256(processedBytes)
sum := sha256.Sum256(storedBytes)
storedName := hex.EncodeToString(sum[:])
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
@@ -234,11 +246,11 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: "image/jpeg",
Size: int64(len(processedBytes)),
MIME: finalMIME,
Size: int64(len(storedBytes)),
Category: models.CategoryImage,
}
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), processedBytes); 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
}
+66 -43
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,49 +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 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;
});
uploadAvatarBytes(blob, 'avatar.jpg');
}, 'image/jpeg', 0.92);
});