fix(auth,download): playlists/likes 401, suppression downloads, 429 formats
CI / build-and-test (push) Failing after 8m20s
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:
+94
-28
@@ -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 : [];
|
||||
|
||||
Reference in New Issue
Block a user