Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b6264aa555 |
@@ -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()
|
||||
|
||||
+23
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user