- #20 AuthRequired 改为 AuthRequired(db):受保护路由每次回库校验 Status == StatusNormal 且未软删,失败清 session(保留 lang/csrf_token, 与登录轮换口径一致)并 302 /login;session user_id 先断言为数值再入 GORM(呼应 #19),AdminRequired 同步加固;SetUserContext 仅在用户 存在且状态正常时置 is_logged_in——禁用用户发评论不再自动通过, 回落游客审核策略 - #21 头像两个分支(UploadAvatar / UpdateProfile)强制 Category == image,解码失败直接拒绝、删除"回退存原始字节"路径, 统一经 processAvatar 解码→256px 缩放→JPEG 重编码;addUploadFileType 增加危险扩展黑名单(.html/.htm/.xhtml/.xht/.svg/.xml/.js/.mjs), 拒绝添加并在上传设置页提示(模板 + 中英 i18n) - 附带修复:processAvatar 依赖的 png/gif 解码器此前未注册(旧代码靠 回退存原始字节掩盖,PNG 头像从未真正处理过),补 blank import - 新增回归测试 session_upload_security_test.go(6 用例:禁用/锁定/ 软删旧 cookie 302、锁定用户评论转 pending、6 组危险扩展拒绝、 伪装扩展名头像拒绝且磁盘零写入、正常图片转存 .jpg;已变异验证: 去掉任一修复对应测试即失败) - SECURITY_TODO.md 勾选 #20/#21 并更新执行顺序
306 lines
8.4 KiB
Go
306 lines
8.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"
|
|
|
|
// Register the decoders processAvatar relies on. JPEG is registered by
|
|
// the image/jpeg import above; png/gif must be blank-imported or
|
|
// image.Decode would reject them.
|
|
_ "image/gif"
|
|
_ "image/png"
|
|
|
|
"go_blog/models"
|
|
)
|
|
|
|
// ProfilePage renders the profile edit page.
|
|
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 messages (success / error).
|
|
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"))
|
|
}
|
|
|
|
c.HTML(http.StatusOK, "profile", data)
|
|
}
|
|
}
|
|
|
|
// UpdateProfile processes the profile edit form (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
|
|
}
|
|
|
|
// --- Text fields ---
|
|
// Allow empty display_name (user can clear it to fall back to username)
|
|
user.DisplayName = strings.TrimSpace(c.PostForm("display_name"))
|
|
|
|
if v := c.PostForm("gender"); v != "" {
|
|
user.Gender = v
|
|
}
|
|
if v := c.PostForm("email"); v != "" {
|
|
user.Email = v
|
|
}
|
|
if v := c.PostForm("birthday"); v != "" {
|
|
if t, err := time.Parse("2006-01-02", v); err == nil {
|
|
user.Birthday = &t
|
|
}
|
|
}
|
|
|
|
// --- Avatar upload ---
|
|
file, header, err := c.Request.FormFile("avatar")
|
|
if err == nil {
|
|
defer file.Close()
|
|
|
|
// Validate against the platform upload policy (switch + type + size).
|
|
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
|
|
}
|
|
|
|
// Determine file extension (validated to be in the whitelist).
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
|
|
// SECURITY (#21): decode and re-encode the avatar as a normalized
|
|
// JPEG instead of storing the original bytes — undecodable payloads
|
|
// (e.g. HTML disguised behind an image extension) are rejected.
|
|
imgBytes, err := io.ReadAll(file)
|
|
if err != nil {
|
|
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
|
|
}
|
|
|
|
// Save under storagePath/avatars/.
|
|
avatarDir := filepath.Join(storagePath, "avatars")
|
|
if err := os.MkdirAll(avatarDir, 0755); err != nil {
|
|
c.Redirect(http.StatusFound, "/profile")
|
|
return
|
|
}
|
|
|
|
// Remove old avatar file if it exists (different extension or same).
|
|
if user.Avatar != "" {
|
|
oldPath := filepath.Join(avatarDir, user.Avatar)
|
|
os.Remove(oldPath) // ignore error — file may not exist
|
|
}
|
|
|
|
// Use user ID as filename base.
|
|
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)
|
|
}
|
|
|
|
// --- Password change ---
|
|
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
|
|
}
|
|
if err := user.SetPassword(newPass); err != nil {
|
|
session.Save()
|
|
c.Redirect(http.StatusFound, "/profile")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Save user record.
|
|
if err := db.Save(&user).Error; err != nil {
|
|
session.Save()
|
|
c.Redirect(http.StatusFound, "/profile")
|
|
return
|
|
}
|
|
|
|
// Update display name in session.
|
|
session.Set("display_name", user.DisplayName)
|
|
session.Save()
|
|
|
|
c.Redirect(http.StatusFound, "/profile?saved=1")
|
|
}
|
|
}
|
|
|
|
// UploadAvatar handles AJAX avatar upload with cropping.
|
|
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()
|
|
|
|
// Validate against the platform upload policy (switch + type + size).
|
|
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): avatars must be an image-category type from the
|
|
// whitelist — the extension whitelist alone is admin-configurable and
|
|
// could otherwise admit active content into /uploads/avatars/.
|
|
if check.Type.Category != models.CategoryImage {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file type not allowed"})
|
|
return
|
|
}
|
|
|
|
// Determine file extension (validated to be in the whitelist).
|
|
ext := strings.ToLower(filepath.Ext(header.Filename))
|
|
|
|
// Read file bytes for image processing.
|
|
imgBytes, err := io.ReadAll(file)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to read file"})
|
|
return
|
|
}
|
|
|
|
// Decode, resize, and re-encode the image.
|
|
processedBytes, finalExt, err := processAvatar(imgBytes, ext)
|
|
if err != nil {
|
|
// SECURITY (#21): reject undecodable payloads outright — storing
|
|
// the raw bytes would let non-image content land in avatars/.
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid image file"})
|
|
return
|
|
}
|
|
|
|
// Ensure avatar directory exists.
|
|
avatarDir := filepath.Join(storagePath, "avatars")
|
|
os.MkdirAll(avatarDir, 0755)
|
|
|
|
// Remove old avatar file.
|
|
if user.Avatar != "" {
|
|
oldPath := filepath.Join(avatarDir, user.Avatar)
|
|
os.Remove(oldPath)
|
|
}
|
|
|
|
// Save processed avatar.
|
|
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
|
|
}
|
|
|
|
// Update user record.
|
|
user.Avatar = savedName
|
|
db.Save(&user)
|
|
|
|
// Update session.
|
|
session.Set("avatar", savedName)
|
|
session.Save()
|
|
|
|
c.JSON(http.StatusOK, gin.H{"avatar": savedName})
|
|
}
|
|
}
|
|
|
|
// processAvatar decodes, resizes to 256x256, and re-encodes an avatar image as 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
|
|
}
|