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:
parent
ac31a54405
commit
282467ae14
3 changed files with 91 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue