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 24 * **`X-XSS-Protection: 1; mode=block`:** Enables browser cross-site scripting filters. 25 25 * **`Referrer-Policy: strict-origin-when-cross-origin`:** Controls how much referrer information is included with requests. 26 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 + 27 80 ## CORS 28 81 29 82 Cross-Origin Resource Sharing (CORS) is configured to allow:
+3 -1
frontend/src/lib/components/AddToMenu.svelte
··· 10 10 trackUri?: string; 11 11 trackCid?: string; 12 12 fileId?: string; 13 + gated?: boolean; 13 14 initialLiked?: boolean; 14 15 disabled?: boolean; 15 16 disabledReason?: string; ··· 25 26 trackUri, 26 27 trackCid, 27 28 fileId, 29 + gated, 28 30 initialLiked = false, 29 31 disabled = false, 30 32 disabledReason, ··· 102 104 103 105 try { 104 106 const success = liked 105 - ? await likeTrack(trackId, fileId) 107 + ? await likeTrack(trackId, fileId, gated) 106 108 : await unlikeTrack(trackId); 107 109 108 110 if (!success) {
+3 -2
frontend/src/lib/components/LikeButton.svelte
··· 6 6 trackId: number; 7 7 trackTitle: string; 8 8 fileId?: string; 9 + gated?: boolean; 9 10 initialLiked?: boolean; 10 11 disabled?: boolean; 11 12 disabledReason?: string; 12 13 onLikeChange?: (_liked: boolean) => void; 13 14 } 14 15 15 - let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 + let { trackId, trackTitle, fileId, gated, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 16 17 17 18 // use overridable $derived (Svelte 5.25+) - syncs with prop but can be overridden for optimistic UI 18 19 let liked = $derived(initialLiked); ··· 31 32 32 33 try { 33 34 const success = liked 34 - ? await likeTrack(trackId, fileId) 35 + ? await likeTrack(trackId, fileId, gated) 35 36 : await unlikeTrack(trackId); 36 37 37 38 if (!success) {
+3 -1
frontend/src/lib/components/TrackActionsMenu.svelte
··· 10 10 trackUri?: string; 11 11 trackCid?: string; 12 12 fileId?: string; 13 + gated?: boolean; 13 14 initialLiked: boolean; 14 15 shareUrl: string; 15 16 onQueue: () => void; ··· 24 25 trackUri, 25 26 trackCid, 26 27 fileId, 28 + gated, 27 29 initialLiked, 28 30 shareUrl, 29 31 onQueue, ··· 101 103 102 104 try { 103 105 const success = liked 104 - ? await likeTrack(trackId, fileId) 106 + ? await likeTrack(trackId, fileId, gated) 105 107 : await unlikeTrack(trackId); 106 108 107 109 if (!success) {
+2
frontend/src/lib/components/TrackItem.svelte
··· 306 306 trackUri={track.atproto_record_uri} 307 307 trackCid={track.atproto_record_cid} 308 308 fileId={track.file_id} 309 + gated={track.gated} 309 310 initialLiked={track.is_liked || false} 310 311 disabled={!track.atproto_record_uri} 311 312 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 339 340 trackUri={track.atproto_record_uri} 340 341 trackCid={track.atproto_record_cid} 341 342 fileId={track.file_id} 343 + gated={track.gated} 342 344 initialLiked={track.is_liked || false} 343 345 shareUrl={shareUrl} 344 346 onQueue={handleQueue}
+4 -2
frontend/src/lib/tracks.svelte.ts
··· 133 133 export const tracksCache = new TracksCache(); 134 134 135 135 // like/unlike track functions 136 - export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 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> { 137 138 try { 138 139 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 139 140 method: 'POST', ··· 148 149 tracksCache.invalidate(); 149 150 150 151 // auto-download if preference is enabled and file_id provided 151 - if (fileId && preferences.autoDownloadLiked) { 152 + // skip download only if track is gated AND viewer lacks access (gated === true) 153 + if (fileId && preferences.autoDownloadLiked && gated !== true) { 152 154 try { 153 155 const alreadyDownloaded = await isDownloaded(fileId); 154 156 if (!alreadyDownloaded) {
+2
frontend/src/routes/track/[id]/+page.svelte
··· 491 491 trackTitle={track.title} 492 492 trackUri={track.atproto_record_uri} 493 493 trackCid={track.atproto_record_cid} 494 + fileId={track.file_id} 495 + gated={track.gated} 494 496 initialLiked={track.is_liked || false} 495 497 shareUrl={shareUrl} 496 498 onQueue={addToQueue}