From db1e1d1120aefdac44aa7b5d776a76028f5d0e66 Mon Sep 17 00:00:00 2001 From: Dylan Jeffers Date: Wed, 19 Aug 2026 18:00:56 -0700 Subject: [PATCH] fix(api): enforce is_streamable on the stream and download endpoints The track response has always reported `is_streamable: false` when a track is deleted or its owner is no longer active (a self deactivation or a trusted-notifier delist), but nothing enforced it. `/v1/tracks/{id}/stream` still redirected to a signed content-node URL, so the audio stayed fully reachable to anyone holding the link - verified against a delisted account in production, which served the complete 7.3MB mp3. Guard the stream and download endpoints, and leave non-streamable tracks out of the playlist m3u8 rather than emitting URLs the stream endpoint now rejects. Return 404 rather than 403 so these aren't distinguishable from a missing track. Co-Authored-By: Claude Opus 5 --- api/v1_playlist_stream.go | 7 +++++ api/v1_track_download.go | 7 +++++ api/v1_track_stream.go | 10 +++++++ api/v1_track_stream_test.go | 59 +++++++++++++++++++++++++++++++++++++ 4 files changed, 83 insertions(+) diff --git a/api/v1_playlist_stream.go b/api/v1_playlist_stream.go index b493a256..70f2932c 100644 --- a/api/v1_playlist_stream.go +++ b/api/v1_playlist_stream.go @@ -51,6 +51,13 @@ func (app *ApiServer) v1PlaylistStream(c *fiber.Ctx) error { continue } + // Leave deleted tracks, and tracks whose owner is no longer active, out + // of the m3u8 entirely rather than emitting a URL the stream endpoint + // will now reject. + if !track.IsStreamable { + continue + } + // Get track duration duration := int32(0) if track.Duration.Valid { diff --git a/api/v1_track_download.go b/api/v1_track_download.go index ef0a6454..674b7e77 100644 --- a/api/v1_track_download.go +++ b/api/v1_track_download.go @@ -42,6 +42,13 @@ func (app *ApiServer) v1TrackDownload(c *fiber.Ctx) error { } track := tracks[0] + + // Same guard as the stream endpoint: a deleted track, or one whose owner is + // no longer active, must not have its audio served here either. + if !track.IsStreamable { + return fiber.NewError(fiber.StatusNotFound, "track not found") + } + if !track.Access.Download { return fiber.NewError(fiber.StatusForbidden, "you are not allowed to download this track") } diff --git a/api/v1_track_stream.go b/api/v1_track_stream.go index 14a3b8ab..98393d22 100644 --- a/api/v1_track_stream.go +++ b/api/v1_track_stream.go @@ -29,6 +29,16 @@ func (app *ApiServer) v1TrackStream(c *fiber.Ctx) error { track := tracks[0] + // `is_streamable` is false when the track is deleted or its owner is no + // longer active - either the artist deactivated their own account or the + // account was delisted by the trusted notifier. The track response has + // always reported this, but nothing enforced it, so the audio stayed + // reachable to anyone holding the URL. Treat it as not found rather than + // forbidden so we don't distinguish these from a missing track. + if !track.IsStreamable { + return fiber.NewError(fiber.StatusNotFound, "track not found") + } + if track.Access.Stream { // Stream is nil when the track row has no cid to sign (e.g. an // upload-v2 row that never got its track_cid backfilled). diff --git a/api/v1_track_stream_test.go b/api/v1_track_stream_test.go index 7d5e43b2..92d35cb4 100644 --- a/api/v1_track_stream_test.go +++ b/api/v1_track_stream_test.go @@ -60,3 +60,62 @@ func TestGetTrackStreamWithID3(t *testing.T) { assert.Contains(t, location, "id3=true") assert.Contains(t, location, "id3_title=Culca+Canyon") } + +// A track whose owner is no longer active - the artist deactivated their own +// account, or the account was delisted by the trusted notifier - reports +// is_streamable=false on the track response. The stream endpoint must refuse +// to serve the audio rather than redirecting to a signed content-node URL. +func TestGetTrackStream_DeactivatedOwner(t *testing.T) { + app := emptyTestApp(t) + fixtures := database.FixtureMap{ + "tracks": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "title": "Deactivated Owner", + "track_cid": "QmDeactivatedOwnerCid", + }, + }, + "users": []map[string]any{ + { + "user_id": 1, + "handle": "testuser1", + "is_deactivated": true, + }, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + req := httptest.NewRequest("GET", "/v1/tracks/"+trashid.MustEncodeHashID(1)+"/stream", nil) + res, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, 404, res.StatusCode) + assert.Empty(t, res.Header.Get("Location")) +} + +// A deleted track is likewise non-streamable and must not redirect. +func TestGetTrackStream_DeletedTrack(t *testing.T) { + app := emptyTestApp(t) + fixtures := database.FixtureMap{ + "tracks": []map[string]any{ + { + "track_id": 1, + "owner_id": 1, + "title": "Deleted", + "track_cid": "QmDeletedTrackCid", + "is_delete": true, + }, + }, + "users": []map[string]any{ + { + "user_id": 1, + "handle": "testuser1", + }, + }, + } + database.Seed(app.pool.Replicas[0], fixtures) + req := httptest.NewRequest("GET", "/v1/tracks/"+trashid.MustEncodeHashID(1)+"/stream", nil) + res, err := app.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, 404, res.StatusCode) + assert.Empty(t, res.Header.Get("Location")) +}