fix(auth,download): playlists/likes 401, suppression downloads, 429 formats
CI / build-and-test (push) Failing after 8m20s

- likes + playlists: authMiddlewareCookieAware (cookies + Bearer) au lieu
  du Bearer seul -> corrige 401 Unauthorized meme connecte (creation
  playlist, ajout video, bouton J'aime)
- add-to-playlist/like-button/search-box: retire HttpClientModule local
  qui court-circuitait les intercepteurs fonctionnels
- intercepteur: refresh+retry sur 401 meme sans token en memoire (F5)
- UserService: apiBase() au lieu de /proxy/api en dur (prod :4000)
- J'aime: envoie titre/miniature, erreur visible au lieu de bouton mort
- download: seaux separes (lectures 120/min, ecritures 30/min, formats
  30/min) + reponses limiter en JSON rate_limited + Retry-After
- formats: cache memoire 10 min + ytdlpNetOpts + retry front avec backoff
- polling allege (downloads 5s, suivi job 4s, 429 transitoire non fatal)
- erreurs FR precises (http-error.util): 401/403/404/410/429/quota/500
  avec compte a rebours + bouton Reessayer
This commit is contained in:
2026-09-26 09:26:44 -04:00
parent 2db1f2deee
commit d5ceab7115
11 changed files with 482 additions and 73 deletions
+94 -28
View File
@@ -492,13 +492,67 @@ const loginLimiter = rateLimit({
legacyHeaders: false,
});
const downloadLimiter = rateLimit({
windowMs: 60 * 1000, // 1 min
max: 15,
/**
* Réponse JSON (et non texte brut) quand un rate-limiter se déclenche, pour
* que le front puisse afficher un message FR précis avec compte à rebours.
*/
function jsonLimitHandler(req, res, _next, options) {
let retryAfterSec = 60;
try {
const resetMs = req?.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).getTime() : 0;
if (resetMs > Date.now()) retryAfterSec = Math.max(1, Math.ceil((resetMs - Date.now()) / 1000));
else if (options?.windowMs) retryAfterSec = Math.max(1, Math.ceil(options.windowMs / 1000));
} catch {}
try { res.set('Retry-After', String(retryAfterSec)); } catch {}
return res.status(options?.statusCode || 429).json({ error: 'rate_limited', retryAfterSec });
}
const downloadReadLimiter = rateLimit({
windowMs: 60 * 1000, // polling légitime des jobs (2-5 s) : seau large
max: 120,
standardHeaders: true,
legacyHeaders: false,
handler: jsonLimitHandler,
});
const downloadWriteLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
handler: jsonLimitHandler,
});
const downloadFormatsLimiter = rateLimit({
windowMs: 60 * 1000,
max: 30,
standardHeaders: true,
legacyHeaders: false,
handler: jsonLimitHandler,
});
/** Cache mémoire des listes de formats (un dump yt-dlp = 5-15 s + quota YouTube). */
const formatsCache = new Map(); // key -> { ts, data }
const FORMATS_CACHE_TTL_MS = 10 * 60 * 1000;
const FORMATS_CACHE_MAX = 200;
function formatsCacheGet(key) {
const hit = formatsCache.get(key);
if (!hit) return null;
if (Date.now() - hit.ts > FORMATS_CACHE_TTL_MS) { formatsCache.delete(key); return null; }
// LRU : rejoue l'entrée en fin de Map
formatsCache.delete(key);
formatsCache.set(key, hit);
return hit.data;
}
function formatsCacheSet(key, data) {
if (formatsCache.has(key)) formatsCache.delete(key);
formatsCache.set(key, { ts: Date.now(), data });
while (formatsCache.size > FORMATS_CACHE_MAX) {
const oldest = formatsCache.keys().next().value;
formatsCache.delete(oldest);
}
}
// Rate limiter for Rumble scraping to prevent being blocked
const rumbleLimiter = rateLimit({
windowMs: 60 * 1000, // 1 min
@@ -1173,11 +1227,13 @@ r.get('/details/:provider/:videoId', async (req, res) => {
}
});
// Download routes middleware (auth supports both Authorization header and cookies)
r.use('/download', authMiddlewareCookieAware, downloadLimiter);
// Download routes middleware (auth supports both Authorization header and cookies).
// Les lectures (polling jobs) et écritures ont des seaux séparés : le polling
// ne doit jamais affamer les suppressions ni les listes de formats.
r.use('/download', authMiddlewareCookieAware);
// List available formats for a given video
r.get('/download/:provider/:videoId/formats', async (req, res) => {
// List available formats for a given video (cache 10 min : un dump = 5-15 s)
r.get('/download/:provider/:videoId/formats', downloadFormatsLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
@@ -1186,19 +1242,24 @@ r.get('/download/:provider/:videoId/formats', async (req, res) => {
const instance = req.query.instance || undefined;
const slug = req.query.slug || undefined;
const sourceUrl = req.query.sourceUrl || undefined;
const cacheKey = `formats:${provider}:${videoId}:${instance || ''}:${slug || ''}:${sourceUrl || ''}`;
const cached = formatsCacheGet(cacheKey);
if (cached) return res.json(cached);
const url = providerUrlFrom(provider, videoId, { instance, slug, sourceUrl });
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true });
const raw = await youtubedl(url, { dumpSingleJson: true, noWarnings: true, noCheckCertificates: true, skipDownload: true, ...ytdlpNetOpts() });
const meta = (typeof raw === 'string') ? JSON.parse(raw || '{}') : (raw || {});
const formats = formatListFromMeta(meta);
return res.json({ url, formats, title: meta?.title || '', duration: meta?.duration || 0 });
const out = { url, formats, title: meta?.title || '', duration: meta?.duration || 0 };
formatsCacheSet(cacheKey, out);
return res.json(out);
} catch (e) {
const code = (e && e.message === 'peertube_instance_required') ? 400 : 500;
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e) });
return res.status(code).json({ error: 'formats_failed', details: String(e?.message || e).slice(0, 300) });
}
});
// Start a download job
r.post('/download/:provider/:videoId', async (req, res) => {
r.post('/download/:provider/:videoId', downloadWriteLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
if (!DOWNLOAD_ALLOWED_PROVIDERS.includes(String(provider))) {
@@ -1394,7 +1455,7 @@ r.post('/download/:provider/:videoId', async (req, res) => {
});
// List current user's download jobs (persistent queue history)
r.get('/download/jobs', (req, res) => {
r.get('/download/jobs', downloadReadLimiter, (req, res) => {
try {
const userId = req.user?.id || 'anonymous';
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
@@ -1424,7 +1485,7 @@ function loadOwnedJob(req) {
}
// Retry a failed/interrupted job (reprise)
r.post('/download/jobs/:id/retry', async (req, res) => {
r.post('/download/jobs/:id/retry', downloadWriteLimiter, async (req, res) => {
try {
const { row, error } = loadOwnedJob(req);
if (error === 'not_found' || error === 'forbidden') return res.status(error === 'forbidden' ? 403 : 404).json({ error });
@@ -1568,7 +1629,7 @@ r.post('/download/jobs/:id/retry', async (req, res) => {
});
// Job status — DB first (persistent, survives restarts), memory fallback
r.get('/download/jobs/:id', (req, res) => {
r.get('/download/jobs/:id', downloadReadLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
@@ -1587,7 +1648,7 @@ r.get('/download/jobs/:id', (req, res) => {
});
// Stream the file (supports Range) — path resolved from the DB row (owner only)
r.get('/download/jobs/:id/file', (req, res) => {
r.get('/download/jobs/:id/file', downloadReadLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
@@ -1626,7 +1687,7 @@ r.get('/download/jobs/:id/file', (req, res) => {
});
// Cancel a job (owner only) — also removes the DB row
r.delete('/download/jobs/:id', (req, res) => {
r.delete('/download/jobs/:id', downloadWriteLimiter, (req, res) => {
const { id } = req.params;
const { row, error } = loadOwnedJob(req);
if (error === 'forbidden') return res.status(403).json({ error });
@@ -1875,14 +1936,17 @@ r.delete('/user/history/watch', authMiddleware, (req, res) => {
});
// --- Likes ---
r.get('/user/likes', authMiddleware, (req, res) => {
// NOTE : cookie-aware (et non Bearer seul) : le front s'authentifie via
// cookies httpOnly + Bearer en mémoire (perdu au F5) ; exiger le Bearer
// seul renvoyait 401 « Unauthorized » même connecté.
r.get('/user/likes', authMiddlewareCookieAware, (req, res) => {
const limit = Math.min(500, Number(req.query.limit || 100));
const q = typeof req.query.q === 'string' ? req.query.q : undefined;
const rows = listLikedVideos({ userId: req.user.id, limit, q });
return res.json(rows);
});
r.post('/user/likes', authMiddleware, async (req, res) => {
r.post('/user/likes', authMiddlewareCookieAware, async (req, res) => {
let { provider, videoId, title, thumbnail } = req.body || {};
try {
console.log('[POST /user/likes] payload:', {
@@ -1913,7 +1977,7 @@ r.post('/user/likes', authMiddleware, async (req, res) => {
return res.status(201).json(row);
});
r.delete('/user/likes', authMiddleware, (req, res) => {
r.delete('/user/likes', authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
@@ -1922,7 +1986,7 @@ r.delete('/user/likes', authMiddleware, (req, res) => {
});
// Like status for a specific video
r.get('/user/likes/status', authMiddleware, (req, res) => {
r.get('/user/likes/status', authMiddlewareCookieAware, (req, res) => {
const provider = req.query.provider ? String(req.query.provider) : '';
const videoId = req.query.videoId ? String(req.query.videoId) : '';
if (!provider || !videoId) return res.status(400).json({ error: 'provider and videoId are required' });
@@ -2716,8 +2780,10 @@ app.listen(PORT, () => {
});
// --- Playlists ---
// NOTE : cookie-aware (et non Bearer seul) : voir commentaire section Likes.
// Sans cela, création/ajout renvoyait 401 « Unauthorized » même connecté.
// Create a new playlist
r.post('/playlists', authMiddleware, (req, res) => {
r.post('/playlists', authMiddlewareCookieAware, (req, res) => {
try {
const { title, description, thumbnail, isPrivate } = req.body || {};
if (!title || String(title).trim().length === 0) {
@@ -2733,7 +2799,7 @@ r.post('/playlists', authMiddleware, (req, res) => {
});
// List current user's playlists (pagination + search)
r.get('/playlists', authMiddleware, (req, res) => {
r.get('/playlists', authMiddlewareCookieAware, (req, res) => {
try {
const limit = Math.min(200, Math.max(1, Number(req.query.limit || 50)));
const offset = Math.max(0, Number(req.query.offset || 0));
@@ -2746,7 +2812,7 @@ r.get('/playlists', authMiddleware, (req, res) => {
});
// Get playlist details (owner only for now)
r.get('/playlists/:id', authMiddleware, (req, res) => {
r.get('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const pl = getPlaylistRaw(id);
@@ -2762,7 +2828,7 @@ r.get('/playlists/:id', authMiddleware, (req, res) => {
});
// Update a playlist (title/description/thumbnail/isPrivate)
r.put('/playlists/:id', authMiddleware, (req, res) => {
r.put('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const patch = req.body || {};
@@ -2776,7 +2842,7 @@ r.put('/playlists/:id', authMiddleware, (req, res) => {
});
// Delete a playlist
r.delete('/playlists/:id', authMiddleware, (req, res) => {
r.delete('/playlists/:id', authMiddlewareCookieAware, (req, res) => {
try {
const id = String(req.params.id || '');
const result = deletePlaylist({ userId: req.user.id, id });
@@ -2789,7 +2855,7 @@ r.delete('/playlists/:id', authMiddleware, (req, res) => {
});
// Add a video to a playlist (enrich title/thumbnail if missing)
r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
r.post('/playlists/:id/videos', authMiddlewareCookieAware, async (req, res) => {
try {
const playlistId = String(req.params.id || '');
let { provider, videoId, title, thumbnail, sourceUrl, slug, instance } = req.body || {};
@@ -2822,7 +2888,7 @@ r.post('/playlists/:id/videos', authMiddleware, async (req, res) => {
});
// Remove a video from a playlist (provider required via query)
r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
r.delete('/playlists/:id/videos/:videoId', authMiddlewareCookieAware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const videoId = String(req.params.videoId || '');
@@ -2838,7 +2904,7 @@ r.delete('/playlists/:id/videos/:videoId', authMiddleware, (req, res) => {
});
// Reorder playlist items
r.put('/playlists/:id/reorder', authMiddleware, (req, res) => {
r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
try {
const playlistId = String(req.params.id || '');
const order = Array.isArray(req.body?.order) ? req.body.order : [];