feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
CI / build-and-test (push) Successful in 14m57s

P0
- Aucun secret servi au navigateur : /assets/config.local.js est généré par le
  serveur AVANT les montages statiques (il l'emporte sur le fichier local) et
  ne contient plus YOUTUBE_API_KEY(S) ; le fichier local n'est plus copié dans
  l'image Docker ni embarqué dans dist (angular ignore config.local.js) ;
  youtube-api.service n'appelle plus googleapis directement (fetchYouTube =
  /api/yt -> /proxy/api/yt), gardes « pas de clé => écran vide », rotation et
  carte de bans côté client supprimées ; readiness YouTube via /healthz
  (youtube.keys.count) ; messages d'erreur orientés configuration serveur.
- CORS : origines pilotées par API_ALLOWED_ORIGINS (CSV), méthode PATCH
  ajoutée (requis par /user/preferences), header X-API-Key accepté.
- Clés d'API longue durée : table api_keys (empreinte SHA-256 + préfixe
  affichable), routes GET/POST /api/keys et DELETE /api/keys/:id, jeton
  ntk_… affiché une seule fois, last_used_at à chaque usage ; X-API-Key
  accepté par authMiddleware ET authMiddlewareCookieAware.

P1
- X-Request-Id renvoyé sur chaque réponse + journal JSON structuré en prod
  (ts, reqId, method, route, status, ms).
- GET /metrics : exposition Prometheus sans dépendance (http_requests_total
  par route/code, somme+nombre de durées, uptime/mémoire ; METRICS_TOKEN
  verrouille l'accès si défini).
- Rate-limit sur /api/details (DETAILS_RATE_LIMIT, 60/min, réponse JSON) —
  /transcript avait déjà le sien.
- Version unique package.json : menu du compte, info.version de l'OpenAPI
  (+ schéma apiKeyAuth et chemins /keys / /metrics documentés).

Tests : api_coverage +7 cas « production-ready » (sentinel de fuite de clé,
X-Request-Id, /metrics, CORS PATCH, contrat/version, cycle complet des clés
d'API), suggest, transcript, flags, filters, kind — verts. Build OK.
Vérifs instance locale : config servie sans secret, /api/yt 200 avec la clé
serveur, /metrics alimenté, menu 1.0.59 et 40 cartes rendues sans clé côté
client.
This commit is contained in:
2026-10-02 23:24:50 -04:00
parent 6add43e730
commit 41a28dfccc
16 changed files with 449 additions and 237 deletions
+218 -45
View File
@@ -7,6 +7,7 @@ import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import fs from 'node:fs';
import path from 'node:path';
import { createHash, randomBytes, randomUUID } from 'node:crypto';
import youtubedlPkg, { create as createYtDlp } from 'youtube-dl-exec';
import { execFile as execFileCb } from 'node:child_process';
import { promisify } from 'node:util';
@@ -42,6 +43,10 @@ import {
countTelemetryEvents,
cryptoRandomId,
cryptoRandomUUID,
insertApiKey,
listApiKeys,
revokeApiKey,
findApiKeyUser,
insertSearchHistory,
insertSearchHistoryAt,
listSearchHistory,
@@ -204,10 +209,16 @@ 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));
// CORS : liste d'origines explicite, pilotée par env (CSV). Les origines par
// défaut couvrent le dev ; en production, ajouter l'URL publique de l'app dans
// API_ALLOWED_ORIGINS pour autoriser des clients tiers (sinon requête bloquée
// côté navigateur). PATCH est requis : /user/preferences est une route PATCH.
const ALLOWED_ORIGINS = (process.env.API_ALLOWED_ORIGINS || 'http://localhost:4200,http://localhost:4000,http://localhost:3000')
.split(',').map((s) => s.trim()).filter(Boolean);
const corsOptions = {
origin: ['http://localhost:4200', 'http://localhost:4000', 'http://localhost:3000'],
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
origin: ALLOWED_ORIGINS,
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
credentials: true,
maxAge: 86400 // 24h
};
@@ -225,15 +236,78 @@ function sanitizeBody(body) {
return out;
}
// --- Requête : X-Request-Id, journal structuré, métriques -------------------
// Un seul point de mesure : tout ce qui sort du serveur passe par ici, ce qui
// donne d'un coup l'identifiant de requête (debug), le journal JSON de prod et
// les compteurs exposés sur /metrics.
const httpMetrics = new Map(); // "METHOD|route|status" -> { count, durSumMs }
/** Libellé de route stable : `req.route` quand Express a routé, sinon « other ». */
function routeLabel(req) {
try {
const p = req.route && typeof req.route.path === 'string' ? req.route.path : null;
if (p) {
if (p.includes('*')) return 'spa';
return `${req.baseUrl || ''}${p}` || '/';
}
} catch {}
return 'other'; // static, proxy non routé…
}
const METRIC_TEXT_TYPE = 'text/plain; version=0.0.4; charset=utf-8';
function escapeLabel(v) { return String(v).replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n'); }
/** Exposition Prometheus (compteurs HTTP + jauge mémoire ; sans dépendance). */
function renderMetrics() {
const out = [];
const push = (name, type, help) => { out.push(`# HELP ${name} ${help}`, `# TYPE ${name} ${type}`); };
push('http_requests_total', 'counter', 'Requetes HTTP traitees, par methode, route et code.');
const rows = [...httpMetrics.entries()].map(([k, v]) => [k.split('|'), v]);
for (const [[method, route, status], v] of rows) {
out.push(`http_requests_total{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.count}`);
}
push('http_request_duration_ms_sum', 'counter', 'Duree cumulee des requetes HTTP en millisecondes.');
for (const [[method, route, status], v] of rows) {
out.push(`http_request_duration_ms_sum{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.durSumMs}`);
}
push('http_request_duration_ms_count', 'counter', 'Nombre de durees mesurees (meme tuple que le sum).');
for (const [[method, route, status], v] of rows) {
out.push(`http_request_duration_ms_count{method="${escapeLabel(method)}",route="${escapeLabel(route)}",status="${escapeLabel(status)}"} ${v.count}`);
}
push('process_uptime_seconds', 'gauge', 'Demenrage du processus serveur.');
out.push(`process_uptime_seconds ${Math.round(process.uptime())}`);
push('process_resident_memory_bytes', 'gauge', 'Memoire residente du processus.');
out.push(`process_resident_memory_bytes ${process.memoryUsage().rss}`);
return `${out.join('\n')}\n`;
}
const requestLogger = (req, res, next) => {
if (IS_PROD) {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
return next();
const t0 = Date.now();
const reqId = randomUUID();
req.requestId = reqId;
try { res.setHeader('X-Request-Id', reqId); } catch {}
res.on('finish', () => {
const ms = Date.now() - t0;
const label = routeLabel(req);
const key = `${req.method}|${label}|${res.statusCode}`;
const cur = httpMetrics.get(key) || { count: 0, durSumMs: 0 };
cur.count += 1;
cur.durSumMs += ms;
httpMetrics.set(key, cur);
// Prod : une ligne JSON par requête (parseable par docker/loki). Dev : ligne lisible.
if (IS_PROD) {
console.log(JSON.stringify({ ts: new Date().toISOString(), reqId, method: req.method, route: label, status: res.statusCode, ms }));
} else {
console.log(`[${new Date().toISOString()}] ${reqId} ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
}
});
if (!IS_PROD) {
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));
}
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();
};
@@ -945,6 +1019,26 @@ r.get('/playlists/:id/view', (req, res) => {
}
});
// -------------------- Client config from environment --------------------
// AVANT les montages statiques : c'est ce qui garantit que le fichier
// « généré » l'emporte sur `assets/config.local.js` (fichier local qui peut
// contenir d'anciennes clés et ne doit JAMAIS être servi au navigateur).
// WARNING: every value served here is exposed to the browser. Only
// browser-safe values belong here (public client ids, player ids).
// Server-only secrets — YOUTUBE_API_KEY(S), TWITCH_CLIENT_SECRET, GEMINI_API_KEY,
// RUMBLE_API_KEY, VIMEO_ACCESS_TOKEN — stay on the server and are consumed
// through dedicated endpoints (/api/yt/*, /api/twitch-token, /api/ai/*, …).
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
const lines = [];
const env = process.env || {};
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
if (env.DAILYMOTION_PLAYER_ID) lines.push(`window.DAILYMOTION_PLAYER_ID = ${jsVal(env.DAILYMOTION_PLAYER_ID)};`);
// Intentionally NOT exposed: YOUTUBE_API_KEY(S), TWITCH_CLIENT_SECRET, GEMINI_API_KEY…
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.send(lines.join('\n'));
});
// 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')));
@@ -1212,7 +1306,29 @@ async function hashToken(token) {
return bcrypt.hash(token, salt);
}
// -------------------- Clés d'API (clients hors navigateur) --------------------
// `X-API-Key: ntk_…` : identité longue durée pour appeler l'API en script,
// révocable individuellement. Seule l'empreinte SHA-256 du jeton est stockée ;
// le clair n'est affiché qu'une fois, à la création (route `/keys`).
// Accepté par les DEUX middlewares : toutes les routes protégées deviennent
// utilisables hors navigateur sans changer de mécanisme d'auth.
function apiKeyUser(req) {
const raw = String(req.get('x-api-key') || '').trim();
if (!raw) return null;
try {
const found = findApiKeyUser(createHash('sha256').update(raw).digest('hex'));
return found ? { id: found.userId, sessionId: null, via: 'api_key' } : null;
} catch {
return null;
}
}
function authMiddleware(req, res, next) {
const byKey = apiKeyUser(req);
if (byKey) {
req.user = byKey;
return next();
}
const hdr = req.headers['authorization'] || '';
const [, token] = hdr.split(' ');
if (!token) return res.status(401).json({ error: 'Unauthorized' });
@@ -1228,6 +1344,11 @@ function authMiddleware(req, res, next) {
// For direct browser downloads (anchor tag), Authorization header is not attached.
// Allow authentication using the httpOnly session cookies as a fallback for the file route.
function authMiddlewareCookieAware(req, res, next) {
const byKey = apiKeyUser(req);
if (byKey) {
req.user = byKey;
return next();
}
const hdr = req.headers['authorization'] || '';
const [, token] = hdr.split(' ');
if (token) {
@@ -1555,9 +1676,21 @@ function detailsCacheSet(key, data) {
}
}
// /details invoque yt-dlp (2-5 s par vidéo non cachée) : seau dédié — sans lui,
// un client qui varie les ids peut faire tourner yt-dlp en boucle. Le cache
// 6 h protège les ids déjà vus, le seau protège le reste. Réponse JSON, comme
// transcriptLimiter (sinon Angular reçoit du texte et plante au parsing).
const detailsLimiter = rateLimit({
windowMs: 60 * 1000,
max: Number(process.env.DETAILS_RATE_LIMIT || 60),
standardHeaders: true,
legacyHeaders: false,
handler: (req, res) => res.status(429).json({ error: 'rate_limited' }),
});
// Returns metadata such as title, description, uploader, thumbnail, duration and views for a provider/videoId
// Supports query params similar to download endpoints: instance (PeerTube), slug (Odysee), sourceUrl (direct)
r.get('/details/:provider/:videoId', async (req, res) => {
r.get('/details/:provider/:videoId', detailsLimiter, async (req, res) => {
try {
const { provider, videoId } = req.params;
const instance = req.query.instance || undefined;
@@ -2370,6 +2503,35 @@ r.delete('/auth/sessions/:id', authMiddleware, (req, res) => {
return res.status(204).end();
});
// -------------------- Clés d'API (gestion) --------------------
// GET liste les clés (jamais le jeton), POST en crée une — le jeton complet
// n'est renvoyé QUE dans cette réponse —, DELETE la révoque immédiatement.
r.get('/keys', authMiddleware, (req, res) => {
try {
return res.json({ items: listApiKeys(req.user.id) });
} catch (e) {
return res.status(500).json({ error: 'api_key_list_failed', details: String(e?.message || e) });
}
});
r.post('/keys', authMiddleware, (req, res) => {
try {
const name = String(req.body?.name || '').trim().slice(0, 80) || 'clé';
const token = `ntk_${randomBytes(24).toString('base64url')}`;
const keyHash = createHash('sha256').update(token).digest('hex');
const id = insertApiKey({ userId: req.user.id, name, keyHash, prefix: token.slice(0, 11) });
return res.status(201).json({ id, name, prefix: token.slice(0, 11), createdAt: new Date().toISOString(), token });
} catch (e) {
return res.status(500).json({ error: 'api_key_create_failed', details: String(e?.message || e) });
}
});
r.delete('/keys/:id', authMiddleware, (req, res) => {
const revoked = revokeApiKey(String(req.params.id), req.user.id);
if (!revoked) return res.status(404).json({ error: 'api_key_not_found' });
return res.json({ revoked: true });
});
r.get('/user/me', authMiddleware, (req, res) => {
const u = getUserById(req.user.id);
if (!u) return res.status(404).json({ error: 'not_found' });
@@ -2858,6 +3020,20 @@ r.get('/providers/metrics', async (_req, res) => {
app.use('/api', r);
// Health endpoint for container checks
app.get('/api/health', (_req, res) => res.json({ status: 'ok' }));
// -------------------- Métriques Prometheus --------------------
// Exposition texte simple (sans dépendance) : compteurs HTTP agrégés par
// route, durées cumulées et jauges processus. Si METRICS_TOKEN est défini,
// l'accès exige `Authorization: Bearer <token>` ou `?token=`.
app.get('/metrics', (req, res) => {
const token = String(process.env.METRICS_TOKEN || '').trim();
if (token) {
const got = String(req.get('authorization') || '');
const q = String(req.query.token || '');
if (got !== `Bearer ${token}` && q !== token) return res.status(401).json({ error: 'unauthorized' });
}
res.set('Content-Type', METRIC_TEXT_TYPE).send(renderMetrics());
});
// Step 17 : observabilité YouTube (mode, yt-dlp, cache, quota). Aucun secret exposé.
// Trois chemins, comme le router `r` : le front appelle `/proxy/api` en prod
// (voir apiBase()), sans quoi healthz tomberait sur le fallback SPA → HTML.
@@ -2923,37 +3099,6 @@ app.use('/proxy/api', r);
app.use('/api/rumble', rumbleRouter);
app.use('/proxy/api/rumble', rumbleRouter);
// -------------------- Client config from environment --------------------
// WARNING: Values served here are exposed to the browser.
// Only browser-safe values belong here (e.g. referrer-restricted YouTube keys).
// Secrets like TWITCH_CLIENT_SECRET / GEMINI_API_KEY stay server-side and are
// consumed through dedicated server endpoints (/api/twitch-token, /api/ai/*).
function jsVal(v) { return JSON.stringify(v == null ? '' : v); }
app.get(['/assets/config.local.js', '/assets/config.js', '/config.js'], (_req, res) => {
const lines = [];
const env = process.env || {};
if (env.YOUTUBE_API_KEY) lines.push(`window.YOUTUBE_API_KEY = ${jsVal(env.YOUTUBE_API_KEY)};`);
if (env.YOUTUBE_API_KEYS) {
try {
// Accepte JSON array ('["k1","k2"]', fourni par ex. via compose) ou CSV ('k1,k2').
const raw = String(env.YOUTUBE_API_KEYS).trim();
let arr = [];
if (raw.startsWith('[')) {
try { arr = JSON.parse(raw); } catch { arr = []; }
if (!Array.isArray(arr)) arr = [];
} else {
arr = raw.split(',');
}
arr = arr.map(s => String(s || '').trim().replace(/^["'\[]+|["'\]]+$/g, '')).filter(Boolean);
if (arr.length) lines.push(`window.YOUTUBE_API_KEYS = ${JSON.stringify(arr)};`);
} catch {}
}
if (env.TWITCH_CLIENT_ID) lines.push(`window.TWITCH_CLIENT_ID = ${jsVal(env.TWITCH_CLIENT_ID)};`);
// Intentionally NOT exposed: TWITCH_CLIENT_SECRET, GEMINI_API_KEY — proxy via /api/twitch-token & /api/ai/*
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
res.send(lines.join('\n'));
});
// -------------------- Twitch app token (server-side) --------------------
// Exchanges TWITCH_CLIENT_ID + TWITCH_CLIENT_SECRET server-side and returns the
// app access token to the client. The secret never leaves the server.
@@ -4047,11 +4192,26 @@ r.put('/playlists/:id/reorder', authMiddlewareCookieAware, (req, res) => {
});
// --- OpenAPI (référence machine : lecture publique + espace utilisateur) ---
// Version = package.json, source unique partagée avec le front (src/app/version.ts).
const OPENAPI_VERSION = (() => {
try {
return JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0';
} catch {
return '0.0.0';
}
})();
r.get('/openapi.json', (_req, res) => {
const bearer = [{ bearerAuth: [] }];
// Les deux schémas sont acceptés sur les routes protégées : JWT (navigateur)
// et clé d'API (clients hors navigateur, header X-API-Key).
const bearer = [{ bearerAuth: [] }, { apiKeyAuth: [] }];
const doc = {
openapi: '3.0.0',
info: { title: 'NewTube API', version: '1.1.0' },
info: {
title: 'NewTube API',
version: OPENAPI_VERSION,
description: "Agrégateur vidéo multi-fournisseurs. Authentification des routes protégées : soit `Authorization: Bearer <accessToken>` (obtenu sur /auth/login), soit `X-API-Key: ntk_…` (clé créée sur /keys, révocable).",
},
paths: {
'/healthz': {
get: { summary: 'Santé API (mode YT, yt-dlp, cache, quota)', responses: { 200: { description: 'ok' } } },
@@ -4150,9 +4310,22 @@ r.get('/openapi.json', (_req, res) => {
},
'/playlists/{id}/reorder': {
put: { summary: 'Reorder playlist items', security: [{ bearerAuth: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }] }
},
'/keys': {
get: { summary: 'Lister ses clés d’API (le jeton n’apparaît jamais)', security: bearer, responses: { 200: { description: '{ items: [{ id, name, prefix, createdAt, lastUsedAt, revokedAt }] }' } } },
post: { summary: 'Créer une clé d’API — le jeton complet n’est renvoyé qu’ici', security: bearer, requestBody: { required: false, content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' } } } } } }, responses: { 201: { description: '{ id, name, prefix, token } — token affiché une seule fois' } } },
},
'/keys/{id}': {
delete: { summary: 'Révoquer une clé d’API (effet immédiat)', security: bearer, parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: '{ revoked: true }' }, 404: { description: 'api_key_not_found' } } },
},
'/metrics': {
get: { summary: 'Métriques Prometheus (compteurs HTTP, durées, processus)', parameters: [{ name: 'token', in: 'query', schema: { type: 'string' }, description: 'Requis uniquement si METRICS_TOKEN est défini côté serveur' }], responses: { 200: { description: 'text/plain — exposition Prometheus' }, 401: { description: 'unauthorized' } } },
}
},
components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } }
components: { securitySchemes: {
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
apiKeyAuth: { type: 'apiKey', in: 'header', name: 'X-API-Key' },
} }
};
res.json(doc);
});