diff --git a/go.mod b/go.mod index 272ce9d..85f7103 100644 --- a/go.mod +++ b/go.mod @@ -48,6 +48,7 @@ require ( github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect golang.org/x/arch v0.22.0 // indirect + golang.org/x/image v0.43.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect diff --git a/go.sum b/go.sum index 589dd17..bf300c4 100644 --- a/go.sum +++ b/go.sum @@ -111,6 +111,8 @@ golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI= golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/handlers/profile.go b/handlers/profile.go index e314609..0d28654 100644 --- a/handlers/profile.go +++ b/handlers/profile.go @@ -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 +} diff --git a/i18n/i18n.go b/i18n/i18n.go index 9c1dd5b..6a82226 100644 --- a/i18n/i18n.go +++ b/i18n/i18n.go @@ -83,6 +83,14 @@ var translations = map[Lang]map[string]string{ "gender_male": "Male", "gender_female": "Female", "gender_other": "Other", + + // Avatar cropping + "crop_avatar_title": "Crop Avatar", + "crop_cancel": "Cancel", + "crop_confirm": "Confirm", + "crop_uploading": "Uploading...", + "crop_success": "Avatar updated.", + "crop_error": "Failed to upload avatar. Please try again.", }, ZH: { // 导航 @@ -153,6 +161,14 @@ var translations = map[Lang]map[string]string{ "gender_male": "男", "gender_female": "女", "gender_other": "其他", + + // 头像裁剪 + "crop_avatar_title": "裁剪头像", + "crop_cancel": "取消", + "crop_confirm": "确认", + "crop_uploading": "上传中...", + "crop_success": "头像已更新。", + "crop_error": "头像上传失败,请重试。", }, } diff --git a/main.go b/main.go index f2e8383..e7ae4c4 100644 --- a/main.go +++ b/main.go @@ -64,6 +64,7 @@ func main() { { profile.GET("", handlers.ProfilePage(db)) profile.POST("", handlers.UpdateProfile(db, cfg.Path)) + profile.POST("/avatar", handlers.UploadAvatar(db, cfg.Path)) } // 9. Start the server. diff --git a/templates/layouts/base.html b/templates/layouts/base.html index f66e715..f0dd9b7 100644 --- a/templates/layouts/base.html +++ b/templates/layouts/base.html @@ -6,6 +6,8 @@