Files
go_blog/handlers/profile.go
T
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

290 lines
8.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handlers
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"fmt"
"image"
"image/jpeg"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/gin-contrib/sessions"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
// 注册 processAvatar 依赖的解码器。JPEG 由上面的 image/jpeg 导入注册;
// png/gif 必须空导入,否则 image.Decode 会拒绝它们。
_ "image/gif"
_ "image/png"
"go_blog/models"
)
// ProfilePage 渲染个人资料编辑页面。
func ProfilePage(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
tr := getTr(c)
session := sessions.Default(c)
userID := session.Get("user_id")
var user models.User
if err := db.First(&user, userID).Error; err != nil {
c.String(http.StatusInternalServerError, "User not found")
return
}
data := DefaultData(c)
data["Title"] = tr["profile_title"]
data["Profile"] = user
// Flash 消息(成功 / 错误)。
if msg := c.Query("saved"); msg == "1" {
data["Success"] = tr["profile_saved"]
}
if msg := c.Query("error"); msg == "pw" {
data["Error"] = tr["profile_wrong_password"]
}
switch c.Query("error") {
case "upload":
data["Error"] = tr["profile_upload_invalid"]
case "upload_disabled":
data["Error"] = tr["profile_upload_disabled"]
case "size":
data["Error"] = fmt.Sprintf(tr["profile_upload_too_large"], c.Query("max"))
case "pw_short":
data["Error"] = tr["profile_password_short"]
case "email":
data["Error"] = tr["profile_email_invalid"]
}
c.HTML(http.StatusOK, "profile", data)
}
}
// profileRequest 是 POST /api/profile 的 JSON 请求体。
// 头像文件上传走 POST /api/profile/avatarmultipart)。
type profileRequest struct {
DisplayName string `json:"display_name"`
Gender string `json:"gender"`
Email string `json:"email"`
Birthday string `json:"birthday"` // YYYY-MM-DD
CurrentPassword string `json:"current_password"`
NewPassword string `json:"new_password"`
}
// UpdateProfile 处理个人资料编辑(JSON)。
func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get("user_id")
var user models.User
if err := db.First(&user, userID).Error; err != nil {
APIError(c, http.StatusNotFound, "user_not_found")
return
}
var req profileRequest
if !bindJSON(c, &req) {
return
}
// --- 文本字段 ---
// 允许 display_name 为空(用户可清空以回退到用户名)
user.DisplayName = strings.TrimSpace(req.DisplayName)
if v := strings.TrimSpace(req.Gender); v != "" {
user.Gender = v
}
// SECURITY (#24):持久化前校验邮箱格式
//(脏值会污染 Gravatar 查询)。允许为空。
if v := strings.TrimSpace(req.Email); v != "" {
if !validateEmail(v) {
APIError(c, http.StatusBadRequest, "profile_email_invalid")
return
}
user.Email = v
}
if v := strings.TrimSpace(req.Birthday); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
user.Birthday = &t
}
}
// --- 密码修改 ---
currentPass := req.CurrentPassword
newPass := req.NewPassword
if currentPass != "" && newPass != "" {
if !user.CheckPassword(currentPass) {
APIError(c, http.StatusBadRequest, "profile_wrong_password")
return
}
// SECURITY (#23):执行与注册相同的最小长度;
// 重置为 1 个字符的密码将极易被猜出。
if !validatePassword(newPass) {
APIError(c, http.StatusBadRequest, "profile_password_short")
return
}
if err := user.SetPassword(newPass); err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
}
// 保存用户记录。
if err := db.Save(&user).Error; err != nil {
APIError(c, http.StatusInternalServerError, "api_error")
return
}
// 更新会话中的显示名。
session.Set("display_name", user.DisplayName)
session.Save()
APIOK(c, "/profile?saved=1", nil)
}
}
// UploadAvatar 处理带裁剪的 AJAX 头像上传。
func UploadAvatar(db *gorm.DB, storagePath string) gin.HandlerFunc {
return func(c *gin.Context) {
session := sessions.Default(c)
userID := session.Get("user_id")
var user models.User
if err := db.First(&user, userID).Error; err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "User not found"})
return
}
file, header, err := c.Request.FormFile("avatar")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "No file provided"})
return
}
defer file.Close()
// 依据平台上传策略校验(总开关 + 类型 + 大小)。
check := ValidateUpload(header)
if !check.OK {
if !models.GetUploadConfig().Enabled {
c.JSON(http.StatusBadRequest, gin.H{"error": "uploads are disabled"})
return
}
if check.Type != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("file too large; limit is %s", formatSize(check.MaxSize))})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// SECURITY (#21):头像必须是白名单中的图片类型——
// 仅靠扩展名白名单(管理员可配置)可能让活动内容进入
// /uploads/avatars/。
if check.Type.Category != models.CategoryImage {
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
return
}
// 确定文件扩展名(已校验在白名单内)。
ext := strings.ToLower(filepath.Ext(header.Filename))
// 读取文件字节以进行图像处理。
imgBytes, err := io.ReadAll(file)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
return
}
// SECURITY (#14):解码前进行魔数字节一致性校验。
if !contentMatchesType(check.Type, imgBytes) {
c.JSON(http.StatusBadRequest, gin.H{"error": "file content does not match its declared type"})
return
}
// 解码、缩放并重新编码图像。
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(processedBytes)
storedName := hex.EncodeToString(sum[:])
oldAvatar := user.Avatar // 替换前的旧头像(旧命名 <uid>.jpg 或哈希)
f := models.File{
Type: models.FileTypeAvatar,
UploaderID: user.ID,
Filename: header.Filename,
StoredName: storedName,
Ext: finalExt,
MIME: "image/jpeg",
Size: int64(len(processedBytes)),
Category: models.CategoryImage,
}
if _, err := saveUploadedFile(db, f, filepath.Join(storagePath, "avatars"), processedBytes); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
return
}
// 更新用户记录与会话(Avatar 存哈希,公开 URL /uploads/avatars/<哈希>)。
user.Avatar = storedName
db.Save(&user)
session.Set("avatar", storedName)
session.Save()
// 清理旧的头像记录与文件:
// - 软删除旧的 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})
}
}
// processAvatar 解码头像图像,缩放到 256x256,并重新编码为 JPEG。
func processAvatar(imgBytes []byte, ext string) ([]byte, string, error) {
src, _, err := image.Decode(bytes.NewReader(imgBytes))
if err != nil {
return nil, "", err
}
const targetSize = 256
dst := image.NewRGBA(image.Rect(0, 0, targetSize, targetSize))
xdraw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), xdraw.Src, nil)
var buf bytes.Buffer
if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: 85}); err != nil {
return nil, "", err
}
return buf.Bytes(), ".jpg", nil
}