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:
6 files changed
+81
-146
No files matched your search
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
// 受保护的用户文章管理路由(面向非管理员用户)。
|
||||
|
||||
@@ -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}}
|
||||
Reference in New Issue
Block a user