From b6264aa55595ddebe6881e8762f03243f35213e5 Mon Sep 17 00:00:00 2001 From: dsh Date: Fri, 28 Aug 2026 20:33:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A4=B4=E5=83=8F=E6=94=AF=E6=8C=81=20?= =?UTF-8?q?GIF=E2=80=94=E2=80=94=E8=B7=B3=E8=BF=87=E8=A3=81=E5=89=AA?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E4=B8=8A=E4=BC=A0=EF=BC=8C=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=AD=97=E8=8A=82=E5=AD=98=E5=82=A8=E4=BF=9D=E7=95=99=E5=8A=A8?= =?UTF-8?q?=E7=94=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 客户端(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 行元数据/归属者断言 --- handlers/avatar_files_test.go | 62 +++++++++++++++++++ handlers/profile.go | 34 +++++++---- templates/pages/profile.html | 109 ++++++++++++++++++++-------------- 3 files changed, 151 insertions(+), 54 deletions(-) diff --git a/handlers/avatar_files_test.go b/handlers/avatar_files_test.go index 7f13631..abf139d 100644 --- a/handlers/avatar_files_test.go +++ b/handlers/avatar_files_test.go @@ -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 GIF89a(43 字节)。 + 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() diff --git a/handlers/profile.go b/handlers/profile.go index 3246b6e..518ce61 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -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 // 替换前的旧头像(旧命名 .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 } diff --git a/templates/pages/profile.html b/templates/pages/profile.html index 562cac8..25b52f3 100644 --- a/templates/pages/profile.html +++ b/templates/pages/profile.html @@ -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 = + 'avatar'; + + // Update nav bar avatar:只替换头像圆形容器内的内容, + // 保留按钮结构与用户名文本,避免样式被破坏。 + var navAvatarWrap = document.getElementById('headerAvatarWrap'); + if (navAvatarWrap) { + navAvatarWrap.innerHTML = + 'avatar'; + } + + 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 = - 'avatar'; - - // Update nav bar avatar:只替换头像圆形容器内的内容, - // 保留按钮结构与用户名文本,避免样式被破坏。 - var navAvatarWrap = document.getElementById('headerAvatarWrap'); - if (navAvatarWrap) { - navAvatarWrap.innerHTML = - 'avatar'; - } - - 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); });