Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions api/dbv1/get_playlists.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions api/dbv1/queries/get_playlists.sql
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,5 @@ JOIN aggregate_playlist using (playlist_id)
LEFT JOIN playlist_routes on p.playlist_id = playlist_routes.playlist_id and playlist_routes.is_current = true
WHERE is_delete = false
and p.playlist_id = ANY(@ids::int[])
and (p.is_private = false OR p.playlist_owner_id = @my_id OR @include_private::bool = TRUE)
;
14 changes: 9 additions & 5 deletions api/v1_playlist_by_permalink.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,22 @@ func (app *ApiServer) v1PlaylistByPermalink(c *fiber.Ctx) error {
}

ids, err := app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
Handles: []string{params.Handle},
Slugs: []string{params.Slug},
Permalinks: []string{"/" + params.Handle + "/playlist/" + params.Slug},
Handles: []string{params.Handle},
Slugs: []string{params.Slug},
Permalinks: []string{
"/" + params.Handle + "/playlist/" + params.Slug,
"/" + params.Handle + "/album/" + params.Slug,
},
})
if err != nil {
return err
}

playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
MyID: myId,
Ids: ids,
MyID: myId,
Ids: ids,
IncludePrivate: true,
},
AuthedWallet: app.tryGetAuthedWallet(c),
})
Expand Down
52 changes: 52 additions & 0 deletions api/v1_playlist_by_permalink_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
package api

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestV1PlaylistByPermalink(t *testing.T) {
Expand All @@ -16,3 +18,53 @@ func TestV1PlaylistByPermalink(t *testing.T) {
"data.0.playlist_name": "playlist by permalink",
})
}

func TestV1AlbumByPermalink(t *testing.T) {
app := testAppWithFixtures(t)
status, body := testGet(t, app, "/v1/full/playlists/by_permalink/AlbumsByPermalink/album-by-permalink")
assert.Equal(t, 200, status)

jsonAssert(t, body, map[string]any{
"data.0.id": "ePVXL",
"data.0.playlist_name": "album by permalink",
})
}

// A private playlist should be returned to anonymous callers when fetched via
// permalink — "has the link" is sufficient permission.
func TestV1PrivatePlaylistByPermalinkAnonymous(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
require.NoError(t, err)

status, body := testGet(t, app, "/v1/full/playlists/by_permalink/PlaylistsByPermalink/playlist-by-permalink")
assert.Equal(t, 200, status)

jsonAssert(t, body, map[string]any{
"data.0.id": "eYake",
"data.0.playlist_name": "playlist by permalink",
"data.0.is_private": true,
})
}

// Same for albums.
func TestV1PrivateAlbumByPermalinkAnonymous(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 501 AND is_current = true`)
require.NoError(t, err)

status, body := testGet(t, app, "/v1/full/playlists/by_permalink/AlbumsByPermalink/album-by-permalink")
assert.Equal(t, 200, status)

jsonAssert(t, body, map[string]any{
"data.0.id": "ePVXL",
"data.0.playlist_name": "album by permalink",
"data.0.is_private": true,
})
}
55 changes: 41 additions & 14 deletions api/v1_playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ func (app *ApiServer) v1Playlists(c *fiber.Ctx) error {
// unless client explicitly does ?with_tracks=true
withTracks, _ := strconv.ParseBool(c.Query("with_tracks", "false"))

// Add permalink ID mappings
authedWallet := app.tryGetAuthedWallet(c)

// Permalink-matched IDs are kept separate from caller-supplied IDs. Possession
// of a valid permalink is treated as proof of access, so private playlists
// matched by permalink are returned without auth. We must not extend that
// trust to user-supplied IDs in the same request.
var permalinkIds []int32
permalinks := queryMulti(c, "permalink")
if len(permalinks) > 0 {
handles := make([]string, len(permalinks))
Expand All @@ -29,15 +35,15 @@ func (app *ApiServer) v1Playlists(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusBadRequest, "Invalid permalink: "+permalinks[i])
}
}
newIds, err := app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
var err error
permalinkIds, err = app.queries.GetPlaylistIdsByPermalink(c.Context(), dbv1.GetPlaylistIdsByPermalinkParams{
Handles: handles,
Slugs: slugs,
Permalinks: permalinks,
})
if err != nil {
return err
}
ids = append(ids, newIds...)
}

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

playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
MyID: myId,
Ids: ids,
},
OmitTracks: !withTracks,
AuthedWallet: app.tryGetAuthedWallet(c),
})
if err != nil {
return err
out := make([]dbv1.Playlist, 0, len(ids)+len(permalinkIds))

if len(ids) > 0 {
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
MyID: myId,
Ids: ids,
},
OmitTracks: !withTracks,
AuthedWallet: authedWallet,
})
if err != nil {
return err
}
out = append(out, playlists...)
}

if len(permalinkIds) > 0 {
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
MyID: myId,
Ids: permalinkIds,
IncludePrivate: true,
},
OmitTracks: !withTracks,
AuthedWallet: authedWallet,
})
if err != nil {
return err
}
out = append(out, playlists...)
}

return v1PlaylistsResponse(c, playlists)
return v1PlaylistsResponse(c, out)
}
79 changes: 79 additions & 0 deletions api/v1_playlists_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package api

import (
"context"
"testing"

"api.audius.co/api/dbv1"
"api.audius.co/trashid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestPlaylistsEndpoint(t *testing.T) {
Expand Down Expand Up @@ -60,3 +63,79 @@ func TestPlaylistsEndpointWithAlbumPermalink(t *testing.T) {
"data.0.playlist_name": "album by permalink",
})
}

// A permalink-based lookup of a private playlist works for anonymous callers.
func TestPlaylistsEndpointPrivatePermalinkAnonymous(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
require.NoError(t, err)

var resp struct {
Data []dbv1.Playlist
}
status, body := testGet(t, app, "/v1/full/playlists?permalink=/PlaylistsByPermalink/playlist/playlist-by-permalink", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 1, "permalink lookup must return private playlist even without auth")

jsonAssert(t, body, map[string]any{
"data.0.id": "eYake",
"data.0.playlist_name": "playlist by permalink",
"data.0.is_private": true,
})
}

// An ID-based lookup must NOT return private playlists to anonymous callers.
func TestPlaylistsEndpointPrivateByIdHiddenFromAnonymous(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
require.NoError(t, err)

var resp struct {
Data []dbv1.Playlist
}
status, _ := testGet(t, app, "/v1/full/playlists?id=eYake", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 0, "private playlist must not be returned for ID-based anonymous lookup")
}

// The single playlist endpoint must also hide private playlists from anonymous callers.
func TestGetPlaylistPrivateAnonymous404(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
require.NoError(t, err)

status, _ := testGet(t, app, "/v1/full/playlists/eYake")
assert.Equal(t, 404, status, "private playlist must 404 for anonymous ID-based fetch")
}

// The single playlist endpoint must return private playlists to their owner.
func TestGetPlaylistPrivateOwnerAllowed(t *testing.T) {
app := testAppWithFixtures(t)
// user 7's fixture wallet has no test signature, so bypass the auth
// middleware and let user_id alone identify the owner for this test.
app.skipAuthCheck = true
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

// playlist 500 is owned by user 7
_, err := app.writePool.Exec(ctx, `UPDATE playlists SET is_private = true WHERE playlist_id = 500 AND is_current = true`)
require.NoError(t, err)

ownerId := trashid.MustEncodeHashID(7)
status, body := testGet(t, app, "/v1/full/playlists/eYake?user_id="+ownerId)
assert.Equal(t, 200, status, "owner must be able to view their own private playlist by ID")
jsonAssert(t, body, map[string]any{
"data.0.id": "eYake",
"data.0.playlist_name": "playlist by permalink",
"data.0.is_private": true,
})
}
11 changes: 4 additions & 7 deletions api/v1_resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,15 +90,12 @@ func (app *ApiServer) v1Resolve(c *fiber.Ctx) error {
return fiber.NewError(fiber.StatusNotFound, "Playlist not found")
}

playlistId, err := trashid.EncodeHashId(int(playlistIds[0]))
if err != nil {
return err
}

// Redirect to the by_permalink route so the destination handler can
// honor "has the link" as access to private playlists/albums.
if isFull {
return app.redirectWithPreservedParams(c, "/v1/full/playlists/"+playlistId, fiber.StatusFound)
return app.redirectWithPreservedParams(c, "/v1/full/playlists/by_permalink/"+handle+"/"+slug, fiber.StatusFound)
}
return app.redirectWithPreservedParams(c, "/v1/playlists/"+playlistId, fiber.StatusFound)
return app.redirectWithPreservedParams(c, "/v1/playlists/by_permalink/"+handle+"/"+slug, fiber.StatusFound)
}

// Try to match user URL
Expand Down
19 changes: 19 additions & 0 deletions api/v1_tracks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,25 @@ func TestGetTracksByISRC(t *testing.T) {
}
}

func TestGetUnlistedTrackByPermalinkAnonymous(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
require.NotNil(t, app.writePool, "test requires write pool")

// Mark the permalink fixture track (track_id=500) as unlisted.
_, err := app.writePool.Exec(ctx, `UPDATE tracks SET is_unlisted = true WHERE track_id = 500 AND is_current = true`)
require.NoError(t, err)

// Anonymous request via permalink returns the unlisted track.
var resp struct {
Data []dbv1.Track
}
status, _ := testGet(t, app, "/v1/full/tracks?permalink=/TracksByPermalink/track-by-permalink", &resp)
assert.Equal(t, 200, status)
assert.Len(t, resp.Data, 1, "permalink lookup must return the unlisted track even without auth")
assert.Equal(t, "track by permalink", resp.Data[0].Title.String)
}

func TestGetTracksExcludesAccessAuthorities(t *testing.T) {
app := testAppWithFixtures(t)
ctx := context.Background()
Expand Down
8 changes: 6 additions & 2 deletions api/v1_users_albums.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ func (app *ApiServer) v1UserAlbums(c *fiber.Ctx) error {
return err
}

// Privacy was already enforced by the outer query via albumFilter, so
// allow Playlists() to hydrate private albums when the caller has
// authorization (e.g. filter_albums=private for the owner).
albums, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
Ids: ids,
MyID: myId,
Ids: ids,
MyID: myId,
IncludePrivate: true,
},
OmitTracks: true,
AuthedWallet: app.tryGetAuthedWallet(c),
Expand Down
8 changes: 6 additions & 2 deletions api/v1_users_playlists.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,14 @@ func (app *ApiServer) v1UserPlaylists(c *fiber.Ctx) error {
return err
}

// Privacy was already enforced by the outer query via playlistFilter, so
// allow Playlists() to hydrate private playlists when the caller has
// authorization (e.g. filter_playlists=private for the owner).
playlists, err := app.queries.Playlists(c.Context(), dbv1.PlaylistsParams{
GetPlaylistsParams: dbv1.GetPlaylistsParams{
Ids: ids,
MyID: myId,
Ids: ids,
MyID: myId,
IncludePrivate: true,
},
OmitTracks: true,
AuthedWallet: app.tryGetAuthedWallet(c),
Expand Down
Loading