feat(tracks): serve S3-backed tracks via signed URL redirect (v1.0.8 P2)

Closes the read-side gap for Phase 1 uploads. Tracks with
storage_backend='s3' now get a 302 redirect to a MinIO signed URL
from /stream and /download, letting the client fetch bytes directly
without the backend proxying. Range headers remain honored by MinIO.

Changes:

- internal/core/track/service.go
  - New method `TrackService.GetStorageURL(ctx, track, ttl)` returns
    (url, isS3, err). Empty + false for local-backed tracks (caller
    falls back to FS). Returns a presigned URL with caller-chosen TTL
    for s3-backed rows.
  - Defensive: storage_backend='s3' with nil storage_key returns
    (empty, false, nil) — treated as legacy/broken, falls back to FS
    rather than crashing the request.
  - Errors when row claims s3 but TrackService has no S3 wired
    (should be prevented by Config validation rule 11).

- internal/core/track/track_hls_handler.go
  - `StreamTrack`: tries GetStorageURL(ctx, track, 15*time.Minute)
    before opening the local file. On s3 hit → 302 redirect. TTL 15min
    fits a full track consumption with margin.
  - `DownloadTrack`: same pattern with 30min TTL (downloads can be
    slower on mobile; single-shot flow).
  - Both endpoints keep their existing permission checks (share token,
    public/owner, license) unchanged — redirect happens only after the
    request is authorized to see the track.

- internal/core/track/service_async_test.go
  - `TestGetStorageURL` covers 3 cases: local backend (no redirect),
    s3 backend with valid key (redirect + TTL forwarded), s3 backend
    with nil key (defensive fallback).

Out of scope Phase 2 remaining (A5): transcoder pulls from S3 via
signed URL, HLS segments written to MinIO.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
senke 2026-04-23 23:26:14 +02:00
parent ac31a54405
commit 282467ae14
3 changed files with 91 additions and 1 deletions

View file

@ -164,6 +164,33 @@ func (s *TrackService) IsS3Backend() bool {
return s.storageBackend == "s3" && s.s3Service != nil
}
// GetStorageURL returns a signed URL for a track's file when the track row
// carries storage_backend='s3'. Returns ("", false, nil) for local-backed
// tracks — the caller must fall back to filesystem serving.
//
// v1.0.8 Phase 2 — handlers (StreamTrack, DownloadTrack) use this to emit
// a 302 redirect to MinIO/S3 for tracks that were uploaded under s3 mode.
// TTL is caller-provided: 15min for streaming, 30min for downloads, 1h for
// the transcoder.
func (s *TrackService) GetStorageURL(ctx context.Context, track *models.Track, ttl time.Duration) (string, bool, error) {
if track == nil {
return "", false, fmt.Errorf("track is nil")
}
if track.StorageBackend != "s3" || track.StorageKey == nil || *track.StorageKey == "" {
return "", false, nil
}
if s.s3Service == nil {
// Row says s3 but no S3 service wired. Should be prevented by
// Config.ValidateForEnvironment rule 11, but guard here anyway.
return "", false, fmt.Errorf("track %s is s3-backed but TrackService has no S3 service configured", track.ID)
}
url, err := s.s3Service.GetSignedURL(ctx, *track.StorageKey, ttl)
if err != nil {
return "", false, fmt.Errorf("generate signed URL for track %s: %w", track.ID, err)
}
return url, true, nil
}
// ValidateTrackFile valide le format et la taille d'un fichier audio
func (s *TrackService) ValidateTrackFile(fileHeader *multipart.FileHeader) error {
// Valider la taille

View file

@ -348,6 +348,37 @@ func TestUploadTrack_S3Backend_UploadsToS3(t *testing.T) {
}
}
func TestGetStorageURL(t *testing.T) {
logger := zaptest.NewLogger(t)
service := NewTrackService(nil, logger, t.TempDir())
fake := &fakeS3Storage{}
service.SetS3Storage(fake, "s3", "bucket")
// Local-backed track — returns empty, not s3
key := ""
localTrack := &models.Track{ID: uuid.New(), StorageBackend: "local", StorageKey: &key}
url, isS3, err := service.GetStorageURL(context.Background(), localTrack, 5*time.Minute)
require.NoError(t, err)
assert.False(t, isS3, "local backend must not return isS3=true")
assert.Empty(t, url)
// S3-backed track — returns signed URL
s3Key := "tracks/u/t.mp3"
s3Track := &models.Track{ID: uuid.New(), StorageBackend: "s3", StorageKey: &s3Key}
url, isS3, err = service.GetStorageURL(context.Background(), s3Track, 5*time.Minute)
require.NoError(t, err)
assert.True(t, isS3)
assert.Contains(t, url, s3Key)
assert.Contains(t, url, "ttl=5m0s", "fake returns TTL in URL for assertion")
// S3-backed track but nil StorageKey — treated as local (defensive)
s3BrokenTrack := &models.Track{ID: uuid.New(), StorageBackend: "s3", StorageKey: nil}
url, isS3, err = service.GetStorageURL(context.Background(), s3BrokenTrack, 5*time.Minute)
require.NoError(t, err)
assert.False(t, isS3)
assert.Empty(t, url)
}
func TestUploadTrack_S3Backend_NilS3Service_FallsBackToLocal(t *testing.T) {
// Defensive: storageBackend="s3" but nil s3Service must fall back to local,
// not crash. Matches the guard in copyFileAsync (both conditions required).

View file

@ -6,6 +6,7 @@ import (
"net/http"
"os"
"strings"
"time"
"github.com/google/uuid"
@ -156,7 +157,22 @@ func (h *TrackHandler) DownloadTrack(c *gin.Context) {
}
}
// Vérifier que le fichier existe
// v1.0.8 Phase 2 — S3-backed tracks: redirect to a signed URL. MinIO
// honors Range headers so the client can still seek within the audio.
// TTL 30min — downloads tend to be one-shot and may be slow on mobile.
if url, ok, err := h.trackService.GetStorageURL(c.Request.Context(), track, 30*time.Minute); err != nil {
h.trackService.logger.Error("Failed to build signed URL for S3-backed track download",
zap.String("track_id", trackID.String()),
zap.Error(err),
)
h.respondWithError(c, http.StatusInternalServerError, "failed to build track URL")
return
} else if ok {
c.Redirect(http.StatusFound, url)
return
}
// Local backend — serve from FS.
if _, err := os.Stat(track.FilePath); os.IsNotExist(err) {
// MOD-P2-003: Utiliser AppError au lieu de gin.H
h.respondWithError(c, http.StatusNotFound, "track file not found")
@ -233,6 +249,22 @@ func (h *TrackHandler) StreamTrack(c *gin.Context) {
return
}
// v1.0.8 Phase 2 — S3-backed tracks: redirect to a signed URL so the
// browser fetches bytes directly from MinIO. TTL 15min — shorter than
// download because streams are typically consumed within minutes; long
// enough to survive one full track (even 30-minute mixes).
if url, ok, err := h.trackService.GetStorageURL(c.Request.Context(), track, 15*time.Minute); err != nil {
h.trackService.logger.Error("Failed to build signed URL for S3-backed track stream",
zap.String("track_id", trackID.String()),
zap.Error(err),
)
h.respondWithError(c, http.StatusInternalServerError, "failed to build track URL")
return
} else if ok {
c.Redirect(http.StatusFound, url)
return
}
file, err := os.Open(track.FilePath)
if err != nil {
if os.IsNotExist(err) {