From f9992c83c54caef5cde888d84804a5fe8c1946f8 Mon Sep 17 00:00:00 2001 From: espadonne Date: Thu, 2 Apr 2026 17:25:54 -0400 Subject: [PATCH] add server-side audio proxy for S3 media files Django doesn't serve /media/ in production (S3 storage), so the existing rewrite to the backend returned 404. Route bassline URLs through /api/audio-proxy which fetches server-side (no CORS). --- pages/api/audio-proxy.js | 50 +++++++++++++++++++ .../[partType]/activity/[step].js | 6 +-- 2 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 pages/api/audio-proxy.js diff --git a/pages/api/audio-proxy.js b/pages/api/audio-proxy.js new file mode 100644 index 00000000..d42ac3e1 --- /dev/null +++ b/pages/api/audio-proxy.js @@ -0,0 +1,50 @@ +/** + * Server-side audio proxy for S3-hosted media files. + * + * In production the Django backend stores media on S3 and returns pre-signed + * S3 URLs. Browser-side fetches (especially from Web Workers) fail because + * the S3 bucket has no CORS policy for the frontend origin. This route + * fetches on the server (Cloudflare Worker / Node dev server) where CORS + * does not apply and streams the result back same-origin. + */ +export default async function handler(req, res) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).end(); + } + + const { url } = req.query; + if (!url) return res.status(400).json({ error: 'Missing url parameter' }); + + // Only allow known-safe destinations + let parsed; + try { + parsed = new URL(url); + } catch { + return res.status(400).json({ error: 'Invalid URL' }); + } + + const allowed = + parsed.hostname.endsWith('.amazonaws.com') || + parsed.hostname.endsWith('.musiccpr.org') || + parsed.hostname === 'localhost'; + if (!allowed) return res.status(403).json({ error: 'Domain not allowed' }); + + try { + const upstream = await fetch(url); + if (!upstream.ok) return res.status(upstream.status).end(); + + const arrayBuffer = await upstream.arrayBuffer(); + + res.setHeader( + 'Content-Type', + upstream.headers.get('content-type') || 'audio/mpeg', + ); + res.setHeader('Content-Length', arrayBuffer.byteLength); + res.setHeader('Cache-Control', 'public, max-age=3600'); + res.send(Buffer.from(arrayBuffer)); + } catch (err) { + console.error('audio-proxy: upstream fetch failed', err); + res.status(502).json({ error: 'Upstream fetch failed' }); + } +} diff --git a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js index c911ddd7..b40400de 100644 --- a/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js +++ b/pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js @@ -285,11 +285,11 @@ export default function ActivityPage() { const basslineAssignment = loadedActivities ? activities[piece]?.find((a) => a.part_type === 'Bassline') : null; - // Convert absolute backend URL to relative /media/... path so it routes through - // Next.js rewrite proxy (avoids CORS issues with cross-origin fetch/decodeAudioData) + // Proxy S3 URLs through our API route so the browser (and Web Workers) can + // fetch same-origin without needing S3 CORS configuration. const rawBasslineURL = basslineAssignment?.part?.sample_audio || null; const basslineURL = rawBasslineURL - ? rawBasslineURL.replace(/^https?:\/\/[^/]+/, '') // strip origin, keep /media/... + ? `/api/audio-proxy?url=${encodeURIComponent(rawBasslineURL)}` : null; const sampleTakes = (stepNumber === 3 && basslineURL) ? [{ id: 'sample-bassline',