feat: add avatar cropping with Cropper.js and server-side resize

This commit is contained in:
2026-06-21 02:30:35 +08:00
parent 610ea643ff
commit 67023378ce
7 changed files with 270 additions and 7 deletions
+95
View File
@@ -1,7 +1,10 @@
package handlers
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"io"
"net/http"
"os"
@@ -13,6 +16,9 @@ import (
"github.com/gin-gonic/gin"
"gorm.io/gorm"
xdraw "golang.org/x/image/draw"
_ "golang.org/x/image/webp"
"go_blog/models"
)
@@ -147,3 +153,92 @@ func UpdateProfile(db *gorm.DB, storagePath string) gin.HandlerFunc {
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 extension.
ext := strings.ToLower(filepath.Ext(header.Filename))
if ext == "" || (ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp") {
ext = ".jpg"
}
// 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 {
// Fall back to saving raw bytes if processing fails.
processedBytes = imgBytes
finalExt = ext
}
// 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
}