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 全绿
This commit is contained in:
2026-08-27 19:56:03 +08:00
parent efc30156b9
commit 76b744d5b5
6 files changed
+81 -146

No files matched your search

+17 -28
View File
@@ -79,15 +79,11 @@ func TestProfilePasswordMinLength(t *testing.T) {
token := e.csrfTokenFor(t, alice)
// 1 个字符的密码必须被拒绝,旧哈希保持不变。
fields := url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "a")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("short password: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=pw_short" {
t.Fatalf("short password: location = %q, want /profile?error=pw_short", loc)
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"current_password": "pw-alice", "new_password": "a"})
if w.Code != http.StatusBadRequest || respCode(w) != "profile_password_short" {
t.Fatalf("short password: status = %d, code = %q, want 400/profile_password_short",
w.Code, respCode(w))
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
@@ -98,12 +94,10 @@ func TestProfilePasswordMinLength(t *testing.T) {
}
// 6 个字符的密码可被接受。
fields = url.Values{}
fields.Set("current_password", "pw-alice")
fields.Set("new_password", "newpass6")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid password change: status = %d", w.Code)
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"current_password": "pw-alice", "new_password": "newpass6"})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("valid password change: status = %d, body %s", w.Code, w.Body.String())
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
@@ -119,14 +113,10 @@ func TestProfileEmailValidation(t *testing.T) {
alice := e.login(t, "alice")
token := e.csrfTokenFor(t, alice)
fields := url.Values{}
fields.Set("email", "not-an-email")
w := postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("invalid email: status = %d, want 302", w.Code)
}
if loc := w.Header().Get("Location"); loc != "/profile?error=email" {
t.Fatalf("invalid email: location = %q, want /profile?error=email", loc)
w := postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"email": "not-an-email"})
if w.Code != http.StatusBadRequest || respCode(w) != "profile_email_invalid" {
t.Fatalf("invalid email: status = %d, code = %q", w.Code, respCode(w))
}
var u models.User
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
@@ -136,11 +126,10 @@ func TestProfileEmailValidation(t *testing.T) {
t.Fatalf("invalid email was persisted: %q", u.Email)
}
fields = url.Values{}
fields.Set("email", "alice@example.com")
w = postForm(e, http.MethodPost, "/profile", alice, token, fields)
if w.Code != http.StatusFound {
t.Fatalf("valid email: status = %d, want 302", w.Code)
w = postJSON(e, http.MethodPost, "/api/profile", alice, token,
gin.H{"email": "alice@example.com"})
if w.Code != http.StatusOK || !respOK(w) {
t.Fatalf("valid email: status = %d, body %s", w.Code, w.Body.String())
}
if err := e.db.Where("username = ?", "alice").First(&u).Error; err != nil {
t.Fatalf("reload alice: %v", err)
+30 -96
View File
@@ -68,7 +68,18 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc {
}
}
// UpdateProfile 处理个人资料编辑表单(multipart)
// 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)
@@ -76,137 +87,60 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
var user models.User
if err := db.First(&user, userID).Error; err != nil {
c.Redirect(http.StatusFound, "/profile")
APIError(c, http.StatusNotFound, "user_not_found")
return
}
var req profileRequest
if !bindJSON(c, &req) {
return
}
// --- 文本字段 ---
// 允许 display_name 为空(用户可清空以回退到用户名)
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
user.DisplayName = strings.TrimSpace(req.DisplayName)
if v := c.PostForm("gender"); v != "" {
if v := strings.TrimSpace(req.Gender); v != "" {
user.Gender = v
}
// SECURITY (#24):持久化前校验邮箱格式
//(脏值会污染 Gravatar 查询)。允许为空。
if v := c.PostForm("email"); v != "" {
if v := strings.TrimSpace(req.Email); v != "" {
if !validateEmail(v) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=email")
APIError(c, http.StatusBadRequest, "profile_email_invalid")
return
}
user.Email = v
}
if v := c.PostForm("birthday"); v != "" {
if v := strings.TrimSpace(req.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")
currentPass := req.CurrentPassword
newPass := req.NewPassword
if currentPass != "" && newPass != "" {
if !user.CheckPassword(currentPass) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=pw")
APIError(c, http.StatusBadRequest, "profile_wrong_password")
return
}
// SECURITY (#23):执行与注册相同的最小长度;
// 重置为 1 个字符的密码将极易被猜出。
if !validatePassword(newPass) {
session.Save()
c.Redirect(http.StatusFound, "/profile?error=pw_short")
APIError(c, http.StatusBadRequest, "profile_password_short")
return
}
if err := user.SetPassword(newPass); err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile")
APIError(c, http.StatusInternalServerError, "api_error")
return
}
}
// 保存用户记录。
if err := db.Save(&user).Error; err != nil {
session.Save()
c.Redirect(http.StatusFound, "/profile")
APIError(c, http.StatusInternalServerError, "api_error")
return
}
@@ -214,7 +148,7 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
session.Set("display_name", user.DisplayName)
session.Save()
c.Redirect(http.StatusFound, "/profile?saved=1")
APIOK(c, "/profile?saved=1", nil)
}
}
+4 -4
View File
@@ -104,11 +104,11 @@ func newSecurityTestEnv(t *testing.T) *securityTestEnv {
})
}
// 个人资料路由(头像上传 XSS 链回归覆盖,#21)。
profile := r.Group("/profile", middleware.AuthRequired(db))
// 个人资料 API(头像上传 XSS 链回归覆盖,#21)。
profileAPI := r.Group("/api/profile", middleware.AuthRequired(db))
{
profile.POST("", UpdateProfile(db, storageDir))
profile.POST("/avatar", UploadAvatar(db, storageDir))
profileAPI.POST("", UpdateProfile(db, storageDir))
profileAPI.POST("/avatar", UploadAvatar(db, storageDir))
}
// 上传设置 API(危险扩展名黑名单覆盖,#21)。
+11 -14
View File
@@ -199,7 +199,7 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) {
// 模拟黑名单之前已配置的危险类型(纵深防御):类别检查必须拒绝它。
seedUploadType(t, e, ".html", models.CategoryOther)
w := e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil)
w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("upload .html (other category): status=%d body=%s, want 400", w.Code, w.Body.String())
}
@@ -207,14 +207,14 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) {
// 即使是误分类为 "image" 的旧版 .html 行,也会被解码步骤拦下——
// 不会再存储原始字节。
seedUploadType(t, e, ".htm", models.CategoryImage)
w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "evil.htm", htmlPayload, nil)
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.htm", htmlPayload, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("upload .htm (image category): status=%d body=%s, want 400", w.Code, w.Body.String())
}
// 伪装在白名单图片扩展名之后的 HTML 同样被拒绝。
seedUploadType(t, e, ".jpg", models.CategoryImage)
w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "x.jpg", htmlPayload, nil)
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "x.jpg", htmlPayload, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("upload html as .jpg: status=%d body=%s, want 400", w.Code, w.Body.String())
}
@@ -233,7 +233,7 @@ func TestUploadAvatarRejectsNonImage(t *testing.T) {
// 真实图片被接受,并处理为规范化 JPEG。
seedUploadType(t, e, ".png", models.CategoryImage)
w = e.multipartUpload(t, "/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil)
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil)
if w.Code != http.StatusOK {
t.Fatalf("upload valid png: status=%d body=%s", w.Code, w.Body.String())
}
@@ -254,24 +254,21 @@ func TestUpdateProfileAvatarRejectsNonImage(t *testing.T) {
seedUploadType(t, e, ".png", models.CategoryImage)
seedUploadType(t, e, ".html", models.CategoryImage) // 旧版误分类的行
// 白名单扩展名背后的 HTML 必须以上传错误重定向被拒绝,
// 白名单扩展名背后的 HTML 必须被 JSON API 拒绝,
// 且不得向 avatars/ 写入任何内容。
htmlPayload := []byte("<html><script>alert(1)</script></html>")
w := e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "evil.html", htmlPayload,
map[string]string{"display_name": "alice"})
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "error=upload") {
t.Fatalf("update profile with html avatar: status=%d location=%q, want 302 error=upload",
w.Code, w.Header().Get("Location"))
w := e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "evil.html", htmlPayload, nil)
if w.Code != http.StatusBadRequest {
t.Fatalf("upload html avatar: status=%d body=%s, want 400", w.Code, w.Body.String())
}
if alice := reloadAlice(t, e); alice.Avatar != "" {
t.Fatalf("avatar unexpectedly set to %q", alice.Avatar)
}
// 真实图片经过处理并以 JPEG 保存。
w = e.multipartUpload(t, "/profile", aliceCookie, token, "avatar", "me.png", pngBytes(t),
map[string]string{"display_name": "alice"})
if w.Code != http.StatusFound || !strings.Contains(w.Header().Get("Location"), "saved=1") {
t.Fatalf("update profile with valid avatar: status=%d location=%q", w.Code, w.Header().Get("Location"))
w = e.multipartUpload(t, "/api/profile/avatar", aliceCookie, token, "avatar", "me.png", pngBytes(t), nil)
if w.Code != http.StatusOK {
t.Fatalf("upload valid avatar: status=%d body=%s", w.Code, w.Body.String())
}
alice := reloadAlice(t, e)
if want := fmt.Sprintf("%d.jpg", alice.ID); alice.Avatar != want {
+1 -2
View File
@@ -271,14 +271,13 @@ func registerRoutes(router *gin.Engine, cfg *config.Config, db *gorm.DB, loginLi
profile.Use(middleware.AuthRequired(db))
{
profile.GET("", handlers.ProfilePage(db))
profile.POST("", handlers.UpdateProfile(db, cfg.Path))
}
profileAPI := router.Group("/api/profile")
profileAPI.Use(middleware.AuthRequired(db))
{
profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
profileAPI.POST("", handlers.UpdateProfile(db, cfg.Path))
profileAPI.POST("/avatar", handlers.UploadAvatar(db, cfg.Path))
}
// 受保护的用户文章管理路由(面向非管理员用户)。
+18 -2
View File
@@ -35,8 +35,9 @@
</div>
</div>
<form action="/profile" method="post" enctype="multipart/form-data" class="space-y-8">
<input type="hidden" name="_csrf" value="{{.CSRFToken}}">
<div id="profileError" class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6 hidden"></div>
<form id="profileForm" action="/api/profile" method="post" class="space-y-8">
<!-- Avatar Section -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h3 class="text-lg font-semibold text-gray-800 mb-4">{{index .Tr "profile_avatar"}}</h3>
@@ -245,6 +246,21 @@
else statusDiv.classList.add('text-gray-500');
}
})();
// 个人资料主表单:文本字段 + 密码走 JSON API;
// 头像文件由 cropper 流程独立上传(/api/profile/avatar)。
(function () {
var form = document.getElementById('profileForm');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var btn = e.submitter || null;
blogAPI('POST', '/api/profile', blogForm(form, btn)).then(function (r) {
if (r.ok) { window.location.href = r.redirect || '/profile'; }
else { blogShowError('profileError', r.error || 'Failed to save profile.'); }
});
});
})();
</script>
{{end}}