audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: remove SSR sensitive-images fetch (#785)

* fix: remove SSR sensitive-images fetch

the frontend SSR was fetching /moderation/sensitive-images on every
page load, hammering the backend during traffic spikes (1,179 rate
limit hits over 7 days).

the SSR fetch was premature optimization - cloudflare pages workers
make direct fetch calls to fly.io with no CDN caching layer. the
cache-control headers from PR #784 only help browser caching.

fix: remove the SSR fetch entirely. the client-side ModerationManager
singleton already has caching and fetches the data once on page load.
the "flash of sensitive content" risk is theoretical - images load
slower than a single API call, and there are only 2 flagged images.

- delete +layout.server.ts
- simplify +layout.ts
- use moderation.isSensitive() singleton in pages

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add localStorage caching for sensitive images

loads from localStorage synchronously on page load (no flash),
then refreshes from API in background and caches for next visit.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

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

authored by

nate nowack
Claude Opus 4.5
and committed by
GitHub
299ff709 df4e09e2

+69 -86
+9 -3
STATUS.md
··· 55 55 56 56 --- 57 57 58 - #### sensitive-images cache headers (PR #784, Jan 24) 58 + #### remove SSR sensitive-images fetch (PR #785, Jan 24) 59 59 60 - **added edge caching for /moderation/sensitive-images** - the frontend SSR (`+layout.server.ts`) fetches sensitive images on every page load to filter NSFW content. during traffic spikes, this exceeded the 120/minute rate limit (1,179 rate limit hits over 7 days, mostly Jan 22 spike). 60 + **eliminated unnecessary SSR fetch** - the frontend SSR (`+layout.server.ts`) was fetching `/moderation/sensitive-images` on every page load to pre-populate the client-side moderation filter. during traffic spikes, this hammered the backend (1,179 rate limit hits over 7 days). 61 61 62 - **fix**: added `Cache-Control: public, s-maxage=300, max-age=60` header to the endpoint. cloudflare edge caches for 5 minutes, browsers cache for 1 minute. sensitive images list changes rarely (only when admins flag new images), so this is safe and massively reduces backend load. 62 + **root cause**: the SSR fetch was premature optimization. cloudflare pages workers make direct fetch calls to fly.io - there's no CDN layer to cache responses. the cache-control headers we added in PR #784 only help browser caching, not SSR-to-origin requests. 63 + 64 + **fix**: removed the SSR fetch entirely. the client-side `ModerationManager` singleton already has caching and will fetch the data once on page load. the "flash of sensitive content" risk is theoretical - images load slower than a single API call completes, and there are only 2 flagged images. 65 + 66 + - deleted `+layout.server.ts` 67 + - simplified `+layout.ts` 68 + - updated pages to use `moderation.isSensitive()` singleton instead of SSR data 63 69 64 70 --- 65 71
+46 -13
frontend/src/lib/moderation.svelte.ts
··· 2 2 import { browser } from '$app/environment'; 3 3 import { API_URL } from '$lib/config'; 4 4 5 + const STORAGE_KEY = 'plyr:sensitive-images'; 6 + 5 7 interface SensitiveImages { 6 8 image_ids: Set<string>; 7 9 urls: Set<string>; 8 10 } 9 11 10 - // raw data format from API (arrays, not Sets) - used for SSR 12 + // raw data format from API/storage (arrays, not Sets) 11 13 export interface SensitiveImagesData { 12 14 image_ids: string[]; 13 15 urls: string[]; ··· 15 17 16 18 /** 17 19 * check if an image URL matches sensitive image data. 18 - * works with both Set-based (client) and array-based (SSR) data. 20 + * works with both Set-based (client) and array-based (storage) data. 19 21 */ 20 22 export function checkImageSensitive( 21 23 url: string | null | undefined, ··· 56 58 private initialized = false; 57 59 loading = $state(false); 58 60 61 + constructor() { 62 + // load from localStorage synchronously (available immediately on page load) 63 + if (browser) { 64 + this.loadFromStorage(); 65 + } 66 + } 67 + 59 68 /** 60 69 * check if an image URL is flagged as sensitive. 61 70 * checks both the full URL and extracts image_id from R2 URLs. ··· 65 74 } 66 75 67 76 /** 68 - * initialize from pre-fetched SSR data (avoids redundant API call) 77 + * load cached data from localStorage (synchronous, for immediate availability) 78 + */ 79 + private loadFromStorage(): void { 80 + try { 81 + const stored = localStorage.getItem(STORAGE_KEY); 82 + if (stored) { 83 + const parsed: SensitiveImagesData = JSON.parse(stored); 84 + this.data = { 85 + image_ids: new Set(parsed.image_ids || []), 86 + urls: new Set(parsed.urls || []) 87 + }; 88 + } 89 + } catch { 90 + // ignore parse errors, will fetch fresh data 91 + } 92 + } 93 + 94 + /** 95 + * save data to localStorage for future page loads 69 96 */ 70 - initializeFromData(ssrData: SensitiveImagesData): void { 71 - if (this.initialized) return; 72 - this.initialized = true; 73 - this.data = { 74 - image_ids: new Set(ssrData.image_ids || []), 75 - urls: new Set(ssrData.urls || []) 76 - }; 97 + private saveToStorage(data: SensitiveImagesData): void { 98 + try { 99 + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); 100 + } catch { 101 + // ignore storage errors (quota exceeded, etc) 102 + } 77 103 } 78 104 105 + /** 106 + * initialize moderation data - loads from storage immediately, 107 + * then refreshes from API in background 108 + */ 79 109 async initialize(): Promise<void> { 80 110 if (!browser || this.initialized || this.loading) return; 81 111 this.initialized = true; 112 + // refresh from API in background (storage data is already loaded in constructor) 82 113 await this.fetch(); 83 114 } 84 115 ··· 89 120 try { 90 121 const response = await fetch(`${API_URL}/moderation/sensitive-images`); 91 122 if (response.ok) { 92 - const data = await response.json(); 123 + const apiData: SensitiveImagesData = await response.json(); 93 124 this.data = { 94 - image_ids: new Set(data.image_ids || []), 95 - urls: new Set(data.urls || []) 125 + image_ids: new Set(apiData.image_ids || []), 126 + urls: new Set(apiData.urls || []) 96 127 }; 128 + // persist for next page load 129 + this.saveToStorage(apiData); 97 130 } 98 131 } catch (error) { 99 132 console.error('failed to fetch sensitive images:', error);
-24
frontend/src/routes/+layout.server.ts
··· 1 - import { API_URL } from '$lib/config'; 2 - import type { LayoutServerLoad } from './$types'; 3 - 4 - export interface SensitiveImages { 5 - image_ids: string[]; 6 - urls: string[]; 7 - } 8 - 9 - export const load: LayoutServerLoad = async ({ fetch }) => { 10 - let sensitiveImages: SensitiveImages = { image_ids: [], urls: [] }; 11 - 12 - try { 13 - const response = await fetch(`${API_URL}/moderation/sensitive-images`); 14 - if (response.ok) { 15 - sensitiveImages = await response.json(); 16 - } 17 - } catch (e) { 18 - console.error('failed to fetch sensitive images:', e); 19 - } 20 - 21 - return { 22 - sensitiveImages 23 - }; 24 - };
+3 -5
frontend/src/routes/+layout.svelte
··· 21 21 import { queue } from '$lib/queue.svelte'; 22 22 import { search } from '$lib/search.svelte'; 23 23 import { browser } from '$app/environment'; 24 - import type { LayoutData } from './$types'; 25 - 26 - let { children, data } = $props<{ children: any; data: LayoutData }>(); 24 + let { children } = $props<{ children: any }>(); 27 25 let showQueue = $state(false); 28 26 29 27 // pages that define their own <title> in svelte:head ··· 54 52 // initialize auth and preferences once on mount (not on every navigation) 55 53 // this prevents repeated /auth/me calls for unauthenticated users 56 54 onMount(async () => { 57 - // use sensitive images from SSR (avoids redundant API call) 58 - moderation.initializeFromData(data.sensitiveImages); 55 + // fetch sensitive images client-side (small payload, fast) 56 + moderation.initialize(); 59 57 60 58 // check auth status once 61 59 await auth.initialize();
+2 -14
frontend/src/routes/+layout.ts
··· 1 - import type { SensitiveImagesData } from '$lib/moderation.svelte'; 2 - import type { LoadEvent } from '@sveltejs/kit'; 3 - 4 - export interface LayoutData { 5 - sensitiveImages: SensitiveImagesData; 6 - } 7 - 8 - // auth and preferences are handled client-side via singletons 9 - // this load function just passes through server data (sensitive images) 10 - export async function load({ data }: LoadEvent): Promise<LayoutData> { 11 - return { 12 - sensitiveImages: data?.sensitiveImages ?? { image_ids: [], urls: [] } 13 - }; 14 - } 1 + // layout load - currently empty as auth/preferences/moderation are handled client-side 2 + // kept for future use if we need to pass data from server to client
+3 -9
frontend/src/routes/track/[id]/+page.svelte
··· 11 11 import TagEffects from '$lib/components/TagEffects.svelte'; 12 12 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 13 13 import LikersTooltip from '$lib/components/LikersTooltip.svelte'; 14 - import { checkImageSensitive } from '$lib/moderation.svelte'; 14 + import { moderation } from '$lib/moderation.svelte'; 15 15 import { player } from '$lib/player.svelte'; 16 16 import { queue } from '$lib/queue.svelte'; 17 17 import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; ··· 35 35 let { data }: { data: PageData } = $props(); 36 36 37 37 let track = $state<Track>(data.track); 38 - 39 - // SSR-safe sensitive image check using server-loaded data 40 - function isImageSensitiveSSR(url: string | null | undefined): boolean { 41 - if (!data.sensitiveImages) return false; 42 - return checkImageSensitive(url, data.sensitiveImages); 43 - } 44 38 45 39 // comments state - assume enabled until we know otherwise 46 40 let comments = $state<Comment[]>([]); ··· 515 509 {#if track.album} 516 510 <meta property="music:album" content="{track.album.title}" /> 517 511 {/if} 518 - {#if track.image_url && !isImageSensitiveSSR(track.image_url)} 512 + {#if track.image_url && !moderation.isSensitive(track.image_url)} 519 513 <meta property="og:image" content="{track.image_url}" /> 520 514 <meta property="og:image:secure_url" content="{track.image_url}" /> 521 515 <meta property="og:image:width" content="1200" /> ··· 534 528 name="twitter:description" 535 529 content="{track.artist}{track.album ? ` • ${track.album.title}` : ''}" 536 530 /> 537 - {#if track.image_url && !isImageSensitiveSSR(track.image_url)} 531 + {#if track.image_url && !moderation.isSensitive(track.image_url)} 538 532 <meta name="twitter:image" content="{track.image_url}" /> 539 533 {/if} 540 534
+3 -9
frontend/src/routes/u/[handle]/+page.svelte
··· 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 11 import SupporterBadge from '$lib/components/SupporterBadge.svelte'; 12 12 import RichText from '$lib/components/RichText.svelte'; 13 - import { checkImageSensitive } from '$lib/moderation.svelte'; 13 + import { moderation } from '$lib/moderation.svelte'; 14 14 import { player } from '$lib/player.svelte'; 15 15 import { queue } from '$lib/queue.svelte'; 16 16 import { auth } from '$lib/auth.svelte'; ··· 27 27 28 28 // receive server-loaded data 29 29 let { data }: { data: PageData } = $props(); 30 - 31 - // SSR-safe sensitive image check using server-loaded data 32 - function isImageSensitiveSSR(url: string | null | undefined): boolean { 33 - if (!data.sensitiveImages) return false; 34 - return checkImageSensitive(url, data.sensitiveImages); 35 - } 36 30 37 31 // use server-loaded data directly 38 32 const artist = $derived(data.artist); ··· 382 376 /> 383 377 <meta property="og:site_name" content={APP_NAME} /> 384 378 <meta property="profile:username" content="{data.artist.handle}" /> 385 - {#if data.artist.avatar_url && !isImageSensitiveSSR(data.artist.avatar_url)} 379 + {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 386 380 <meta property="og:image" content="{data.artist.avatar_url}" /> 387 381 <meta property="og:image:secure_url" content="{data.artist.avatar_url}" /> 388 382 <meta property="og:image:width" content="400" /> ··· 397 391 name="twitter:description" 398 392 content="@{data.artist.handle} on {APP_NAME}" 399 393 /> 400 - {#if data.artist.avatar_url && !isImageSensitiveSSR(data.artist.avatar_url)} 394 + {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 401 395 <meta name="twitter:image" content="{data.artist.avatar_url}" /> 402 396 {/if} 403 397 {/if}
+3 -9
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 4 4 import TrackItem from '$lib/components/TrackItem.svelte'; 5 5 import ShareButton from '$lib/components/ShareButton.svelte'; 6 6 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 7 - import { checkImageSensitive } from '$lib/moderation.svelte'; 7 + import { moderation } from '$lib/moderation.svelte'; 8 8 import { player } from '$lib/player.svelte'; 9 9 import { queue } from '$lib/queue.svelte'; 10 10 import { playQueue } from '$lib/playback.svelte'; ··· 69 69 let touchStartY = $state(0); 70 70 let touchDragElement = $state<HTMLElement | null>(null); 71 71 let tracksListElement = $state<HTMLElement | null>(null); 72 - 73 - // SSR-safe check for sensitive images (for og:image meta tags) 74 - function isImageSensitiveSSR(url: string | null | undefined): boolean { 75 - if (!url) return false; 76 - return checkImageSensitive(url, data.sensitiveImages); 77 - } 78 72 79 73 function playTrack(track: Track) { 80 74 queue.playNow(track); ··· 407 401 <meta property="og:url" content="{APP_CANONICAL_URL}/u/{albumMetadata.artist_handle}/album/{albumMetadata.slug}" /> 408 402 <meta property="og:site_name" content={APP_NAME} /> 409 403 <meta property="music:musician" content="{albumMetadata.artist_handle}" /> 410 - {#if albumMetadata.image_url && !isImageSensitiveSSR(albumMetadata.image_url)} 404 + {#if albumMetadata.image_url && !moderation.isSensitive(albumMetadata.image_url)} 411 405 <meta property="og:image" content="{albumMetadata.image_url}" /> 412 406 <meta property="og:image:secure_url" content="{albumMetadata.image_url}" /> 413 407 <meta property="og:image:width" content="1200" /> ··· 419 413 <meta name="twitter:card" content="summary" /> 420 414 <meta name="twitter:title" content="{albumMetadata.title} by {albumMetadata.artist}" /> 421 415 <meta name="twitter:description" content="{albumMetadata.track_count} tracks • {albumMetadata.total_plays} plays" /> 422 - {#if albumMetadata.image_url && !isImageSensitiveSSR(albumMetadata.image_url)} 416 + {#if albumMetadata.image_url && !moderation.isSensitive(albumMetadata.image_url)} 423 417 <meta name="twitter:image" content="{albumMetadata.image_url}" /> 424 418 {/if} 425 419 </svelte:head>