diff --git a/backend/src/index.ts b/backend/src/index.ts index a449d42..25937c3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -11,6 +11,7 @@ import { errorHandler } from './middleware/errorHandler'; import authRoutes from './routes/authRoutes'; import podcastRoutes from './routes/podcastRoutes'; import episodeRoutes from './routes/episodeRoutes'; +import audioRoutes from './routes/audioRoutes'; const app = express(); const PORT = process.env.PORT || 5000; @@ -70,6 +71,7 @@ app.get('/api/health', (req, res) => { app.use('/api/auth', authRoutes); app.use('/api/podcasts', apiLimiter, podcastRoutes); app.use('/api/episodes', apiLimiter, episodeRoutes); +app.use('/api/audio', audioRoutes); // Serve frontend static files in production const frontendDist = path.join(__dirname, '..', 'public'); diff --git a/backend/src/routes/audioRoutes.ts b/backend/src/routes/audioRoutes.ts new file mode 100644 index 0000000..b9ce86f --- /dev/null +++ b/backend/src/routes/audioRoutes.ts @@ -0,0 +1,69 @@ +import { Router, Request, Response } from 'express'; +import https from 'https'; +import http from 'http'; + +const router = Router(); + +// Block private/loopback ranges to prevent SSRF +const BLOCKED = [ + /^127\./, + /^10\./, + /^192\.168\./, + /^172\.(1[6-9]|2\d|3[01])\./, + /^::1$/, + /^localhost$/i, +]; + +function isBlocked(hostname: string): boolean { + return BLOCKED.some(r => r.test(hostname)); +} + +router.get('/proxy', (req: Request, res: Response) => { + const raw = req.query.url as string; + if (!raw) { res.status(400).json({ message: 'url required' }); return; } + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + res.status(400).json({ message: 'Invalid URL' }); return; + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + res.status(400).json({ message: 'Protocol not allowed' }); return; + } + if (isBlocked(parsed.hostname)) { + res.status(403).json({ message: 'Forbidden' }); return; + } + + const upstream: Record = { + 'User-Agent': 'Mozilla/5.0 (compatible; Podcastic/1.0)', + 'Accept': '*/*', + }; + if (req.headers.range) upstream['Range'] = req.headers.range; + + const lib = parsed.protocol === 'https:' ? https : http; + + const proxyReq = lib.request(raw, { headers: upstream }, (proxyRes) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Expose-Headers', 'Content-Length, Content-Range, Content-Type, Accept-Ranges'); + + const forward = ['content-type', 'content-length', 'content-range', 'accept-ranges']; + for (const h of forward) { + const v = proxyRes.headers[h]; + if (v) res.setHeader(h, v); + } + + res.status(proxyRes.statusCode ?? 200); + proxyRes.pipe(res); + }); + + proxyReq.on('error', () => { + if (!res.headersSent) res.status(502).json({ message: 'Proxy error' }); + }); + + req.on('close', () => proxyReq.destroy()); + proxyReq.end(); +}); + +export default router; diff --git a/frontend/src/context/AudioContext.tsx b/frontend/src/context/AudioContext.tsx index e816ab2..8820d8c 100644 --- a/frontend/src/context/AudioContext.tsx +++ b/frontend/src/context/AudioContext.tsx @@ -48,8 +48,8 @@ export function AudioProvider({ children }: { children: ReactNode }) { const [error, setError] = useState(null); const [userId, setUserId] = useState(null); - // CORS retry: start with CORS for real frequency data, fall back if blocked - const [corsMode, setCorsMode] = useState<'cors' | 'no-cors'>('cors'); + // Audio source mode: direct CORS → backend proxy (always CORS) → give up + const [corsMode, setCorsMode] = useState<'cors' | 'proxy' | 'no-cors'>('cors'); // Refs for interval-based progress saving const currentTimeRef = useRef(0); @@ -118,9 +118,9 @@ export function AudioProvider({ children }: { children: ReactNode }) { }).catch(() => setIsResuming(false)); }, [currentEpisode?._id, userId]); - // Initialize Web Audio Analyser (only in CORS mode) + // Initialize Web Audio Analyser (CORS or proxy mode — both have CORS headers) useEffect(() => { - if (corsMode !== 'cors' || !audioRef.current || !currentEpisode) return; + if (corsMode === 'no-cors' || !audioRef.current || !currentEpisode) return; try { const AudioCtx = (window as any).AudioContext || (window as any).webkitAudioContext; @@ -212,7 +212,11 @@ export function AudioProvider({ children }: { children: ReactNode }) { const handleAudioError = () => { if (corsMode === 'cors') { - // CORS blocked the audio — retry without CORS (sound works, no real frequency data) + // Direct CORS failed — retry via backend proxy (adds CORS headers, Web Audio works) + setCorsMode('proxy'); + setError(null); + } else if (corsMode === 'proxy') { + // Proxy also failed — last resort: plain audio, no Web Audio analysis setCorsMode('no-cors'); setError(null); } else { @@ -231,8 +235,10 @@ export function AudioProvider({ children }: { children: ReactNode }) {