mirror of
https://github.com/R0m1k3/podcastic.git
synced 2026-10-11 17:26:20 +02:00
feat: proxy audio backend pour Web Audio API sans CORS
Nouveau endpoint /api/audio/proxy?url=... re-stream l'audio externe avec Access-Control-Allow-Origin:*. Fallback: direct → proxy → no-cors. Analyser actif en mode proxy → vraie synchronisation waveform/son.
This commit is contained in:
1 parent
59ecd7a747
commit
c86887f037
3 files changed
+84
-7
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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;
|
||||
@@ -48,8 +48,8 @@ export function AudioProvider({ children }: { children: ReactNode }) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [userId, setUserId] = useState<string | null>(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 }) {
|
||||
<audio
|
||||
key={`${currentEpisode._id}-${corsMode}`}
|
||||
ref={audioRef}
|
||||
src={currentEpisode.audioUrl}
|
||||
crossOrigin={corsMode === 'cors' ? 'anonymous' : undefined}
|
||||
src={corsMode === 'proxy'
|
||||
? `/api/audio/proxy?url=${encodeURIComponent(currentEpisode.audioUrl)}`
|
||||
: currentEpisode.audioUrl}
|
||||
crossOrigin={corsMode !== 'no-cors' ? 'anonymous' : undefined}
|
||||
onTimeUpdate={() => audioRef.current && setCurrentTime(audioRef.current.currentTime)}
|
||||
onLoadedMetadata={() => {
|
||||
if (!audioRef.current) return;
|
||||
|
||||
Reference in new issue
Block a user