- docs: SCOPE_CONTROL, CONTRIBUTING, README, .github templates - frontend: DeveloperDashboardView, Player components, MSW handlers, auth, reactQuerySync - backend: playback_analytics, playlist_service, testutils, integration README Excluded (artifacts): .auth, playwright-report, test-results, storybook_audit_detailed.json
178 lines
5.1 KiB
Go
178 lines
5.1 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"image"
|
|
"image/jpeg"
|
|
"mime/multipart"
|
|
"os"
|
|
"path/filepath"
|
|
|
|
"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())
|
|
}
|