package services import ( "bytes" "fmt" "image" "image/jpeg" "mime/multipart" "os" "path/filepath" "github.com/google/uuid" "github.com/disintegration/imaging" ) const ( MaxAvatarSize = 5 * 1024 * 1024 // 5MB AvatarWidth = 200 AvatarHeight = 200 JPEGQuality = 90 ) // ImageService handles image processing operations type ImageService struct { uploadDir string } // NewImageService creates a new ImageService instance func NewImageService(uploadDir string) *ImageService { if uploadDir == "" { uploadDir = "uploads/avatars" } return &ImageService{ uploadDir: uploadDir, } } // ValidateImage validates the image file format and size // T0223: Validates format (JPEG, PNG, WebP) and size (max 5MB) func (s *ImageService) ValidateImage(fileHeader *multipart.FileHeader) error { // Validate file size if fileHeader.Size > MaxAvatarSize { return fmt.Errorf("file size exceeds 5MB limit") } // Validate MIME type contentType := fileHeader.Header.Get("Content-Type") allowedTypes := []string{"image/jpeg", "image/png", "image/webp"} valid := false for _, allowedType := range allowedTypes { if contentType == allowedType { valid = true break } } if !valid { return fmt.Errorf("unsupported image format. Allowed: JPEG, PNG, WebP") } return nil } // ResizeImage resizes an image to the specified dimensions with crop center // T0223: Maintains aspect ratio and crops center to fit target dimensions func (s *ImageService) ResizeImage(img image.Image, width, height int) image.Image { // Calculate dimensions for crop center bounds := img.Bounds() imgWidth := bounds.Dx() imgHeight := bounds.Dy() // Calculate ratio to maintain aspect ratio ratio := float64(imgWidth) / float64(imgHeight) targetRatio := float64(width) / float64(height) var cropWidth, cropHeight int if ratio > targetRatio { // Image is wider, crop width cropHeight = imgHeight cropWidth = int(float64(cropHeight) * targetRatio) } else { // Image is taller, crop height cropWidth = imgWidth cropHeight = int(float64(cropWidth) / targetRatio) } // Crop center cropX := (imgWidth - cropWidth) / 2 cropY := (imgHeight - cropHeight) / 2 cropped := imaging.Crop(img, image.Rect(cropX, cropY, cropX+cropWidth, cropY+cropHeight)) // Final resize return imaging.Resize(cropped, width, height, imaging.Lanczos) } // EncodeJPEG encodes an image as JPEG with the specified quality // T0223: Encodes image as JPEG with quality 90 func (s *ImageService) EncodeJPEG(img image.Image) ([]byte, error) { var buf bytes.Buffer if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: JPEGQuality}); err != nil { return nil, fmt.Errorf("failed to encode image: %w", err) } return buf.Bytes(), nil } // ProcessAvatar validates and processes an avatar image // T0221: Validates format (JPEG, PNG, WebP), size (max 5MB), and resizes to 200x200px // T0223: Refactored to use ValidateImage, ResizeImage, and EncodeJPEG methods func (s *ImageService) ProcessAvatar(fileHeader *multipart.FileHeader) ([]byte, error) { // Validate file if err := s.ValidateImage(fileHeader); err != nil { return nil, err } // Open file file, err := fileHeader.Open() if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } defer file.Close() // Decode image img, format, err := image.Decode(file) if err != nil { return nil, fmt.Errorf("invalid image format: %w", err) } // Validate decoded format if format != "jpeg" && format != "png" && format != "webp" { return nil, fmt.Errorf("unsupported image format: %s", format) } // Resize with crop center resized := s.ResizeImage(img, AvatarWidth, AvatarHeight) // Encode as JPEG return s.EncodeJPEG(resized) } // UploadToS3 uploads image data to S3 (or local storage for now) // T0221: For now, stores locally. S3 implementation will be added in T0224 func (s *ImageService) UploadToS3(data []byte, key string) (string, error) { // Create upload directory if it doesn't exist if err := os.MkdirAll(s.uploadDir, 0755); err != nil { return "", fmt.Errorf("failed to create upload directory: %w", err) } // Save file locally (S3 will be implemented in T0224) filePath := filepath.Join(s.uploadDir, filepath.Base(key)) if err := os.WriteFile(filePath, data, 0644); err != nil { return "", fmt.Errorf("failed to save file: %w", err) } // Return local URL (will be S3 URL in T0224) avatarURL := fmt.Sprintf("/uploads/avatars/%s", filepath.Base(key)) return avatarURL, nil } // DeleteFromS3 deletes an image from S3 (or local storage for now) func (s *ImageService) DeleteFromS3(avatarURL string) error { // Extract filename from URL filename := filepath.Base(avatarURL) filePath := filepath.Join(s.uploadDir, filename) // Delete file (S3 implementation will be added in T0224) if err := os.Remove(filePath); err != nil { if !os.IsNotExist(err) { return fmt.Errorf("failed to delete file: %w", err) } } return nil } // GenerateS3Key generates an S3 key for avatar storage func (s *ImageService) GenerateS3Key(userID uuid.UUID) string { timestamp := uuid.New() return fmt.Sprintf("avatars/%s/%s.jpg", userID.String(), timestamp.String()) }