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
90 changes: 84 additions & 6 deletions api/auth_middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -349,12 +349,28 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {

// Not authorized to act on behalf of myId.
//
// Exception: /users/:userId/feed/for-you accepts user_id as a viewer hint
// used only for response decoration (has_current_user_reposted etc.); the
// path :userId — not user_id — controls what gets personalized. Treat the
// query user_id as advisory rather than authoritative on this route so
// the endpoint can be called like the other public read endpoints.
allowUnauthenticatedViewerId := strings.HasSuffix(c.Path(), "/feed/for-you")
// Exceptions: public discovery reads where user_id is purely a
// viewer hint used for response decoration
// (has_current_user_reposted, has_current_user_saved, etc.) with no
// permission semantics tied to it. Treat the query user_id as
// advisory rather than authoritative on these routes so logged-in
// SDK clients can pass it without forging signature headers.
//
// Anything in this list MUST satisfy two conditions:
// 1. Method is GET (no writes — writes must remain authoritative).
// 2. user_id is used only to populate has_current_user_* / similar
// decoration flags. It MUST NOT control content selection,
// permission, or row visibility — that responsibility lives on
// a path :userId param or an explicit auth middleware.
//
// Follow-up: the list below is the tactical patch for the most
// affected discovery surfaces. The longer-term fix is a per-route
// opt-in marker (e.g. an `advisoryUserId` middleware attached at
// route registration) so this allowlist doesn't have to grow with
// every new public read. Tracked in api#TBD.
path := c.Path()
allowUnauthenticatedViewerId := c.Method() == fiber.MethodGet &&
isAdvisoryUserIdPath(path)

if myId != 0 && !pkceAuthed && !allowUnauthenticatedViewerId && !app.isAuthorizedRequest(c.Context(), myId, wallet) {
return fiber.NewError(
Expand Down Expand Up @@ -382,6 +398,68 @@ func (app *ApiServer) authMiddleware(c *fiber.Ctx) error {
return c.Next()
}

// isAdvisoryUserIdPath matches request paths where ?user_id is treated as a
// viewer hint for response decoration only — no permission semantics. See the
// big comment in authMiddleware for the contract; this matcher must stay in
// sync with it.
//
// All entries are conceptual route patterns rewritten as a literal path-match
// or a small predicate. Anything load-bearing on user_id (e.g. /me, /account,
// any write) is intentionally absent.
func isAdvisoryUserIdPath(path string) bool {
// Normalize: drop the /v1 prefix so the matcher is version-agnostic.
stripped := strings.TrimPrefix(path, "/v1")

switch stripped {
case "/playlists",
"/playlists/trending",
"/playlists/top",
"/playlists/new-releases",
"/playlists/by_permalink",
"/playlists/search",
"/tracks",
"/tracks/trending",
"/tracks/recommended",
"/tracks/trending/ids",
"/users",
"/users/search",
"/users/top",
"/users/genre/top":
return true
}

// /users/:userId/feed/for-you and other /feed/for-you variants.
if strings.HasSuffix(stripped, "/feed/for-you") {
return true
}

// Dynamic single-resource reads: /playlists/<id>, /tracks/<id>,
// /users/<id>, /users/handle/<handle>. These are decorative — the path
// param controls the resource; user_id only personalizes flags.
segs := strings.Split(strings.TrimPrefix(stripped, "/"), "/")
if len(segs) >= 2 {
switch segs[0] {
case "playlists", "tracks":
// /playlists/<id>, /tracks/<id> — but NOT /playlists/<id>/stream
// etc. (those endpoints may have their own semantics). Match
// only the two-segment case here; sub-resources stay strict.
if len(segs) == 2 {
return true
}
case "users":
// /users/<id>, /users/handle/<handle> — single user fetch.
// Their sub-resource reads (/users/<id>/tracks, /followers,
// etc.) are NOT in this tactical exemption; if needed, add
// them explicitly.
if len(segs) == 2 || (len(segs) == 3 && segs[1] == "handle") {
return true
}
}
}

return false
}

// Middleware to require auth for the userId in the route params
// Returns a 403 if the authedWallet is not authorized to act on behalf of the userId
// Should be placed after authMiddleware
Expand Down
55 changes: 55 additions & 0 deletions api/auth_middleware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,58 @@ func base64Encode(s string) string {
func base64EncodeBytes(b []byte) string {
return base64.StdEncoding.EncodeToString(b)
}

// TestIsAdvisoryUserIdPath documents the contract for which public read
// surfaces treat ?user_id as advisory (decoration only) vs authoritative
// (permission/selection). The list MUST stay narrow — anything that
// materially uses user_id beyond decoration should NOT be marked advisory.
func TestIsAdvisoryUserIdPath(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Documented exempt — viewer hint only.
{"/v1/playlists/trending", true},
{"/v1/playlists/top", true},
{"/v1/playlists/new-releases", true},
{"/v1/playlists/by_permalink", true},
{"/v1/playlists/search", true},
{"/v1/tracks/trending", true},
{"/v1/tracks/recommended", true},

// Single-resource reads — :id is the resource selector; user_id
// only personalizes has_current_user_*.
{"/v1/playlists/abc123", true},
{"/v1/tracks/def456", true},
{"/v1/users/oaM5J", true},
{"/v1/users/handle/somebody", true},

// For You — the canonical example.
{"/v1/users/oaM5J/feed/for-you", true},

// NOT exempt — authoritative on myId. /me derives "who is the
// caller" from user_id, so impersonation here would be a
// security hole. (See big comment in authMiddleware.)
{"/v1/me", false},
{"/v1/oauth/me", false},
{"/v1/users/account/0xabc", false},

// NOT exempt — sub-resource reads that this tactical patch
// doesn't claim coverage for. They may need to be added in a
// follow-up; for now they stay strict (which is the existing
// pre-patch behavior).
{"/v1/playlists/abc/tracks", false},
{"/v1/users/oaM5J/tracks", false},
{"/v1/users/oaM5J/followers", false},

// Random paths should never match.
{"/v1/notifications", false},
{"/v1/", false},
{"/", false},
}

for _, tc := range cases {
got := isAdvisoryUserIdPath(tc.path)
assert.Equalf(t, tc.want, got, "isAdvisoryUserIdPath(%q)", tc.path)
}
}
11 changes: 10 additions & 1 deletion api/dbv1/get_events.sql.go

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

9 changes: 8 additions & 1 deletion api/dbv1/queries/get_events.sql
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ SELECT
e.is_deleted AS is_deleted,
e.created_at AS created_at,
e.updated_at AS updated_at,
e.event_data AS event_data
e.event_data AS event_data,
CASE
WHEN er.slug IS NOT NULL AND u.handle_lc IS NOT NULL
THEN '/' || u.handle_lc || '/contest/' || er.slug
ELSE NULL
END AS permalink
FROM events e
LEFT JOIN tracks t ON t.track_id = e.entity_id AND t.is_current = true AND e.entity_type = 'track'
AND t.access_authorities IS NULL
LEFT JOIN event_routes er ON er.event_id = e.event_id AND er.is_current = true
LEFT JOIN users u ON u.user_id = e.user_id AND u.is_current = true
WHERE
(@entity_ids::int[] = '{}' OR e.entity_id = ANY(@entity_ids::int[]))
AND (@event_ids::int[] = '{}' OR e.event_id = ANY(@event_ids::int[]))
Expand Down
1 change: 1 addition & 0 deletions api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func testAppWithFixtures(t *testing.T) *ApiServer {
database.SeedTable(app.pool.Replicas[0], "associated_wallets", testdata.ConnectedWallets)
database.SeedTable(app.pool.Replicas[0], "developer_apps", testdata.DeveloperApps)
database.SeedTable(app.pool.Replicas[0], "events", testdata.Events)
database.SeedTable(app.pool.Replicas[0], "event_routes", testdata.EventRoutes)
database.SeedTable(app.pool.Replicas[0], "follows", testdata.Follows)
database.SeedTable(app.pool.Replicas[0], "grants", testdata.Grants)
database.SeedTable(app.pool.Replicas[0], "playlists", testdata.Playlists)
Expand Down
2 changes: 2 additions & 0 deletions api/swagger/swagger-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11981,6 +11981,8 @@ components:
type: array
items:
$ref: "#/components/schemas/event"
related:
$ref: "#/components/schemas/remix_contests_related"
remix_contests_related:
type: object
properties:
Expand Down
10 changes: 10 additions & 0 deletions api/testdata/event_fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,13 @@ var Events = []map[string]any{
"is_deleted": false,
},
}

// EventRoutes seeds event_routes rows that match the Events fixtures above.
// slug is keyed by event_id so tests can assert permalink construction.
var EventRoutes = []map[string]any{
{"event_id": 1, "owner_id": 200, "slug": "summer-remix-contest"},
{"event_id": 2, "owner_id": 200, "slug": "live-at-the-venue"},
{"event_id": 4, "owner_id": 200, "slug": "fall-remix-contest"},
{"event_id": 5, "owner_id": 200, "slug": "live-fall-show"},
{"event_id": 6, "owner_id": 201, "slug": "indie-remix-contest"},
}
60 changes: 60 additions & 0 deletions api/v1_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"api.audius.co/api/dbv1"
"api.audius.co/trashid"
"github.com/gofiber/fiber/v2"
"github.com/jackc/pgx/v5"
)

type GetEventsParams struct {
Expand Down Expand Up @@ -54,7 +55,66 @@ func (app *ApiServer) v1Events(c *fiber.Ctx) error {
data = append(data, app.queries.ToFullEvent(event))
}

// Compute per-contest entry counts so consumers that resolve a contest via
// this endpoint (the track-page contest section, cold/deep-linked contest
// pages, web Explore's featured contests) can prime
// useRemixesCount({ isContestEntry: true }) instead of firing a separate
// /tracks/{id}/remixes?only_contest_entries=true&limit=0 per card. Mirrors
// the entry-count filter in v1EventsRemixContests: a child track is an entry
// iff it was created after the contest started, before its end_date, and is
// currently listed. Only remix_contest events on track entities have a
// meaningful entry count.
entryCounts := map[string]int64{}
contestEventIds := []int32{}
for _, event := range recentEvents {
if event.EventType == dbv1.EventTypeRemixContest &&
event.EntityType == dbv1.EventEntityTypeTrack &&
event.EntityID.Valid {
contestEventIds = append(contestEventIds, event.EventID)
// Default to 0 so the UI primes a definitive "no entries" and
// still skips the count-only request for empty contests.
entryCounts[trashid.MustEncodeHashID(int(event.EntityID.Int32))] = 0
}
}

if len(contestEventIds) > 0 {
countSql := `
SELECT e.entity_id, COUNT(DISTINCT ct.track_id) AS entry_count
FROM events e
JOIN remixes rm ON rm.parent_track_id = e.entity_id
JOIN tracks ct ON ct.track_id = rm.child_track_id
WHERE e.event_id = ANY(@event_ids)
AND ct.is_current = true
AND ct.is_delete = false
AND ct.is_unlisted = false
AND ct.created_at > e.created_at
AND (e.end_date IS NULL OR ct.created_at < e.end_date)
GROUP BY e.entity_id;
`
rows, err := app.pool.Query(c.Context(), countSql, pgx.NamedArgs{
"event_ids": contestEventIds,
})
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var entityID int32
var entryCount int64
if err := rows.Scan(&entityID, &entryCount); err != nil {
return err
}
entryCounts[trashid.MustEncodeHashID(int(entityID))] = entryCount
}
if err := rows.Err(); err != nil {
return err
}
}

return c.JSON(fiber.Map{
"data": data,
"related": fiber.Map{
"entry_counts": entryCounts,
},
})
}
7 changes: 7 additions & 0 deletions api/v1_events_remix_contests.go
Original file line number Diff line number Diff line change
Expand Up @@ -103,10 +103,16 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
e.created_at,
e.updated_at,
e.event_data,
CASE
WHEN er.slug IS NOT NULL AND u.handle_lc IS NOT NULL
THEN '/' || u.handle_lc || '/contest/' || er.slug
ELSE NULL
END AS permalink,
COALESCE(ec.entry_count, 0) AS entry_count
FROM events e
JOIN users u ON u.user_id = e.user_id
AND u.is_current = true
LEFT JOIN event_routes er ON er.event_id = e.event_id AND er.is_current = true
LEFT JOIN tracks t ON t.track_id = e.entity_id
AND t.is_current = true
AND e.entity_type = 'track'
Expand Down Expand Up @@ -174,6 +180,7 @@ func (app *ApiServer) v1EventsRemixContests(c *fiber.Ctx) error {
&row.CreatedAt,
&row.UpdatedAt,
&row.EventData,
&row.Permalink,
&entryCount,
); err != nil {
return err
Expand Down
Loading
Loading