diff --git a/veza-backend-api/internal/services/s3_storage_service.go b/veza-backend-api/internal/services/s3_storage_service.go index 4176ebe61..89402ad35 100644 --- a/veza-backend-api/internal/services/s3_storage_service.go +++ b/veza-backend-api/internal/services/s3_storage_service.go @@ -163,6 +163,58 @@ func (s *S3StorageService) UploadFile(ctx context.Context, data []byte, key stri return key, nil } +// UploadStream upload un io.Reader vers S3 sans charger le contenu en mémoire. +// Préféré à UploadFile pour les gros objets (tracks audio jusqu'à 500MB) : le +// manager.Uploader (multipart, 10MB parts, 3 goroutines) streame en continu. +// +// `size` peut être -1 si la taille est inconnue d'avance — dans ce cas la SDK +// bufferise. Sinon, passe la taille exacte (p.ex. `fileHeader.Size`) pour que +// le Content-Length soit correct et que le client voie une barre de progrès. +// v1.0.8 Phase 1 — cf. /home/senke/.claude/plans/audit-fonctionnel-wild-hickey.md +func (s *S3StorageService) UploadStream(ctx context.Context, r io.Reader, key, contentType string, size int64) (string, error) { + if key == "" { + return "", fmt.Errorf("key cannot be empty") + } + if r == nil { + return "", fmt.Errorf("reader cannot be nil") + } + if contentType == "" { + contentType = "application/octet-stream" + } + + input := &s3.PutObjectInput{ + Bucket: aws.String(s.bucket), + Key: aws.String(key), + Body: r, + ContentType: aws.String(contentType), + Metadata: map[string]string{ + "uploaded-at": time.Now().UTC().Format(time.RFC3339), + }, + } + if size > 0 { + input.ContentLength = aws.Int64(size) + } + + _, err := s.uploader.Upload(ctx, input) + if err != nil { + s.logger.Error("Failed to stream-upload to S3", + zap.Error(err), + zap.String("key", key), + zap.String("bucket", s.bucket), + zap.Int64("size", size), + ) + return "", fmt.Errorf("failed to stream-upload to S3: %w", err) + } + + s.logger.Info("Stream uploaded successfully to S3", + zap.String("key", key), + zap.String("bucket", s.bucket), + zap.Int64("size", size), + ) + + return key, nil +} + // DeleteFile supprime un fichier de S3 func (s *S3StorageService) DeleteFile(ctx context.Context, key string) error { if key == "" { @@ -190,11 +242,23 @@ func (s *S3StorageService) DeleteFile(ctx context.Context, key string) error { return nil } -// GetPresignedURL génère une URL présignée pour télécharger un fichier +// GetPresignedURL génère une URL présignée pour télécharger un fichier. +// Utilise la TTL configurée sur le service (urlExpiry, défaut 1h). +// Pour une TTL explicite, utiliser GetSignedURL. func (s *S3StorageService) GetPresignedURL(ctx context.Context, key string) (string, error) { + return s.GetSignedURL(ctx, key, s.urlExpiry) +} + +// GetSignedURL génère une URL présignée GET avec une TTL explicite. +// v1.0.8 Phase 2 : StreamTrack utilise 15min, DownloadTrack 30min, +// ffmpeg transcoder 1h. Préféré à GetPresignedURL qui impose urlExpiry global. +func (s *S3StorageService) GetSignedURL(ctx context.Context, key string, ttl time.Duration) (string, error) { if key == "" { return "", fmt.Errorf("key cannot be empty") } + if ttl <= 0 { + ttl = s.urlExpiry + } presignClient := s3.NewPresignClient(s.client) @@ -202,15 +266,16 @@ func (s *S3StorageService) GetPresignedURL(ctx context.Context, key string) (str Bucket: aws.String(s.bucket), Key: aws.String(key), }, func(opts *s3.PresignOptions) { - opts.Expires = s.urlExpiry + opts.Expires = ttl }) if err != nil { - s.logger.Error("Failed to generate presigned URL", + s.logger.Error("Failed to generate signed URL", zap.Error(err), zap.String("key", key), zap.String("bucket", s.bucket), + zap.Duration("ttl", ttl), ) - return "", fmt.Errorf("failed to generate presigned URL: %w", err) + return "", fmt.Errorf("failed to generate signed URL: %w", err) } return request.URL, nil