veza/veza-backend-api/internal/services/image_service.go
senke 286be8ba1d
Some checks failed
Backend API CI / test-unit (push) Failing after 0s
Backend API CI / test-integration (push) Failing after 0s
Frontend CI / test (push) Failing after 0s
Storybook Audit / Build & audit Storybook (push) Failing after 0s
chore(v0.102): consolidate remaining changes — docs, frontend, backend
- 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
2026-02-20 13:02:12 +01:00

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())
}