import express from 'express'; import helmet from 'helmet'; import cors from 'cors'; import cookieParser from 'cookie-parser'; import rateLimit from 'express-rate-limit'; import bcrypt from 'bcryptjs'; import jwt from 'jsonwebtoken'; import fs from 'node:fs'; import path from 'node:path'; import youtubedlPkg, { create as createYtDlp } from 'youtube-dl-exec'; import { execFile as execFileCb } from 'node:child_process'; import { promisify } from 'node:util'; import { fileURLToPath as serverFileURLToPath } from 'node:url'; import ffmpegPath from 'ffmpeg-static'; import * as cheerio from 'cheerio'; import axios from 'axios'; import rumbleRouter from './rumble.mjs'; import { providerRegistry, validateProviders } from './providers/registry.mjs'; import { pickTrack, parseTrackText, parseVtt, dedupeTranscriptLines, orderedTracks, translatedFallbacks, firstPerLanguage, normalizeTranscriptProvider, transcriptTrackExt, looksLikeHtmlError } from './transcript.mjs'; import { getUserByUsername, getUserById, insertUser, insertSession, getSessionById, updateSessionToken, revokeSession, revokeAllUserSessions, listUserSessions, setUserLastLogin, insertLoginAudit, getPreferences, getPreferencesForApi, upsertPreferences, insertTelemetryEvent, listTelemetryEvents, countTelemetryEvents, cryptoRandomId, cryptoRandomUUID, insertSearchHistory, listSearchHistory, deleteSearchHistoryById, deleteAllSearchHistory, upsertWatchHistory, listWatchHistory, updateWatchHistoryById, deleteWatchHistoryById, deleteAllWatchHistory, likeVideo, unlikeVideo, listLikedVideos, isVideoLiked, createPlaylist, listPlaylists, listPublicPlaylists, getPlaylistRaw, getPlaylistWithItemsIfAllowed, updatePlaylist, deletePlaylist, listPlaylistItems, addPlaylistVideo, removePlaylistVideo, reorderPlaylistVideos, ensureChannelFresh, listSubscriptionsByUser, subscribeChannel, unsubscribeChannel, isSubscribed, listSubscriptionGroups, createSubscriptionGroup, updateSubscriptionGroup, deleteSubscriptionGroup, setSubscriptionGroupMembers, setSubscriptionGroups, listSubscriptionGroupMembersByUser, insertDownloadJob, getDownloadJob, listDownloadJobs, updateDownloadJob, deleteDownloadJob, resetActiveDownloadJobs, countActiveDownloadJobs, sumCompletedDownloadBytes, SUPPORTED_DOWNLOAD_LANGUAGES as SUPPORTED_DL_LANGS, DEFAULT_DOWNLOAD_LANGUAGES as DEFAULT_DL_LANGS, } from './db.mjs'; import { getChannelAdapter, setTwitchTokenProvider } from './providers/channel-registry.mjs'; import { fetchChannelContent } from './providers/channel-content.mjs'; import { getSearchMode, getYtDlpBin, hasCookiesFile, metricsSnapshot } from './providers/youtube-common.mjs'; import { ytScrapeCacheStats } from './providers/youtube.mjs'; /** * Options réseau communes pour les appels yt-dlp : cookies YouTube exportés * (session résidentielle de confiance) + proxy sortant. Sans eux, les IPs * de datacenter se voient servir du vide/429 sur timedtext et parfois sur * les dumps. Absents par défaut -> comportement inchangé. */ function ytdlpNetOpts() { const opts = {}; try { const cookies = String(process.env.YT_COOKIES_FILE || '').trim(); if (cookies) { try { if (fs.existsSync(cookies)) opts.cookies = cookies; else console.warn(`[transcript] YT_COOKIES_FILE introuvable : ${cookies}`); } catch {} } } catch {} try { const proxy = String(process.env.YT_EGRESS_PROXY || '').trim(); if (proxy) opts.proxy = proxy; } catch {} return opts; } const app = express(); const PORT = Number(process.env.PORT || 4000); // yt-dlp: prefer the newest binary available. The copy bundled with // youtube-dl-exec goes stale (YouTube then answers "The page needs to be // reloaded" to dump-single-json), while a system install is usually fresher. // `YT_DLP_PATH` wins, then `yt-dlp` on PATH, then the bundled binary. const execFileAsync = promisify(execFileCb); async function binaryVersion(bin) { try { const { stdout } = await execFileAsync(bin, ['--version'], { timeout: 15000 }); return String(stdout || '').trim().split('\n')[0].trim(); } catch { return null; } } let youtubedl = youtubedlPkg; let ytDlpInfo = 'bundled'; try { let bundledBin = null; try { const u = new URL('../node_modules/youtube-dl-exec/bin/', import.meta.url); const cand = path.join(String(serverFileURLToPath(u)), process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp'); if (fs.existsSync(cand)) bundledBin = cand; } catch {} const bundledVer = bundledBin ? await binaryVersion(bundledBin) : null; const candidates = []; if (process.env.YT_DLP_PATH) candidates.push(process.env.YT_DLP_PATH); candidates.push(process.platform === 'win32' ? 'yt-dlp.exe' : 'yt-dlp', 'yt-dlp'); let best = null; for (const cand of candidates) { if (!cand) continue; const ver = await binaryVersion(cand); if (ver && (!best || ver > best.ver)) best = { bin: cand, ver }; } if (best && (!bundledVer || best.ver >= bundledVer)) { youtubedl = createYtDlp(best.bin); ytDlpInfo = `${best.bin} (${best.ver})`; } else if (bundledVer) { ytDlpInfo = `bundled (${bundledVer})`; } } catch (e) { console.warn('[config] yt-dlp binary selection failed, using bundled:', e?.message || e); } console.log(`[config] yt-dlp: ${ytDlpInfo}`); const IS_PROD = String(process.env.NODE_ENV || '').toLowerCase() === 'production'; const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-me'; if (!process.env.JWT_SECRET) { const msg = '[config] JWT_SECRET non défini — utilisation du secret de développement. NE PAS UTILISER EN PRODUCTION.'; if (IS_PROD) console.error(msg); else console.warn(msg); } const ACCESS_TTL_MIN = Number(process.env.ACCESS_TTL_MIN || 15); // Garde-fou : une erreur non capturée dans une route ne doit jamais tuer tout le serveur. process.on('uncaughtException', (err) => { try { console.error('[fatal] uncaughtException:', err?.stack || err); } catch {} }); process.on('unhandledRejection', (reason) => { try { console.error('[fatal] unhandledRejection:', reason); } catch {} }); const REFRESH_TTL_DAYS = Number(process.env.REFRESH_TTL_DAYS || 2); const REMEMBER_TTL_DAYS = Number(process.env.REMEMBER_TTL_DAYS || 30); const CHANNEL_TTL_MS = Number(process.env.CHANNEL_TTL_MS || (6 * 60 * 60 * 1000)); const corsOptions = { origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'], methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization'], credentials: true, maxAge: 86400 // 24h }; // Middleware de logging — verbeux uniquement hors production, sinon une ligne sobre. // Jamais de headers complets (tokens) ni de body brut (mots de passe) en prod. const SENSITIVE_FIELDS = new Set(['password', 'currentPassword', 'newPassword', 'token', 'refreshToken', 'accessToken']); function sanitizeBody(body) { if (!body || typeof body !== 'object') return body; const out = Array.isArray(body) ? [...body] : { ...body }; for (const k of Object.keys(out)) { if (SENSITIVE_FIELDS.has(k)) out[k] = '[redacted]'; } return out; } const requestLogger = (req, res, next) => { if (IS_PROD) { console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`); return next(); } console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`); console.log('Headers:', JSON.stringify({ ...req.headers, authorization: req.headers.authorization ? '[redacted]' : undefined }, null, 2)); console.log('Query:', JSON.stringify(req.query, null, 2)); console.log('Body:', JSON.stringify(sanitizeBody(req.body), null, 2)); next(); }; const subscriptionsLimiter = rateLimit({ windowMs: 60 * 1000, max: 30, standardHeaders: true, legacyHeaders: false, }); const channelsLimiter = rateLimit({ windowMs: 60 * 1000, max: 60, standardHeaders: true, legacyHeaders: false, }); const r = express.Router(); // Public: list public playlists (no auth required) r.get('/playlists/public', (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)); const q = typeof req.query.q === 'string' ? req.query.q : undefined; const rows = listPublicPlaylists({ limit, offset, q }); return res.json(rows); } catch (e) { return res.status(500).json({ error: 'list_public_failed', details: String(e?.message || e) }); } }); // -------------------- Channels APIs -------------------- function requireProviderId(value) { const allowed = ['yt','dm','tw','pt','od','ru']; if (!allowed.includes(String(value))) { throw Object.assign(new Error('invalid_provider'), { status: 400 }); } return /** @type {'yt'|'dm'|'tw'|'pt'|'od'|'ru'} */(value); } async function resolveChannel(provider, externalId, { forceRefresh = false } = {}) { const adapterEntry = getChannelAdapter(provider) || providerRegistry[provider]; if (!adapterEntry || typeof adapterEntry.fetchChannelById !== 'function') { throw Object.assign(new Error('provider_not_supported'), { status: 501 }); } return ensureChannelFresh(provider, externalId, () => adapterEntry.fetchChannelById(externalId), { force: forceRefresh }); } r.post('/channels/resolve', authMiddlewareCookieAware, channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.body?.provider); const externalId = String(req.body?.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const meta = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) }); return res.json(meta); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_resolve_failed' }); } }); // Lecture publique (logo chaîne sur /watch même déconnecté) — ne crée pas d'abonnement. r.get('/channels/:provider/:externalId', channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.params.provider); const externalId = String(req.params.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const refresh = req.query.refresh === '1' || req.query.refresh === 'true'; const meta = await resolveChannel(provider, externalId, { forceRefresh: refresh }); return res.json(meta); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_fetch_failed' }); } }); // Contenu d'une chaîne : ?type=videos|shorts|playlists|live&page=&limit=&sort=recent|popular&q= r.get('/channels/:provider/:externalId/content', channelsLimiter, async (req, res) => { try { const provider = requireProviderId(req.params.provider); const externalId = String(req.params.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const type = String(req.query.type || 'videos'); if (!['videos', 'shorts', 'playlists', 'live'].includes(type)) { return res.status(400).json({ error: 'invalid_type' }); } const page = Math.max(1, Number(req.query.page || 1)); const limit = Math.min(50, Math.max(1, Number(req.query.limit || 24))); const sort = req.query.sort === 'popular' ? 'popular' : req.query.sort === 'relevance' ? 'relevance' : 'recent'; const q = typeof req.query.q === 'string' ? req.query.q.slice(0, 200) : ''; const data = await fetchChannelContent(provider, externalId, { type, page, limit, sort, q }, { searchRegistry: providerRegistry }); return res.json({ ...data, page, limit, sort, type }); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'channel_content_failed', items: [], nextPage: null }); } }); // -------------------- Subscriptions APIs -------------------- r.get('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const items = listSubscriptionsByUser(req.user.id); return res.json({ items, ttl: CHANNEL_TTL_MS }); } catch (error) { return res.status(500).json({ error: 'subscriptions_list_failed', details: String(error?.message || error) }); } }); r.post('/subscriptions', authMiddlewareCookieAware, subscriptionsLimiter, async (req, res) => { try { const provider = requireProviderId(req.body?.provider); const externalId = String(req.body?.externalId || '').trim(); if (!externalId) return res.status(400).json({ error: 'external_id_required' }); const entity = await resolveChannel(provider, externalId, { forceRefresh: Boolean(req.body?.refresh) }); const sub = subscribeChannel({ userId: req.user.id, provider, externalId, channelId: entity?.id }); return res.status(201).json(sub); } catch (error) { const status = error?.status || 500; return res.status(status).json({ error: error?.message || 'subscription_create_failed' }); } }); r.delete('/subscriptions/:subscriptionId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const info = unsubscribeChannel({ userId: req.user.id, subscriptionId: req.params.subscriptionId }); if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' }); return res.status(204).end(); } catch (error) { return res.status(500).json({ error: 'subscription_delete_failed', details: String(error?.message || error) }); } }); // -------------------- Subscription groups APIs (façon PocketTube) -------------------- r.get('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const groups = listSubscriptionGroups(req.user.id); const members = listSubscriptionGroupMembersByUser(req.user.id); return res.json({ groups, members }); } catch (error) { return res.status(500).json({ error: 'groups_list_failed', details: String(error?.message || error) }); } }); r.post('/subscription-groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = createSubscriptionGroup({ userId: req.user.id, name: req.body?.name, color: req.body?.color, icon: req.body?.icon, }); return res.status(201).json(group); } catch (error) { const msg = error?.message || 'group_create_failed'; if (msg === 'group_name_required') return res.status(400).json({ error: msg }); if (msg === 'group_name_taken') return res.status(409).json({ error: msg }); return res.status(500).json({ error: 'group_create_failed' }); } }); r.patch('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = updateSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId, name: req.body?.name, color: req.body?.color, icon: req.body?.icon, }); if (!group) return res.status(404).json({ error: 'not_found' }); return res.json(group); } catch (error) { const msg = error?.message || 'group_update_failed'; if (msg === 'group_name_required') return res.status(400).json({ error: msg }); if (msg === 'group_name_taken') return res.status(409).json({ error: msg }); return res.status(500).json({ error: 'group_update_failed' }); } }); r.delete('/subscription-groups/:groupId', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const info = deleteSubscriptionGroup({ userId: req.user.id, groupId: req.params.groupId }); if ((info?.changes || 0) === 0) return res.status(404).json({ error: 'not_found' }); return res.status(204).end(); } catch (error) { return res.status(500).json({ error: 'group_delete_failed' }); } }); // Remplace les chaînes d'un groupe : { subscriptionIds: number[] } r.put('/subscription-groups/:groupId/members', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const group = setSubscriptionGroupMembers({ userId: req.user.id, groupId: req.params.groupId, subscriptionIds: req.body?.subscriptionIds, }); if (!group) return res.status(404).json({ error: 'not_found' }); return res.json(group); } catch (error) { return res.status(500).json({ error: 'group_members_failed' }); } }); // Remplace les groupes d'un abonnement : { groupIds: string[] } r.put('/subscriptions/:subscriptionId/groups', authMiddlewareCookieAware, subscriptionsLimiter, (req, res) => { try { const groupIds = setSubscriptionGroups({ userId: req.user.id, subscriptionId: Number(req.params.subscriptionId), groupIds: req.body?.groupIds, }); if (!groupIds) return res.status(404).json({ error: 'not_found' }); return res.json({ subscriptionId: Number(req.params.subscriptionId), groupIds }); } catch (error) { return res.status(500).json({ error: 'subscription_groups_failed' }); } }); // Public: view a playlist if allowed (owner or public). Authorization header is optional. r.get('/playlists/:id/view', (req, res) => { try { const id = String(req.params.id || ''); let viewerUserId = undefined; try { const auth = req.headers['authorization'] || ''; const [, token] = String(auth).split(' '); if (token) { const payload = jwt.verify(token, JWT_SECRET); viewerUserId = payload?.sub; } } catch {} const limit = Math.min(2000, Math.max(1, Number(req.query.limit || 500))); const offset = Math.max(0, Number(req.query.offset || 0)); const result = getPlaylistWithItemsIfAllowed({ viewerUserId, id, limit, offset }); if (result === 'forbidden') return res.status(404).json({ error: 'not_found' }); if (!result) return res.status(404).json({ error: 'not_found' }); return res.json(result); } catch (e) { return res.status(500).json({ error: 'view_failed', details: String(e?.message || e) }); } }); // Servir les fichiers statiques du dossier dist app.use(express.static(path.join(process.cwd(), 'dist'))); app.use('/assets', express.static(path.join(process.cwd(), 'assets'))); app.set('trust proxy', 1); app.use(helmet({ // Disable strict CSP for now to allow third‑party thumbnails/CDNs used by providers contentSecurityPolicy: false, // Disable COEP to avoid blocking cross‑origin resources (e.g., images/videos) crossOriginEmbedderPolicy: false, // Allow loading cross‑origin images crossOriginResourcePolicy: { policy: 'cross-origin' }, })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); app.use(cookieParser()); app.use(cors(corsOptions)); app.options('*', cors(corsOptions)); // Pré-vol CORS // Logging des requêtes app.use(requestLogger); // Routes API app.use('/api', r); // -------------------- Downloads configuration -------------------- // Downloads directory (per-user sub-directories) const downloadsRoot = path.join(process.cwd(), 'tmp', 'downloads'); if (!fs.existsSync(downloadsRoot)) { fs.mkdirSync(downloadsRoot, { recursive: true }); } // Storage quota (bytes) per user for completed downloads in the retention window. // Default: 5 GiB. Set to 0 to disable. const DOWNLOAD_STORAGE_QUOTA_BYTES = Number(process.env.DOWNLOAD_STORAGE_QUOTA_BYTES ?? (5 * 1024 * 1024 * 1024)); // Retention window (ms) for quota accounting. Default: 30 days. Set to 0 for no window. const DOWNLOAD_QUOTA_WINDOW_MS = Number(process.env.DOWNLOAD_QUOTA_WINDOW_MS ?? (30 * 24 * 60 * 60 * 1000)); function userDownloadsDir(userId) { const safeId = String(userId || 'anonymous').replace(/[^a-zA-Z0-9_-]+/g, '_').slice(0, 64) || 'anonymous'; const dir = path.join(downloadsRoot, safeId); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); return dir; } // On boot: mark jobs that were active when the API stopped as 'interrupted' // so users can retry them, and clean orphan files left by jobs that never completed. resetActiveDownloadJobs(); (function cleanupOrphanDownloadFiles() { try { const known = new Set( listDownloadJobs({ userId: '', limit: 100000 }) .filter(j => j.state === 'completed' && j.filePath) .map(j => path.resolve(j.filePath)) ); if (!fs.existsSync(downloadsRoot)) return; for (const entry of fs.readdirSync(downloadsRoot, { withFileTypes: true })) { const full = path.join(downloadsRoot, entry.name); if (entry.isDirectory()) { for (const f of fs.readdirSync(full)) { const fp = path.join(full, f); if (!known.has(path.resolve(fp))) { try { fs.unlinkSync(fp); } catch {} } } } else if (entry.isFile() && !known.has(path.resolve(full))) { // Legacy layout: files directly under downloadsRoot try { fs.unlinkSync(full); } catch {} } } } catch (e) { console.warn('[downloads] orphan cleanup failed:', e?.message || e); } })(); function providerLabel(provider) { switch (String(provider)) { case 'youtube': return 'YouTube'; case 'dailymotion': return 'Dailymotion'; case 'twitch': return 'Twitch'; case 'peertube': return 'PeerTube'; case 'odysee': return 'Odysee'; case 'rumble': return 'Rumble'; default: return String(provider || '').charAt(0).toUpperCase() + String(provider || '').slice(1); } } function normalizeResolutionLabel(label) { const s = String(label || '').trim(); // Prefer forms like "480p", falling back to numeric height const m = /(\d{3,4})\b/.exec(s); if (/\d{3,4}p/.test(s)) return s.replace(/[^0-9p]/g, ''); if (m) return `${m[1]}p`; return s || 'best'; } function uniquePath(baseDir, baseName, ext) { let candidate = `${baseName}.${ext}`; let full = path.join(baseDir, candidate); let i = 1; while (fs.existsSync(full)) { candidate = `${baseName} (${i}).${ext}`; full = path.join(baseDir, candidate); i++; } return { fileName: candidate, filePath: full }; } // Pick the best progressive (video+audio) format from metadata function pickBestProgressiveFormat(meta) { const items = Array.isArray(meta?.formats) ? meta.formats : []; let best = null; for (const f of items) { if (!f) continue; const hasVideo = f.vcodec && f.vcodec !== 'none'; const hasAudio = f.acodec && f.acodec !== 'none'; if (!hasVideo || !hasAudio) continue; const height = Number(f.height || 0); const fps = Number(f.fps || 0); if (!best) { best = f; continue; } const bh = Number(best.height || 0); const bf = Number(best.fps || 0); if (height > bh || (height === bh && fps > bf)) best = f; } return best; } const loginLimiter = rateLimit({ windowMs: 60 * 1000, // 1 min max: 5, standardHeaders: true, legacyHeaders: false, }); /** * 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 max: 10, // Limit to 10 requests per minute per IP standardHeaders: true, legacyHeaders: false, message: { error: 'Too many requests to Rumble API. Please try again later.' } }); function makeAccessToken(userId, sessionId) { const payload = { sub: userId, sid: sessionId }; return jwt.sign(payload, JWT_SECRET, { expiresIn: `${ACCESS_TTL_MIN}m` }); } function isSecureRequest(req) { try { if (process.env.COOKIE_SECURE === 'true') return true; if (process.env.COOKIE_SECURE === 'false') return false; if (req?.secure) return true; const proto = String(req?.headers?.['x-forwarded-proto'] || req?.protocol || '').split(',')[0].trim().toLowerCase(); return proto === 'https'; } catch { return false; } } function setRefreshCookies(res, { sessionId, token, days }, req) { const maxAgeMs = days * 24 * 60 * 60 * 1000; const cookieOpts = { httpOnly: true, // 'lax' : envoyé sur les navigations top-level (liens directs,