Files
go_blog/handlers/profile.go
T
kevin f307781f58 docs: 全部 Go 代码注释汉化
- 48 个 Go 文件所有注释(行注释/块注释/行尾注释,含 _test.go)翻译为中文
- 保留技术标识符:SECURITY_TODO(n)、unsafe-inline、sqlite/mysql、路由参数等
- 代码、字符串字面量、日志消息保持英文原文,零逻辑改动
- go build/vet 通过,go test -count=1 ./... 全绿
2026-08-27 19:03:03 +08:00

336 lines
9.4 KiB
Go

package handlers
import (
"bytes"
"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)
}
}
// UpdateProfile 处理个人资料编辑表单(multipart)。
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 {
c.Redirect(http.StatusFound, "/profile")
return
}
// --- 文本字段 ---
// 允许 display_name 为空(用户可清空以回退到用户名)
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
if v := c.PostForm("gender"); v != "" {
user.Gender = v
}
// SECURITY (#24):持久化前校验邮箱格式
//(脏值会污染 Gravatar 查询)。允许为空。
if v := c.PostForm("email"); v != "" {
if !validateEmail(v) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=email")
return
}
user.Email = v
}
if v := c.PostForm("birthday"); v != "" {
if t, err := time.Parse("2006-01-02", v); err == nil {
user.Birthday = &t
}
}
// --- 头像上传 ---
file, header, err := c.Request.FormFile("avatar")
if err == nil {
defer file.Close()
// 依据平台上传策略校验(总开关 + 类型 + 大小)。
check := ValidateUpload(header)
if !check.OK || check.Type.Category != models.CategoryImage {
session.Save()
reason := "?error=upload"
if !models.GetUploadConfig().Enabled {
reason = "?error=upload_disabled"
} else if check.Type != nil {
reason = fmt.Sprintf("?error=size&max=%s", formatSize(check.MaxSize))
}
c.Redirect(http.StatusFound, "/profile"+reason)
return
}
// 确定文件扩展名(已校验在白名单内)。
ext := strings.ToLower(filepath.Ext(header.Filename))
// SECURITY (#21):解码并将头像重新编码为规范化 JPEG,
// 而不是存储原始字节——无法解码的载荷(如伪装在图片扩展名下的
// HTML)会被拒绝。
imgBytes, err := io.ReadAll(file)
if err != nil {
c.Redirect(http.StatusFound, "/profile?error=upload")
return
}
// SECURITY (#14):解码前进行魔数字节一致性校验——
// 扩展名策略仅是头部级别的。
if !contentMatchesType(check.Type, imgBytes) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=upload")
return
}
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
if err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=upload")
return
}
// 保存到 storagePath/avatars/ 下。
avatarDir := filepath.Join(storagePath, "avatars")
if err := os.MkdirAll(avatarDir, 0755); err != nil {
c.Redirect(http.StatusFound, "/profile")
return
}
// 若存在旧头像文件则删除(无论扩展名是否相同)。
if user.Avatar != "" {
oldPath := filepath.Join(avatarDir, user.Avatar)
os.Remove(oldPath) // 忽略错误——文件可能不存在
}
// 使用用户 ID 作为文件名基础。
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
savedPath := filepath.Join(avatarDir, savedName)
dst, err := os.Create(savedPath)
if err != nil {
c.Redirect(http.StatusFound, "/profile")
return
}
defer dst.Close()
if _, err := dst.Write(processedBytes); err != nil {
c.Redirect(http.StatusFound, "/profile")
return
}
user.Avatar = savedName
session.Set("avatar", savedName)
}
// --- 密码修改 ---
currentPass := c.PostForm("current_password")
newPass := c.PostForm("new_password")
if currentPass != "" && newPass != "" {
if !user.CheckPassword(currentPass) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=pw")
return
}
// SECURITY (#23):执行与注册相同的最小长度;
// 重置为 1 个字符的密码将极易被猜出。
if !validatePassword(newPass) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=pw_short")
return
}
if err := user.SetPassword(newPass); err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile")
return
}
}
// 保存用户记录。
if err := db.Save(&user).Error; err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile")
return
}
// 更新会话中的显示名。
session.Set("display_name", user.DisplayName)
session.Save()
c.Redirect(http.StatusFound, "/profile?saved=1")
}
}
// 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
}
// 确保头像目录存在。
avatarDir := filepath.Join(storagePath, "avatars")
os.MkdirAll(avatarDir, 0755)
// 删除旧头像文件。
if user.Avatar != "" {
oldPath := filepath.Join(avatarDir, user.Avatar)
os.Remove(oldPath)
}
// 保存处理后的头像。
savedName := fmt.Sprintf("%d%s", user.ID, finalExt)
savedPath := filepath.Join(avatarDir, savedName)
if err := os.WriteFile(savedPath, processedBytes, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to save avatar"})
return
}
// 更新用户记录。
user.Avatar = savedName
db.Save(&user)
// 更新会话。
session.Set("avatar", savedName)
session.Save()
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
}
}
// 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
}