feat(s3): add UploadStream + GetSignedURL with explicit TTL (v1.0.8 P1 prep)

Prepares the S3StorageService surface for the MinIO upload migration:

- UploadStream(ctx, io.Reader, key, contentType, size) — streams bytes
  via the existing manager.Uploader (multipart, 10MB parts, 3 goroutines)
  without buffering the whole body in memory. Tracks can be up to 500MB;
  UploadFile([]byte) would OOM at that size.

- GetSignedURL(ctx, key, ttl) — presigned URL with per-call TTL, decoupling
  from the service-level urlExpiry. Phase 2 needs 15min (StreamTrack),
  30min (DownloadTrack), 1h (transcoder). GetPresignedURL remains as
  thin back-compat wrapper using the default TTL.

No change in behavior for existing callers (CloudService, WaveformService,
GearDocumentService, CloudBackupWorker). TrackService will consume these
new methods in Phase 1.

Refs: plan Batch A step A1, AUDIT_REPORT §10 v1.0.8 deferrals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-23 20:49:19 +02:00
parent 4ee8c38536
commit 3d43d43075

View file

@ -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