fix: fetch sensitive images server-side for SSR meta tags (#479)

Link previews weren't filtering sensitive images because the moderation
data was only fetched client-side. Now we fetch it in +layout.server.ts
and pass it through to pages for SSR-safe meta tag filtering.

- Add +layout.server.ts to fetch sensitive images
- Export checkImageSensitive() utility for shared use
- Update track and artist pages to use SSR-safe checks

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

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

authored by zzstoatzz.io Claude and committed by GitHub 46013bd8 c5a70abf

Changed files
+99 -25
frontend
+45 -15
frontend/src/lib/moderation.svelte.ts
··· 7 7 urls: Set<string>; 8 8 } 9 9 10 + // raw data format from API (arrays, not Sets) - used for SSR 11 + export interface SensitiveImagesData { 12 + image_ids: string[]; 13 + urls: string[]; 14 + } 15 + 16 + /** 17 + * check if an image URL matches sensitive image data. 18 + * works with both Set-based (client) and array-based (SSR) data. 19 + */ 20 + export function checkImageSensitive( 21 + url: string | null | undefined, 22 + data: { image_ids: Set<string> | string[]; urls: Set<string> | string[] } 23 + ): boolean { 24 + if (!url) return false; 25 + 26 + // check full URL match 27 + const urlsHas = Array.isArray(data.urls) 28 + ? data.urls.includes(url) 29 + : data.urls.has(url); 30 + if (urlsHas) return true; 31 + 32 + // extract image_id from R2 URL patterns: 33 + // - https://pub-*.r2.dev/{image_id}.{ext} 34 + // - https://cdn.plyr.fm/images/{image_id}.{ext} 35 + const r2Match = url.match(/r2\.dev\/([^/.]+)\./); 36 + if (r2Match) { 37 + const hasR2 = Array.isArray(data.image_ids) 38 + ? data.image_ids.includes(r2Match[1]) 39 + : data.image_ids.has(r2Match[1]); 40 + if (hasR2) return true; 41 + } 42 + 43 + const cdnMatch = url.match(/\/images\/([^/.]+)\./); 44 + if (cdnMatch) { 45 + const hasCdn = Array.isArray(data.image_ids) 46 + ? data.image_ids.includes(cdnMatch[1]) 47 + : data.image_ids.has(cdnMatch[1]); 48 + if (hasCdn) return true; 49 + } 50 + 51 + return false; 52 + } 53 + 10 54 class ModerationManager { 11 55 private data = $state<SensitiveImages>({ image_ids: new Set(), urls: new Set() }); 12 56 private initialized = false; ··· 17 61 * checks both the full URL and extracts image_id from R2 URLs. 18 62 */ 19 63 isSensitive(url: string | null | undefined): boolean { 20 - if (!url) return false; 21 - 22 - // check full URL match 23 - if (this.data.urls.has(url)) return true; 24 - 25 - // extract image_id from R2 URL patterns: 26 - // - https://pub-*.r2.dev/{image_id}.{ext} 27 - // - https://cdn.plyr.fm/images/{image_id}.{ext} 28 - const r2Match = url.match(/r2\.dev\/([^/.]+)\./); 29 - if (r2Match && this.data.image_ids.has(r2Match[1])) return true; 30 - 31 - const cdnMatch = url.match(/\/images\/([^/.]+)\./); 32 - if (cdnMatch && this.data.image_ids.has(cdnMatch[1])) return true; 33 - 34 - return false; 64 + return checkImageSensitive(url, this.data); 35 65 } 36 66 37 67 async initialize(): Promise<void> {
+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 + };
+12 -4
frontend/src/routes/+layout.ts
··· 2 2 import { API_URL } from '$lib/config'; 3 3 import type { User } from '$lib/types'; 4 4 import type { Preferences } from '$lib/preferences.svelte'; 5 + import type { SensitiveImagesData } from '$lib/moderation.svelte'; 5 6 import type { LoadEvent } from '@sveltejs/kit'; 6 7 7 8 export interface LayoutData { 8 9 user: User | null; 9 10 isAuthenticated: boolean; 10 11 preferences: Preferences | null; 12 + sensitiveImages: SensitiveImagesData; 11 13 } 12 14 13 15 const DEFAULT_PREFERENCES: Preferences = { ··· 21 23 show_sensitive_artwork: false 22 24 }; 23 25 24 - export async function load({ fetch }: LoadEvent): Promise<LayoutData> { 26 + export async function load({ fetch, parent }: LoadEvent): Promise<LayoutData> { 27 + // get server-loaded data (sensitiveImages) 28 + const parentData = await parent(); 29 + 25 30 if (!browser) { 26 31 return { 27 32 user: null, 28 33 isAuthenticated: false, 29 - preferences: null 34 + preferences: null, 35 + sensitiveImages: parentData.sensitiveImages ?? { image_ids: [], urls: [] } 30 36 }; 31 37 } 32 38 ··· 64 70 return { 65 71 user, 66 72 isAuthenticated: true, 67 - preferences 73 + preferences, 74 + sensitiveImages: parentData.sensitiveImages ?? { image_ids: [], urls: [] } 68 75 }; 69 76 } 70 77 } catch (e) { ··· 74 81 return { 75 82 user: null, 76 83 isAuthenticated: false, 77 - preferences: null 84 + preferences: null, 85 + sensitiveImages: parentData.sensitiveImages ?? { image_ids: [], urls: [] } 78 86 }; 79 87 }
+9 -3
frontend/src/routes/track/[id]/+page.svelte
··· 9 9 import ShareButton from '$lib/components/ShareButton.svelte'; 10 10 import TagEffects from '$lib/components/TagEffects.svelte'; 11 11 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 12 - import { moderation } from '$lib/moderation.svelte'; 12 + import { checkImageSensitive } from '$lib/moderation.svelte'; 13 13 import { player } from '$lib/player.svelte'; 14 14 import { queue } from '$lib/queue.svelte'; 15 15 import { auth } from '$lib/auth.svelte'; ··· 32 32 let { data }: { data: PageData } = $props(); 33 33 34 34 let track = $state<Track>(data.track); 35 + 36 + // SSR-safe sensitive image check using server-loaded data 37 + function isImageSensitiveSSR(url: string | null | undefined): boolean { 38 + if (!data.sensitiveImages) return false; 39 + return checkImageSensitive(url, data.sensitiveImages); 40 + } 35 41 36 42 // comments state - assume enabled until we know otherwise 37 43 let comments = $state<Comment[]>([]); ··· 320 326 {#if track.album} 321 327 <meta property="music:album" content="{track.album.title}" /> 322 328 {/if} 323 - {#if track.image_url && !moderation.isSensitive(track.image_url)} 329 + {#if track.image_url && !isImageSensitiveSSR(track.image_url)} 324 330 <meta property="og:image" content="{track.image_url}" /> 325 331 <meta property="og:image:secure_url" content="{track.image_url}" /> 326 332 <meta property="og:image:width" content="1200" /> ··· 339 345 name="twitter:description" 340 346 content="{track.artist}{track.album ? ` • ${track.album.title}` : ''}" 341 347 /> 342 - {#if track.image_url && !moderation.isSensitive(track.image_url)} 348 + {#if track.image_url && !isImageSensitiveSSR(track.image_url)} 343 349 <meta name="twitter:image" content="{track.image_url}" /> 344 350 {/if} 345 351
+9 -3
frontend/src/routes/u/[handle]/+page.svelte
··· 8 8 import ShareButton from '$lib/components/ShareButton.svelte'; 9 9 import Header from '$lib/components/Header.svelte'; 10 10 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 11 - import { moderation } from '$lib/moderation.svelte'; 11 + import { checkImageSensitive } from '$lib/moderation.svelte'; 12 12 import { player } from '$lib/player.svelte'; 13 13 import { queue } from '$lib/queue.svelte'; 14 14 import { auth } from '$lib/auth.svelte'; ··· 18 18 19 19 // receive server-loaded data 20 20 let { data }: { data: PageData } = $props(); 21 + 22 + // SSR-safe sensitive image check using server-loaded data 23 + function isImageSensitiveSSR(url: string | null | undefined): boolean { 24 + if (!data.sensitiveImages) return false; 25 + return checkImageSensitive(url, data.sensitiveImages); 26 + } 21 27 22 28 // use server-loaded data directly 23 29 const artist = $derived(data.artist); ··· 169 175 /> 170 176 <meta property="og:site_name" content={APP_NAME} /> 171 177 <meta property="profile:username" content="{data.artist.handle}" /> 172 - {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 178 + {#if data.artist.avatar_url && !isImageSensitiveSSR(data.artist.avatar_url)} 173 179 <meta property="og:image" content="{data.artist.avatar_url}" /> 174 180 <meta property="og:image:secure_url" content="{data.artist.avatar_url}" /> 175 181 <meta property="og:image:width" content="400" /> ··· 184 190 name="twitter:description" 185 191 content="@{data.artist.handle} on {APP_NAME}" 186 192 /> 187 - {#if data.artist.avatar_url && !moderation.isSensitive(data.artist.avatar_url)} 193 + {#if data.artist.avatar_url && !isImageSensitiveSSR(data.artist.avatar_url)} 188 194 <meta name="twitter:image" content="{data.artist.avatar_url}" /> 189 195 {/if} 190 196 {/if}