diff --git a/handlers/p2_validation_test.go b/handlers/p2_validation_test.go index d1c9b81..5fa0ad1 100644 --- a/handlers/p2_validation_test.go +++ b/handlers/p2_validation_test.go @@ -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) diff --git a/handlers/profile.go b/handlers/profile.go index e24da58..716749d 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -68,7 +68,18 @@ func ProfilePage(db *gorm.DB) gin.HandlerFunc { } } -// UpdateProfile 处理个人资料编辑表单(multipart)。 +// 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) @@ -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) } } diff --git a/handlers/security_test.go b/handlers/security_test.go index 9964d31..13cbaae 100644 --- a/handlers/security_test.go +++ b/handlers/security_test.go @@ -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)。 diff --git a/handlers/session_upload_security_test.go b/handlers/session_upload_security_test.go index 0ae4df4..749ec49 100644 --- a/handlers/session_upload_security_test.go +++ b/handlers/session_upload_security_test.go @@ -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("") - 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 { diff --git a/main.go b/main.go index 5976286..02d8c04 100644 --- a/main.go +++ b/main.go @@ -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)) } // 受保护的用户文章管理路由(面向非管理员用户)。 diff --git a/templates/pages/profile.html b/templates/pages/profile.html index 228a019..661481f 100644 --- a/templates/pages/profile.html +++ b/templates/pages/profile.html @@ -35,8 +35,9 @@ -