- 提取共享上传引擎 saveUploadedFile(handlers/attachment.go): 内容 SHA-256 寻址写入 + 磁盘去重 + files 表登记,供附件/头像共用 - UploadAvatar 改用该引擎:处理后 JPEG 以哈希命名存储,登记 models.FileTypeAvatar('avatars')记录;用户 Avatar 字段 = 哈希, 公开链接 /uploads/avatars/<哈希>(沿用原有 avatars/ 目录与路由) - 更换头像时:旧登记行软删除;仅当无其他 files 记录或用户引用时 删除旧磁盘文件(与附件一致的引用计数语义) - 既有头像安全测试断言从 <uid>.jpg 改为 64 位哈希; 新增 TestAvatarUploadRegistersFileRow 覆盖登记/磁盘/替换清理全流程
290 lines
8.7 KiB
Go
290 lines
8.7 KiB
Go
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/avatar(multipart)。
|
||
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
|
||
}
|