fix: allow supporters to auto-download gated tracks they like (#643)

the `gated` field is viewer-resolved (true = no access, false = has access).
previously, auto-download would attempt for all tracks then fail silently.
now we pass `gated` through the like flow and only skip when gated === true.

- supporters (gated === false): download proceeds normally
- non-supporters (gated === true): download skipped client-side

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>

authored by zzstoatzz.io Claude Opus 4.5 and committed by GitHub fb913f05 45ac213e

Changed files
+70 -6
docs
frontend
+53
docs/security.md
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 ## CORS 28 29 Cross-Origin Resource Sharing (CORS) is configured to allow:
··· 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 27 + ## Supporter-Gated Content 28 + 29 + Tracks with `support_gate` set require atprotofans supporter validation before streaming. 30 + 31 + ### Access Model 32 + 33 + ``` 34 + request → /audio/{file_id} → check support_gate 35 + 36 + ┌──────────┴──────────┐ 37 + ↓ ↓ 38 + public gated track 39 + ↓ ↓ 40 + 307 → R2 CDN validate_supporter() 41 + 42 + ┌──────────┴──────────┐ 43 + ↓ ↓ 44 + is supporter not supporter 45 + ↓ ↓ 46 + presigned URL (5min) 402 error 47 + ``` 48 + 49 + ### Storage Architecture 50 + 51 + - **public bucket**: `plyr-audio` - CDN-backed, public read access 52 + - **private bucket**: `plyr-audio-private` - no public access, presigned URLs only 53 + 54 + when `support_gate` is toggled, a background task moves the file between buckets. 55 + 56 + ### Presigned URL Behavior 57 + 58 + presigned URLs are time-limited (5 minutes) and grant direct R2 access. security considerations: 59 + 60 + 1. **URL sharing**: a supporter could share the presigned URL. mitigation: short TTL, URLs expire quickly. 61 + 62 + 2. **offline caching**: if a supporter downloads content (via "download liked tracks"), the cached audio persists locally even if support lapses. this is **intentional** - they legitimately accessed it when authorized. 63 + 64 + 3. **auto-download + gated tracks**: the `gated` field is viewer-resolved (true = no access, false = has access). when liking a track with auto-download enabled: 65 + - **supporters** (`gated === false`): download proceeds normally via presigned URL 66 + - **non-supporters** (`gated === true`): download is skipped client-side to avoid wasted 402 requests 67 + 68 + ### ATProto Record Behavior 69 + 70 + when a track is gated, the ATProto `fm.plyr.track` record's `audioUrl` changes: 71 + - **public**: points to R2 CDN URL (e.g., `https://cdn.plyr.fm/audio/abc123.mp3`) 72 + - **gated**: points to API endpoint (e.g., `https://api.plyr.fm/audio/abc123`) 73 + 74 + this means ATProto clients cannot stream gated content without authentication through plyr.fm's API. 75 + 76 + ### Validation Caching 77 + 78 + currently, `validate_supporter()` makes a fresh call to atprotofans on every request. for high-traffic gated tracks, consider adding a short TTL cache (e.g., 60s in redis) to reduce latency and avoid rate limits. 79 + 80 ## CORS 81 82 Cross-Origin Resource Sharing (CORS) is configured to allow:
+3 -1
frontend/src/lib/components/AddToMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked?: boolean; 14 disabled?: boolean; 15 disabledReason?: string; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 initialLiked = false, 29 disabled = false, 30 disabledReason, ··· 102 103 try { 104 const success = liked 105 - ? await likeTrack(trackId, fileId) 106 : await unlikeTrack(trackId); 107 108 if (!success) {
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked?: boolean; 15 disabled?: boolean; 16 disabledReason?: string; ··· 26 trackUri, 27 trackCid, 28 fileId, 29 + gated, 30 initialLiked = false, 31 disabled = false, 32 disabledReason, ··· 104 105 try { 106 const success = liked 107 + ? await likeTrack(trackId, fileId, gated) 108 : await unlikeTrack(trackId); 109 110 if (!success) {
+3 -2
frontend/src/lib/components/LikeButton.svelte
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 initialLiked?: boolean; 10 disabled?: boolean; 11 disabledReason?: string; 12 onLikeChange?: (_liked: boolean) => void; 13 } 14 15 - let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 17 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 18 let liked = $derived(initialLiked); ··· 31 32 try { 33 const success = liked 34 - ? await likeTrack(trackId, fileId) 35 : await unlikeTrack(trackId); 36 37 if (!success) {
··· 6 trackId: number; 7 trackTitle: string; 8 fileId?: string; 9 + gated?: boolean; 10 initialLiked?: boolean; 11 disabled?: boolean; 12 disabledReason?: string; 13 onLikeChange?: (_liked: boolean) => void; 14 } 15 16 + let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 17 18 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 19 let liked = $derived(initialLiked); ··· 32 33 try { 34 const success = liked 35 + ? await likeTrack(trackId, fileId, gated) 36 : await unlikeTrack(trackId); 37 38 if (!success) {
+3 -1
frontend/src/lib/components/TrackActionsMenu.svelte
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 initialLiked: boolean; 14 shareUrl: string; 15 onQueue: () => void; ··· 24 trackUri, 25 trackCid, 26 fileId, 27 initialLiked, 28 shareUrl, 29 onQueue, ··· 101 102 try { 103 const success = liked 104 - ? await likeTrack(trackId, fileId) 105 : await unlikeTrack(trackId); 106 107 if (!success) {
··· 10 trackUri?: string; 11 trackCid?: string; 12 fileId?: string; 13 + gated?: boolean; 14 initialLiked: boolean; 15 shareUrl: string; 16 onQueue: () => void; ··· 25 trackUri, 26 trackCid, 27 fileId, 28 + gated, 29 initialLiked, 30 shareUrl, 31 onQueue, ··· 103 104 try { 105 const success = liked 106 + ? await likeTrack(trackId, fileId, gated) 107 : await unlikeTrack(trackId); 108 109 if (!success) {
+2
frontend/src/lib/components/TrackItem.svelte
··· 306 trackUri={track.atproto_record_uri} 307 trackCid={track.atproto_record_cid} 308 fileId={track.file_id} 309 initialLiked={track.is_liked || false} 310 disabled={!track.atproto_record_uri} 311 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 339 trackUri={track.atproto_record_uri} 340 trackCid={track.atproto_record_cid} 341 fileId={track.file_id} 342 initialLiked={track.is_liked || false} 343 shareUrl={shareUrl} 344 onQueue={handleQueue}
··· 306 trackUri={track.atproto_record_uri} 307 trackCid={track.atproto_record_cid} 308 fileId={track.file_id} 309 + gated={track.gated} 310 initialLiked={track.is_liked || false} 311 disabled={!track.atproto_record_uri} 312 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 340 trackUri={track.atproto_record_uri} 341 trackCid={track.atproto_record_cid} 342 fileId={track.file_id} 343 + gated={track.gated} 344 initialLiked={track.is_liked || false} 345 shareUrl={shareUrl} 346 onQueue={handleQueue}
+4 -2
frontend/src/lib/tracks.svelte.ts
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 - export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 137 try { 138 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 139 method: 'POST', ··· 148 tracksCache.invalidate(); 149 150 // auto-download if preference is enabled and file_id provided 151 - if (fileId && preferences.autoDownloadLiked) { 152 try { 153 const alreadyDownloaded = await isDownloaded(fileId); 154 if (!alreadyDownloaded) {
··· 133 export const tracksCache = new TracksCache(); 134 135 // like/unlike track functions 136 + // gated: true means viewer lacks access (non-supporter), false means accessible 137 + export async function likeTrack(trackId: number, fileId?: string, gated?: boolean): Promise<boolean> { 138 try { 139 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 140 method: 'POST', ··· 149 tracksCache.invalidate(); 150 151 // auto-download if preference is enabled and file_id provided 152 + // skip download only if track is gated AND viewer lacks access (gated === true) 153 + if (fileId && preferences.autoDownloadLiked && gated !== true) { 154 try { 155 const alreadyDownloaded = await isDownloaded(fileId); 156 if (!alreadyDownloaded) {
+2
frontend/src/routes/track/[id]/+page.svelte
··· 491 trackTitle={track.title} 492 trackUri={track.atproto_record_uri} 493 trackCid={track.atproto_record_cid} 494 initialLiked={track.is_liked || false} 495 shareUrl={shareUrl} 496 onQueue={addToQueue}
··· 491 trackTitle={track.title} 492 trackUri={track.atproto_record_uri} 493 trackCid={track.atproto_record_cid} 494 + fileId={track.file_id} 495 + gated={track.gated} 496 initialLiked={track.is_liked || false} 497 shareUrl={shareUrl} 498 onQueue={addToQueue}