Skip to content

Commit b4ce322

Browse files
dylanjeffersclaude
andauthored
feat(playlists): permalink-only access to private playlists (#843)
## Summary - Allow unauthenticated callers to view a **private playlist** when they have the permalink, while keeping it hidden from search/browse and bare-ID lookups. This mirrors the existing `is_unlisted` model for tracks. - A new `is_private` filter is added to `get_playlists.sql` with an `@include_private` bypass flag. Permalink-driven entry points (the `by_permalink` route, the bulk `/v1/full/playlists?permalink=…` endpoint, and the `/v1/resolve` redirect target) set `IncludePrivate: true`; bare ID lookups do not. - The bulk endpoint keeps caller-supplied IDs and permalink-derived IDs in **separate** `Playlists()` calls so a request mixing `?id=PRIVATE_ID&permalink=any_public` cannot leak a private playlist by piggy-backing on the permalink trust. - `v1_playlist_by_permalink` now accepts both `/playlist/` and `/album/` URL variants, so `/v1/resolve` can redirect album URLs there. - Tracks already allow anonymous permalink access (every direct-fetch handler sets `IncludeUnlisted: true`); no code change there, just a regression test. ## Behavior change Anonymous (and non-owner) requests to `/v1/full/playlists/:id` or `/v1/full/playlists?id=…` for a **private** playlist will now return 404 / empty. Owners (myID == owner_id) and any callers using the permalink continue to see the playlist. ## Test plan - [ ] `TestV1PlaylistByPermalink` and `TestV1AlbumByPermalink` still pass - [ ] `TestV1PrivatePlaylistByPermalinkAnonymous` — anon caller gets private playlist via permalink - [ ] `TestV1PrivateAlbumByPermalinkAnonymous` — anon caller gets private album via permalink - [ ] `TestPlaylistsEndpointPrivatePermalinkAnonymous` — same via bulk endpoint - [ ] `TestPlaylistsEndpointPrivateByIdHiddenFromAnonymous` — `?id=` does **not** leak private - [ ] `TestGetPlaylistPrivateAnonymous404` — `/playlists/:id` returns 404 for anon on private - [ ] `TestGetPlaylistPrivateOwnerAllowed` — owner can still fetch their private playlist by ID - [ ] `TestGetUnlistedTrackByPermalinkAnonymous` — regression lock-in for track permalink behavior - [ ] Resolve tests (`TestResolvePlaylistURL`) still 302 (redirect target changed to `by_permalink`, still ok) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 081664b commit b4ce322

10 files changed

Lines changed: 222 additions & 33 deletions

api/dbv1/get_playlists.sql.go

Lines changed: 5 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

api/dbv1/queries/get_playlists.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,4 +129,5 @@ JOIN aggregate_playlist using (playlist_id)
129129
LEFT JOIN playlist_routes on p.playlist_id = playlist_routes.playlist_id and playlist_routes.is_current = true
130130
WHERE is_delete = false
131131
and p.playlist_id = ANY(@ids::int[])
132+
and (p.is_private = false OR p.playlist_owner_id = @my_id OR @include_private::bool = TRUE)
132133
;

api/v1_playlist_by_permalink.go

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,22 @@ func (app *ApiServer) v1PlaylistByPermalink(c *fiber.Ctx) error {
2020
}
2121

2222
ids, err := app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
23-
Handles: []string{params.Handle},
24-
Slugs: []string{params.Slug},
25-
Permalinks: []string{"/" + params.Handle + "/playlist/" + params.Slug},
23+
Handles: []string{params.Handle},
24+
Slugs: []string{params.Slug},
25+
Permalinks: []string{
26+
"/" + params.Handle + "/playlist/" + params.Slug,
27+
"/" + params.Handle + "/album/" + params.Slug,
28+
},
2629
})
2730
if err != nil {
2831
return err
2932
}
3033

3134
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
3235
GetPlaylistsParams: dbv1.GetPlaylistsParams{
33-
MyID: myId,
34-
Ids: ids,
36+
MyID: myId,
37+
Ids: ids,
38+
IncludePrivate: true,
3539
},
3640
AuthedWallet: app.tryGetAuthedWallet(c),
3741
})

api/v1_playlist_by_permalink_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
package api
22

33
import (
4+
"context"
45
"testing"
56

67
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
79
)
810

911
func TestV1PlaylistByPermalink(t *testing.T) {
@@ -16,3 +18,53 @@ func TestV1PlaylistByPermalink(t *testing.T) {
1618
"data.0.playlist_name": "playlist by permalink",
1719
})
1820
}
21+
22+
func TestV1AlbumByPermalink(t *testing.T) {
23+
app := testAppWithFixtures(t)
24+
status, body := testGet(t, app, "/v1/full/playlists/by_permalink/AlbumsByPermalink/album-by-permalink")
25+
assert.Equal(t, 200, status)
26+
27+
jsonAssert(t, body, map[string]any{
28+
"data.0.id": "ePVXL",
29+
"data.0.playlist_name": "album by permalink",
30+
})
31+
}
32+
33+
// A private playlist should be returned to anonymous callers when fetched via
34+
// permalink — "has the link" is sufficient permission.
35+
func TestV1PrivatePlaylistByPermalinkAnonymous(t *testing.T) {
36+
app := testAppWithFixtures(t)
37+
ctx := context.Background()
38+
require.NotNil(t, app.writePool, "test requires write pool")
39+
40+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
41+
require.NoError(t, err)
42+
43+
status, body := testGet(t, app, "/v1/full/playlists/by_permalink/PlaylistsByPermalink/playlist-by-permalink")
44+
assert.Equal(t, 200, status)
45+
46+
jsonAssert(t, body, map[string]any{
47+
"data.0.id": "eYake",
48+
"data.0.playlist_name": "playlist by permalink",
49+
"data.0.is_private": true,
50+
})
51+
}
52+
53+
// Same for albums.
54+
func TestV1PrivateAlbumByPermalinkAnonymous(t *testing.T) {
55+
app := testAppWithFixtures(t)
56+
ctx := context.Background()
57+
require.NotNil(t, app.writePool, "test requires write pool")
58+
59+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 501 AND is_current = true`)
60+
require.NoError(t, err)
61+
62+
status, body := testGet(t, app, "/v1/full/playlists/by_permalink/AlbumsByPermalink/album-by-permalink")
63+
assert.Equal(t, 200, status)
64+
65+
jsonAssert(t, body, map[string]any{
66+
"data.0.id": "ePVXL",
67+
"data.0.playlist_name": "album by permalink",
68+
"data.0.is_private": true,
69+
})
70+
}

api/v1_playlists.go

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,13 @@ func (app *ApiServer) v1Playlists(c *fiber.Ctx) error {
1515
// unless client explicitly does ?with_tracks=true
1616
withTracks, _ := strconv.ParseBool(c.Query("with_tracks", "false"))
1717

18-
// Add permalink ID mappings
18+
authedWallet := app.tryGetAuthedWallet(c)
19+
20+
// Permalink-matched IDs are kept separate from caller-supplied IDs. Possession
21+
// of a valid permalink is treated as proof of access, so private playlists
22+
// matched by permalink are returned without auth. We must not extend that
23+
// trust to user-supplied IDs in the same request.
24+
var permalinkIds []int32
1925
permalinks := queryMulti(c, "permalink")
2026
if len(permalinks) > 0 {
2127
handles := make([]string, len(permalinks))
@@ -29,15 +35,15 @@ func (app *ApiServer) v1Playlists(c *fiber.Ctx) error {
2935
return fiber.NewError(fiber.StatusBadRequest, "Invalid permalink: "+permalinks[i])
3036
}
3137
}
32-
newIds, err := app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
38+
var err error
39+
permalinkIds, err = app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
3340
Handles: handles,
3441
Slugs: slugs,
3542
Permalinks: permalinks,
3643
})
3744
if err != nil {
3845
return err
3946
}
40-
ids = append(ids, newIds...)
4147
}
4248

4349
upcs := queryMulti(c, "upc")
@@ -49,17 +55,38 @@ func (app *ApiServer) v1Playlists(c *fiber.Ctx) error {
4955
ids = append(ids, newIds...)
5056
}
5157

52-
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
53-
GetPlaylistsParams: dbv1.GetPlaylistsParams{
54-
MyID: myId,
55-
Ids: ids,
56-
},
57-
OmitTracks: !withTracks,
58-
AuthedWallet: app.tryGetAuthedWallet(c),
59-
})
60-
if err != nil {
61-
return err
58+
out := make([]dbv1.Playlist, 0, len(ids)+len(permalinkIds))
59+
60+
if len(ids) > 0 {
61+
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
62+
GetPlaylistsParams: dbv1.GetPlaylistsParams{
63+
MyID: myId,
64+
Ids: ids,
65+
},
66+
OmitTracks: !withTracks,
67+
AuthedWallet: authedWallet,
68+
})
69+
if err != nil {
70+
return err
71+
}
72+
out = append(out, playlists...)
73+
}
74+
75+
if len(permalinkIds) > 0 {
76+
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
77+
GetPlaylistsParams: dbv1.GetPlaylistsParams{
78+
MyID: myId,
79+
Ids: permalinkIds,
80+
IncludePrivate: true,
81+
},
82+
OmitTracks: !withTracks,
83+
AuthedWallet: authedWallet,
84+
})
85+
if err != nil {
86+
return err
87+
}
88+
out = append(out, playlists...)
6289
}
6390

64-
return v1PlaylistsResponse(c, playlists)
91+
return v1PlaylistsResponse(c, out)
6592
}

api/v1_playlists_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
package api
22

33
import (
4+
"context"
45
"testing"
56

67
"api.audius.co/api/dbv1"
8+
"api.audius.co/trashid"
79
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
811
)
912

1013
func TestPlaylistsEndpoint(t *testing.T) {
@@ -60,3 +63,79 @@ func TestPlaylistsEndpointWithAlbumPermalink(t *testing.T) {
6063
"data.0.playlist_name": "album by permalink",
6164
})
6265
}
66+
67+
// A permalink-based lookup of a private playlist works for anonymous callers.
68+
func TestPlaylistsEndpointPrivatePermalinkAnonymous(t *testing.T) {
69+
app := testAppWithFixtures(t)
70+
ctx := context.Background()
71+
require.NotNil(t, app.writePool, "test requires write pool")
72+
73+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
74+
require.NoError(t, err)
75+
76+
var resp struct {
77+
Data []dbv1.Playlist
78+
}
79+
status, body := testGet(t, app, "/v1/full/playlists?permalink=/PlaylistsByPermalink/playlist/playlist-by-permalink", &resp)
80+
assert.Equal(t, 200, status)
81+
assert.Len(t, resp.Data, 1, "permalink lookup must return private playlist even without auth")
82+
83+
jsonAssert(t, body, map[string]any{
84+
"data.0.id": "eYake",
85+
"data.0.playlist_name": "playlist by permalink",
86+
"data.0.is_private": true,
87+
})
88+
}
89+
90+
// An ID-based lookup must NOT return private playlists to anonymous callers.
91+
func TestPlaylistsEndpointPrivateByIdHiddenFromAnonymous(t *testing.T) {
92+
app := testAppWithFixtures(t)
93+
ctx := context.Background()
94+
require.NotNil(t, app.writePool, "test requires write pool")
95+
96+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
97+
require.NoError(t, err)
98+
99+
var resp struct {
100+
Data []dbv1.Playlist
101+
}
102+
status, _ := testGet(t, app, "/v1/full/playlists?id=eYake", &resp)
103+
assert.Equal(t, 200, status)
104+
assert.Len(t, resp.Data, 0, "private playlist must not be returned for ID-based anonymous lookup")
105+
}
106+
107+
// The single playlist endpoint must also hide private playlists from anonymous callers.
108+
func TestGetPlaylistPrivateAnonymous404(t *testing.T) {
109+
app := testAppWithFixtures(t)
110+
ctx := context.Background()
111+
require.NotNil(t, app.writePool, "test requires write pool")
112+
113+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
114+
require.NoError(t, err)
115+
116+
status, _ := testGet(t, app, "/v1/full/playlists/eYake")
117+
assert.Equal(t, 404, status, "private playlist must 404 for anonymous ID-based fetch")
118+
}
119+
120+
// The single playlist endpoint must return private playlists to their owner.
121+
func TestGetPlaylistPrivateOwnerAllowed(t *testing.T) {
122+
app := testAppWithFixtures(t)
123+
// user 7's fixture wallet has no test signature, so bypass the auth
124+
// middleware and let user_id alone identify the owner for this test.
125+
app.skipAuthCheck = true
126+
ctx := context.Background()
127+
require.NotNil(t, app.writePool, "test requires write pool")
128+
129+
// playlist 500 is owned by user 7
130+
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
131+
require.NoError(t, err)
132+
133+
ownerId := trashid.MustEncodeHashID(7)
134+
status, body := testGet(t, app, "/v1/full/playlists/eYake?user_id="+ownerId)
135+
assert.Equal(t, 200, status, "owner must be able to view their own private playlist by ID")
136+
jsonAssert(t, body, map[string]any{
137+
"data.0.id": "eYake",
138+
"data.0.playlist_name": "playlist by permalink",
139+
"data.0.is_private": true,
140+
})
141+
}

api/v1_resolve.go

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,15 +90,12 @@ func (app *ApiServer) v1Resolve(c *fiber.Ctx) error {
9090
return fiber.NewError(fiber.StatusNotFound, "Playlist not found")
9191
}
9292

93-
playlistId, err := trashid.EncodeHashId(int(playlistIds[0]))
94-
if err != nil {
95-
return err
96-
}
97-
93+
// Redirect to the by_permalink route so the destination handler can
94+
// honor "has the link" as access to private playlists/albums.
9895
if isFull {
99-
return app.redirectWithPreservedParams(c, "/v1/full/playlists/"+playlistId, fiber.StatusFound)
96+
return app.redirectWithPreservedParams(c, "/v1/full/playlists/by_permalink/"+handle+"/"+slug, fiber.StatusFound)
10097
}
101-
return app.redirectWithPreservedParams(c, "/v1/playlists/"+playlistId, fiber.StatusFound)
98+
return app.redirectWithPreservedParams(c, "/v1/playlists/by_permalink/"+handle+"/"+slug, fiber.StatusFound)
10299
}
103100

104101
// Try to match user URL

api/v1_tracks_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,25 @@ func TestGetTracksByISRC(t *testing.T) {
7878
}
7979
}
8080

81+
func TestGetUnlistedTrackByPermalinkAnonymous(t *testing.T) {
82+
app := testAppWithFixtures(t)
83+
ctx := context.Background()
84+
require.NotNil(t, app.writePool, "test requires write pool")
85+
86+
// Mark the permalink fixture track (track_id=500) as unlisted.
87+
_, err := app.writePool.Exec(ctx, `UPDATE tracks SET is_unlisted = true WHERE track_id = 500 AND is_current = true`)
88+
require.NoError(t, err)
89+
90+
// Anonymous request via permalink returns the unlisted track.
91+
var resp struct {
92+
Data []dbv1.Track
93+
}
94+
status, _ := testGet(t, app, "/v1/full/tracks?permalink=/TracksByPermalink/track-by-permalink", &resp)
95+
assert.Equal(t, 200, status)
96+
assert.Len(t, resp.Data, 1, "permalink lookup must return the unlisted track even without auth")
97+
assert.Equal(t, "track by permalink", resp.Data[0].Title.String)
98+
}
99+
81100
func TestGetTracksExcludesAccessAuthorities(t *testing.T) {
82101
app := testAppWithFixtures(t)
83102
ctx := context.Background()

api/v1_users_albums.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ func (app *ApiServer) v1UserAlbums(c *fiber.Ctx) error {
8282
return err
8383
}
8484

85+
// Privacy was already enforced by the outer query via albumFilter, so
86+
// allow Playlists() to hydrate private albums when the caller has
87+
// authorization (e.g. filter_albums=private for the owner).
8588
albums, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
8689
GetPlaylistsParams: dbv1.GetPlaylistsParams{
87-
Ids: ids,
88-
MyID: myId,
90+
Ids: ids,
91+
MyID: myId,
92+
IncludePrivate: true,
8993
},
9094
OmitTracks: true,
9195
AuthedWallet: app.tryGetAuthedWallet(c),

api/v1_users_playlists.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,14 @@ func (app *ApiServer) v1UserPlaylists(c *fiber.Ctx) error {
8282
return err
8383
}
8484

85+
// Privacy was already enforced by the outer query via playlistFilter, so
86+
// allow Playlists() to hydrate private playlists when the caller has
87+
// authorization (e.g. filter_playlists=private for the owner).
8588
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
8689
GetPlaylistsParams: dbv1.GetPlaylistsParams{
87-
Ids: ids,
88-
MyID: myId,
90+
Ids: ids,
91+
MyID: myId,
92+
IncludePrivate: true,
8993
},
9094
OmitTracks: true,
9195
AuthedWallet: app.tryGetAuthedWallet(c),

0 commit comments

Comments
 (0)