package handler import ( "context" "crypto/rand" "encoding/hex" "fmt" "io" "net/http" "os" "path/filepath" "strconv" "github.com/gin-gonic/gin" "github.com/user-management-system/internal/domain" ) // avatarUserRepository interface for dependency inversion (DIP) type avatarUserRepository interface { GetByID(ctx context.Context, id int64) (*domain.User, error) Update(ctx context.Context, user *domain.User) error } // AvatarHandler handles avatar upload requests type AvatarHandler struct { userRepo avatarUserRepository } // NewAvatarHandler creates a new AvatarHandler func NewAvatarHandler(userRepo avatarUserRepository) *AvatarHandler { return &AvatarHandler{userRepo: userRepo} } const ( maxAvatarSize = 5 * 1024 * 1024 // 5MB magicBytesBufSize = 512 avatarTokenLen = 8 dirPerm = 0o755 filePerm = 0o644 ) // generateSecureToken generates a secure random token func generateSecureToken(length int) string { bytes := make([]byte, length) rand.Read(bytes) return hex.EncodeToString(bytes)[:length] } // UploadAvatar 上传用户头像 // @Summary 上传用户头像 // @Description 上传并更新用户头像(仅本人或管理员) // @Tags 用户头像 // @Accept multipart/form-data // @Produce json // @Security BearerAuth // @Param id path int true "用户ID" // @Param avatar formData file true "头像文件(最大5MB,支持jpg/jpeg/png/gif/webp)" // @Success 200 {object} Response{data=AvatarResponse} "上传成功" // @Failure 400 {object} Response "文件无效或大小超限" // @Failure 401 {object} Response "未认证" // @Failure 403 {object} Response "无权限" // @Failure 404 {object} Response "用户不存在" // @Router /api/v1/users/{id}/avatar [post] func (h *AvatarHandler) UploadAvatar(c *gin.Context) { userIDStr := c.Param("id") userID, err := strconv.ParseInt(userIDStr, 10, 64) if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid user id"}) return } // Get current user from context (set by auth middleware) currentUserID := c.GetInt64("user_id") if currentUserID == 0 { c.JSON(http.StatusUnauthorized, gin.H{"code": 401, "message": "unauthorized"}) return } // Check permission: user can only update their own avatar, or admin can update any isAdmin := false if roles, ok := c.Get("user_roles"); ok { for _, role := range roles.([]*domain.Role) { if role.Code == "admin" { isAdmin = true break } } } if currentUserID != userID && !isAdmin { c.JSON(http.StatusForbidden, gin.H{"code": 403, "message": "permission denied"}) return } // Get file from form file, err := c.FormFile("avatar") if err != nil { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "no avatar file provided"}) return } // Validate file size (max 5MB) if file.Size > maxAvatarSize { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "file size exceeds 5MB limit"}) return } // Validate file type ext := filepath.Ext(file.Filename) allowedExts := map[string]bool{".jpg": true, ".jpeg": true, ".png": true, ".gif": true, ".webp": true} if !allowedExts[ext] { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid file type, allowed: jpg, jpeg, png, gif, webp"}) return } // Open the uploaded file src, err := file.Open() if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to open uploaded file"}) return } defer src.Close() // Validate Magic Bytes to detect actual file type (prevents file extension spoofing) buf := make([]byte, magicBytesBufSize) n, err := src.Read(buf) if err != nil && err != io.EOF { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "failed to read file"}) return } contentType := http.DetectContentType(buf[:n]) allowedMIME := map[string]bool{ "image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true, } if !allowedMIME[contentType] { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "invalid file content, allowed: jpeg, png, gif, webp"}) return } // Seek back to beginning for full file read if _, err := src.Seek(0, io.SeekStart); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read file"}) return } // Generate unique filename avatarFilename := fmt.Sprintf("avatar_%d_%s%s", userID, generateSecureToken(avatarTokenLen), ext) uploadDir := "./uploads/avatars" // Create upload directory if not exists if err := os.MkdirAll(uploadDir, dirPerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to create upload directory"}) return } // Save file to disk dstPath := filepath.Join(uploadDir, avatarFilename) data := make([]byte, file.Size) if _, err := src.Read(data); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to read uploaded file"}) return } if err := os.WriteFile(dstPath, data, filePerm); err != nil { c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to save avatar file"}) return } // Generate avatar URL (in production, this would be a CDN URL) avatarURL := fmt.Sprintf("/uploads/avatars/%s", avatarFilename) // Update user's avatar in database user, err := h.userRepo.GetByID(c.Request.Context(), userID) if err != nil { // Clean up the uploaded file os.Remove(dstPath) c.JSON(http.StatusNotFound, gin.H{"code": 404, "message": "user not found"}) return } user.Avatar = avatarURL if err := h.userRepo.Update(c.Request.Context(), user); err != nil { // Clean up the uploaded file os.Remove(dstPath) c.JSON(http.StatusInternalServerError, gin.H{"code": 500, "message": "failed to update user avatar"}) return } c.JSON(http.StatusOK, gin.H{ "code": 0, "message": "avatar uploaded successfully", "data": gin.H{ "avatar_url": avatarURL, "thumbnail": avatarURL, }, }) }