Author SHA1 Message Date
dsh ae59f756ca chore: 存量头像迁移脚本——旧命名 <uid>.jpg 迁入统一 files 表(type=avatars)
其他部署升级到统一上传引擎后,历史头像仍以 <uid>.jpg 命名且未在 files 表
登记,直到所有者下次换头像才被清理。本脚本提供一次性迁移:
内容字节不变,以 SHA-256 哈希命名复制到 avatars/,登记 files 行
(type=avatars),更新 users.avatar,删除旧文件。
幂等:已为 64 位哈希名的用户自动跳过;无遗留文件时无操作。
已在线上库实测(2 个用户全部跳过)。
2026-08-28 20:22:35 +08:00
5 changed files with 126 additions and 152 deletions
-62
View File
@@ -19,68 +19,6 @@ 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()
+11 -23
View File
@@ -212,30 +212,18 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
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"
// 解码、缩放并重新编码图像。
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
if err != nil {
// SECURITY (#21):直接拒绝无法解码的载荷——存储原始字节
// 会让非图像内容进入 avatars/ 目录。
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
return
}
// 统一上传引擎:SHA-256 内容寻址写入 avatars/ 目录,并在 files 表
// 登记一条记录(Type=avatars)。公开链接 /uploads/avatars/<哈希>。
sum := sha256.Sum256(storedBytes)
sum := sha256.Sum256(processedBytes)
storedName := hex.EncodeToString(sum[:])
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
@@ -246,11 +234,11 @@ func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: finalMIME,
Size: int64(len(storedBytes)),
MIME: "image/jpeg",
Size: int64(len(processedBytes)),
Category: models.CategoryImage,
}
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), storedBytes); 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
}
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# 存量头像迁移:把旧命名(<uid>.jpg,历史方案)的用户头像迁入统一 files 表
# type='avatars')并用 SHA-256 哈希命名(公开链接 /uploads/avatars/<哈希>)。
#
# 背景:头像上传自 2026-08 起走统一上传引擎(files 表 + 内容寻址);历史头像
# 文件仍以 <uid>.jpg 命名、未被 files 表登记。本脚本按用户逐个迁移:
# 1. 取头像文件内容 SHA-256 作为新存储名(内容字节不变,不重新编码);
# 2. 复制为 avatars/<哈希> 并登记 files 行(type=avatars);
# 3. 更新 users.avatar
# 4. 删除旧的 <uid>.jpg。
# 幂等:已是 64 位十六进制哈希名的用户自动跳过;无遗留文件时无操作。
#
# 用法:
# MYSQL_HOST=127.0.0.1 MYSQL_PORT=3306 MYSQL_USER=blog_go MYSQL_PASSWORD=xxx \
# MYSQL_DB=blog_go STORAGE_DIR=/srv/blog_go [SVC_USER=blog_go] \
# bash scripts/migrate_legacy_avatars.sh
set -euo pipefail
: "${MYSQL_HOST:=127.0.0.1}"
: "${MYSQL_PORT:=3306}"
: "${MYSQL_USER:?请设置 MYSQL_USER}"
: "${MYSQL_PASSWORD:?请设置 MYSQL_PASSWORD}"
: "${MYSQL_DB:?请设置 MYSQL_DB}"
: "${STORAGE_DIR:?请设置 STORAGE_DIR(存储根目录,头像位于其 avatars/ 子目录)}"
: "${SVC_USER:=blog_go}"
AVATAR_DIR="$STORAGE_DIR/avatars"
mysqlq() { mysql -h"$MYSQL_HOST" -P"$MYSQL_PORT" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DB" -N -B -e "$1"; }
# 前置检查:files 表与头像目录必须存在。
if ! mysqlq "SHOW TABLES LIKE 'files'" | grep -q files; then
echo "错误:数据库 $MYSQL_DB 中不存在 files 表(需先部署 files 表版本)" >&2
exit 1
fi
if [ ! -d "$AVATAR_DIR" ]; then
echo "错误:头像目录不存在:$AVATAR_DIR" >&2
exit 1
fi
migrated=0
skipped=0
while IFS=$'\t' read -r uid avatar; do
[ -n "$avatar" ] || continue
if [[ "$avatar" =~ ^[0-9a-f]{64}$ ]]; then
echo "跳过 $uid:已是哈希命名($avatar"
skipped=$((skipped + 1))
continue
fi
src="$AVATAR_DIR/$avatar"
if [ ! -f "$src" ]; then
echo "跳过 $uid:磁盘文件缺失 $src(不影响 URL,仅提示)"
skipped=$((skipped + 1))
continue
fi
hash=$(sha256sum "$src" | awk '{print $1}')
dst="$AVATAR_DIR/$hash"
if [ ! -f "$dst" ]; then
cp "$src" "$dst"
chown "$SVC_USER:$SVC_USER" "$dst"
fi
# 登记 files 行(幂等:同类型同哈希已存在则跳过)。
mysqlq "INSERT INTO files (type, article_id, session_token, uploader_id, filename, stored_name, ext, mime, size, category, created_at, updated_at, deleted_at)
SELECT 'avatars', 0, '', $uid, '$avatar', '$hash', '.jpg', 'image/jpeg', $(stat -c%s "$src"), 'image', NOW(3), NOW(3), NULL
WHERE NOT EXISTS (SELECT 1 FROM files WHERE type='avatars' AND stored_name='$hash')"
mysqlq "UPDATE users SET avatar='$hash' WHERE id=$uid AND avatar='$avatar'"
rm -f "$src"
echo "迁移 $uid$avatar -> $hash"
migrated=$((migrated + 1))
done < <(mysqlq "SELECT id, avatar FROM users WHERE avatar <> ''")
echo "完成:迁移 $migrated 个,跳过 $skipped 个。"
+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 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">
<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">
{{if .Avatar}}
<img src="/uploads/avatars/{{.Avatar}}" alt="avatar" class="w-full h-full object-cover">
{{else}}
+42 -66
View File
@@ -142,58 +142,6 @@
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;
@@ -204,19 +152,6 @@
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;
@@ -258,7 +193,48 @@
});
canvas.toBlob(function(blob) {
uploadAvatarBytes(blob, 'avatar.jpg');
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;
});
}, 'image/jpeg', 0.92);
});