Skip to content

Commit f9992c8

Browse files
committed
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).
1 parent 7105d1d commit f9992c8

2 files changed

Lines changed: 53 additions & 3 deletions

File tree

pages/api/audio-proxy.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Server-side audio proxy for S3-hosted media files.
3+
*
4+
* In production the Django backend stores media on S3 and returns pre-signed
5+
* S3 URLs. Browser-side fetches (especially from Web Workers) fail because
6+
* the S3 bucket has no CORS policy for the frontend origin. This route
7+
* fetches on the server (Cloudflare Worker / Node dev server) where CORS
8+
* does not apply and streams the result back same-origin.
9+
*/
10+
export default async function handler(req, res) {
11+
if (req.method !== 'GET') {
12+
res.setHeader('Allow', 'GET');
13+
return res.status(405).end();
14+
}
15+
16+
const { url } = req.query;
17+
if (!url) return res.status(400).json({ error: 'Missing url parameter' });
18+
19+
// Only allow known-safe destinations
20+
let parsed;
21+
try {
22+
parsed = new URL(url);
23+
} catch {
24+
return res.status(400).json({ error: 'Invalid URL' });
25+
}
26+
27+
const allowed =
28+
parsed.hostname.endsWith('.amazonaws.com') ||
29+
parsed.hostname.endsWith('.musiccpr.org') ||
30+
parsed.hostname === 'localhost';
31+
if (!allowed) return res.status(403).json({ error: 'Domain not allowed' });
32+
33+
try {
34+
const upstream = await fetch(url);
35+
if (!upstream.ok) return res.status(upstream.status).end();
36+
37+
const arrayBuffer = await upstream.arrayBuffer();
38+
39+
res.setHeader(
40+
'Content-Type',
41+
upstream.headers.get('content-type') || 'audio/mpeg',
42+
);
43+
res.setHeader('Content-Length', arrayBuffer.byteLength);
44+
res.setHeader('Cache-Control', 'public, max-age=3600');
45+
res.send(Buffer.from(arrayBuffer));
46+
} catch (err) {
47+
console.error('audio-proxy: upstream fetch failed', err);
48+
res.status(502).json({ error: 'Upstream fetch failed' });
49+
}
50+
}

pages/courses/[slug]/[piece]/[actCategory]/[partType]/activity/[step].js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -285,11 +285,11 @@ export default function ActivityPage() {
285285
const basslineAssignment = loadedActivities
286286
? activities[piece]?.find((a) => a.part_type === 'Bassline')
287287
: null;
288-
// Convert absolute backend URL to relative /media/... path so it routes through
289-
// Next.js rewrite proxy (avoids CORS issues with cross-origin fetch/decodeAudioData)
288+
// Proxy S3 URLs through our API route so the browser (and Web Workers) can
289+
// fetch same-origin without needing S3 CORS configuration.
290290
const rawBasslineURL = basslineAssignment?.part?.sample_audio || null;
291291
const basslineURL = rawBasslineURL
292-
? rawBasslineURL.replace(/^https?:\/\/[^/]+/, '') // strip origin, keep /media/...
292+
? `/api/audio-proxy?url=${encodeURIComponent(rawBasslineURL)}`
293293
: null;
294294
const sampleTakes = (stepNumber === 3 && basslineURL) ? [{
295295
id: 'sample-bassline',

0 commit comments

Comments
 (0)