Files
go_blog/handlers/profile.go
T
kevin 76b744d5b5 feat: 个人资料接口 JSON 化——POST /api/profile
- profile.go:UpdateProfile 改 JSON 绑定(profileRequest),移除内嵌
  头像文件分支(头像统一走 /api/profile/avatar,XSS 链校验保留在
  UploadAvatar);错误改 APIError(400 profile_wrong_password/
  profile_password_short/profile_email_invalid、404 user_not_found、
  500),成功 {ok,redirect:/profile?saved=1}
- main.go:/profile 组旧 POST 移除,/api/profile[/avatar] 分组收敛
- profile.html:主表单改 blogAPI(头像 cropper 流程独立不变),
  新增 profileError 错误区
- 测试:TestProfilePasswordMinLength/TestProfileEmailValidation 改
  JSON(400+code);TestUpdateProfileAvatarRejectsNonImage 改打
  /api/profile/avatar;env 路由同步 /api/profile
- go build/vet/test 全绿
2026-08-27 19:56:03 +08:00

270 lines
7.5 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"
"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
}
// 确保头像目录存在。
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
}