import { FastifyInstance } from 'fastify'; // ── Domain allowlist ──────────────────────────────────────────────────────── // Only these hosts may be proxied. Keeps the proxy from being used as an SSRF // vector against internal services (metadata endpoints, cloud metadata, etc.). const ALLOWED_HOSTS = new Set([ 'coverartarchive.org', 'lastfm.freetls.fastly.net', 'i.scdn.co', 'images.genius.com', 'commons.wikimedia.org', 'e.snmc.io', ]); // Wildcard suffixes — any subdomain of these is allowed. const ALLOWED_SUFFIXES = [ '.coverartarchive.org', '.musicbrainz.org', ]; function isAllowed(hostname: string): boolean { if (ALLOWED_HOSTS.has(hostname)) return true; return ALLOWED_SUFFIXES.some(suffix => hostname.endsWith(suffix)); } /** * Image proxy — fetches external artwork URLs server-side and returns them * with aggressive caching headers so the browser never re-fetches from * Discogs / Cover Art Archive on repeat page loads. */ export default async function imagesRoutes(fastify: FastifyInstance) { fastify.get('/images/proxy', async (request, reply) => { const { url } = request.query as { url?: string }; if (!url) { return reply.code(400).send({ error: 'url query parameter is required' }); } // Only proxy http(s) URLs — don't be an open proxy for file:// etc. if (!url.startsWith('http://') && !url.startsWith('https://')) { return reply.code(400).send({ error: 'Only http/https URLs are supported' }); } let parsed: URL; try { parsed = new URL(url); } catch { return reply.code(400).send({ error: 'Invalid URL' }); } if (!isAllowed(parsed.hostname)) { request.log.warn({ hostname: parsed.hostname, url }, 'Image proxy blocked — host not in allowlist'); return reply.code(403).send({ error: 'Domain not allowed' }); } try { const response = await fetch(url, { signal: AbortSignal.timeout(10_000), // Follow up to 5 redirects but validate each hop's domain redirect: 'manual', }); // Handle redirect — validate the redirect target too if (response.status >= 300 && response.status < 400) { const location = response.headers.get('location'); if (!location) { return reply.code(502).send({ error: 'Redirect with no location' }); } let redirectParsed: URL; try { redirectParsed = new URL(location); } catch { return reply.code(502).send({ error: 'Invalid redirect URL' }); } if (!isAllowed(redirectParsed.hostname)) { request.log.warn({ hostname: redirectParsed.hostname, location }, 'Image proxy blocked — redirect target not in allowlist'); return reply.code(403).send({ error: 'Redirect target not allowed' }); } // Re-fetch the redirect target const redirectResponse = await fetch(location, { signal: AbortSignal.timeout(10_000), }); if (!redirectResponse.ok) { return reply.code(redirectResponse.status).send({ error: `Upstream returned ${redirectResponse.status}` }); } const redirectBuffer = await redirectResponse.arrayBuffer(); const redirectContentType = redirectResponse.headers.get('content-type') || 'image/jpeg'; return reply .headers({ 'Content-Type': redirectContentType, 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Length': redirectBuffer.byteLength, }) .send(Buffer.from(redirectBuffer)); } if (!response.ok) { return reply.code(response.status).send({ error: `Upstream returned ${response.status}` }); } const buffer = await response.arrayBuffer(); const contentType = response.headers.get('content-type') || 'image/jpeg'; // Cache aggressively — artwork URLs are immutable (Discogs, Cover Art // Archive etc. use content-addressed paths). 1 year. return reply .headers({ 'Content-Type': contentType, 'Cache-Control': 'public, max-age=31536000, immutable', 'Content-Length': buffer.byteLength, }) .send(Buffer.from(buffer)); } catch (err: any) { if (err?.name === 'TimeoutError' || err?.code === 'UND_ERR_CONNECT_TIMEOUT') { return reply.code(504).send({ error: 'Upstream timed out' }); } request.log.error({ err, url }, 'Image proxy failed'); return reply.code(502).send({ error: 'Failed to fetch image' }); } }); }