package media import ( "context" "errors" "fmt" "io" "github.com/google/uuid" "backend/internal/modules/categories" "backend/internal/modules/products" ) var ( ErrFileTooLarge = errors.New("file exceeds the maximum allowed size") ErrUnsupportedType = errors.New("unsupported file type") ErrProductNotFound = errors.New("product not found") ) // ProductFinder is the minimal slice of products.Service this module needs, // defined on the consumer side so media never imports the products // module's handler/repository/service internals -- only used to validate // that a product_id passed on upload actually exists. type ProductFinder interface { Get(ctx context.Context, id uuid.UUID) (*products.Product, error) } // allowedTypes maps accepted MIME types to a safe file extension. Anything // not in this list is rejected -- uploads are never trusted to declare // their own extension. var allowedTypes = map[string]string{ "image/jpeg": ".jpg", "image/png": ".png", "image/webp": ".webp", "image/gif": ".gif", "video/mp4": ".mp4", "video/webm": ".webm", } type Service struct { repo Repository storage Storage maxSizeByte int64 products ProductFinder categoriesRepo categories.Repository } func NewService( repo Repository, storage Storage, products ProductFinder, categoriesRepo categories.Repository, maxSizeByte int64, ) *Service { return &Service{ repo: repo, storage: storage, products: products, categoriesRepo: categoriesRepo, maxSizeByte: maxSizeByte, } } func (s *Service) List(ctx context.Context, productID *uuid.UUID) ([]*Media, error) { return s.repo.List(ctx, productID) } func (s *Service) Get(ctx context.Context, id uuid.UUID) (*Media, error) { return s.repo.FindByID(ctx, id) } func (s *Service) Upload(ctx context.Context, filename string, reader io.Reader, size int64, contentType, altText string, productID *uuid.UUID) (*Media, error) { if size > s.maxSizeByte { return nil, ErrFileTooLarge } ext, ok := allowedTypes[contentType] if !ok { return nil, ErrUnsupportedType } if productID != nil { if _, err := s.products.Get(ctx, *productID); err != nil { if errors.Is(err, products.ErrNotFound) { return nil, ErrProductNotFound } return nil, fmt.Errorf("check product: %w", err) } } key := uuid.NewString() + ext url, err := s.storage.Save(ctx, key, reader, size, contentType) if err != nil { return nil, fmt.Errorf("save file: %w", err) } m := &Media{ ID: uuid.New(), Filename: filename, StorageKey: key, URL: url, MimeType: contentType, SizeBytes: size, AltText: altText, ProductID: productID, } if err := s.repo.Create(ctx, m); err != nil { _ = s.storage.Delete(ctx, key) return nil, fmt.Errorf("save metadata: %w", err) } return m, nil } func (s *Service) UpdateAltText(ctx context.Context, id uuid.UUID, altText string) (*Media, error) { m, err := s.repo.FindByID(ctx, id) if err != nil { return nil, err } m.AltText = altText if err := s.repo.Update(ctx, m); err != nil { return nil, err } return m, nil } func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { m, err := s.repo.FindByID(ctx, id) if err != nil { return err } if err := s.storage.Delete(ctx, m.StorageKey); err != nil { return fmt.Errorf("delete stored file: %w", err) } return s.repo.Delete(ctx, id) } func (s *Service) BelongsToProduct( ctx context.Context, mediaID uuid.UUID, productID uuid.UUID, ) (bool, error) { m, err := s.repo.FindByID(ctx, mediaID) if err != nil { if errors.Is(err, ErrNotFound) { return false, nil } return false, err } if m.ProductID == nil { return false, nil } return *m.ProductID == productID, nil }