feat(api): production-ready P0/P1 — secrets hors navigateur, clés d'API, métriques
CI / build-and-test (push) Successful in 14m57s
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:
@@ -404,6 +404,36 @@ export function cryptoRandomId() {
|
||||
return randomBytes(16).toString('base64url');
|
||||
}
|
||||
|
||||
// -------------------- API keys (clients hors navigateur) --------------------
|
||||
// Le jeton en clair n'est jamais stocké : on ne garde que son SHA-256.
|
||||
|
||||
export function insertApiKey({ userId, name, keyHash, prefix }) {
|
||||
const id = cryptoRandomId();
|
||||
db.prepare(`INSERT INTO api_keys (id, user_id, name, key_hash, prefix, created_at)
|
||||
VALUES (@id, @userId, @name, @keyHash, @prefix, @ts)`)
|
||||
.run({ id, userId, name: String(name || 'clé').slice(0, 80), keyHash, prefix, ts: nowIso() });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function listApiKeys(userId) {
|
||||
return db.prepare(`SELECT id, name, prefix, created_at AS createdAt, last_used_at AS lastUsedAt, revoked_at AS revokedAt
|
||||
FROM api_keys WHERE user_id = ? ORDER BY created_at DESC`).all(userId);
|
||||
}
|
||||
|
||||
export function revokeApiKey(id, userId) {
|
||||
const info = db.prepare('UPDATE api_keys SET revoked_at = ? WHERE id = ? AND user_id = ? AND revoked_at IS NULL')
|
||||
.run(nowIso(), id, userId);
|
||||
return info.changes > 0;
|
||||
}
|
||||
|
||||
/** Renvoie le propriétaire d'un jeton valide (non révoqué) et note l'usage. */
|
||||
export function findApiKeyUser(keyHash) {
|
||||
const row = db.prepare('SELECT id, user_id AS userId FROM api_keys WHERE key_hash = ? AND revoked_at IS NULL').get(keyHash);
|
||||
if (!row) return null;
|
||||
try { db.prepare('UPDATE api_keys SET last_used_at = ? WHERE id = ?').run(nowIso(), row.id); } catch {}
|
||||
return { userId: row.userId, keyId: row.id };
|
||||
}
|
||||
|
||||
// -------------------- Telemetry (minimal product events) --------------------
|
||||
|
||||
export function insertTelemetryEvent({ userId, event, meta }) {
|
||||
|
||||
+218
-45
@@ -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);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,10 @@ const PORT = await new Promise((resolve) => {
|
||||
const base = `http://127.0.0.1:${PORT}`;
|
||||
const server = spawn(process.execPath, ['./server/index.mjs'], {
|
||||
cwd: path.resolve(import.meta.dirname, '..', '..'),
|
||||
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: dbPath, JWT_SECRET: 'cov-test-secret', NODE_ENV: 'test' },
|
||||
env: { ...process.env, PORT: String(PORT), NEWTUBE_DB_FILE: dbPath, JWT_SECRET: 'cov-test-secret', NODE_ENV: 'test',
|
||||
// P0 : sentinel — cette clé ne doit JAMAIS apparaître dans ce que le
|
||||
// serveur livre au navigateur (config.local.js générée).
|
||||
YOUTUBE_API_KEY: 'SENTINEL_YT_KEY_MUST_NOT_LEAK' },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let logs = '';
|
||||
@@ -217,3 +220,93 @@ describe('auth + espace utilisateur', () => {
|
||||
assert.equal(dj.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
// Production-ready (P0 + P1) : secrets hors navigateur, CORS configurable,
|
||||
// clés d'API longue durée, identifiant de requête, métriques, contrat/version.
|
||||
describe('production-ready (P0/P1)', () => {
|
||||
it('config.local.js générée : aucun secret n\'est livré au navigateur', async () => {
|
||||
const r = await fetch(`${base}/assets/config.local.js`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get('content-type') || '', /javascript/);
|
||||
const body = await r.text();
|
||||
for (const s of ['SENTINEL_YT_KEY_MUST_NOT_LEAK', 'YOUTUBE_API_KEY', 'YOUTUBE_API_KEYS',
|
||||
'TWITCH_CLIENT_SECRET', 'GEMINI_API_KEY', 'RUMBLE_API_KEY', 'VIMEO_ACCESS_TOKEN']) {
|
||||
assert.ok(!body.includes(s), `${s} ne doit pas figurer dans la config servie`);
|
||||
}
|
||||
});
|
||||
|
||||
it('X-Request-Id renvoyé sur chaque réponse', async () => {
|
||||
const r = await fetch(`${base}/api/health`);
|
||||
assert.ok(r.headers.get('x-request-id'), 'header X-Request-Id absent');
|
||||
});
|
||||
|
||||
it('GET /metrics : exposition Prometheus', async () => {
|
||||
const r = await fetch(`${base}/metrics`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.match(r.headers.get('content-type') || '', /text\/plain/);
|
||||
const body = await r.text();
|
||||
assert.ok(body.includes('http_requests_total'), 'compteur http_requests_total absent');
|
||||
assert.ok(body.includes('http_request_duration_ms_sum'), 'durées absentes');
|
||||
assert.ok(body.includes('process_uptime_seconds'), 'jauge processus absente');
|
||||
});
|
||||
|
||||
it('CORS : PATCH autorisé et X-API-Key dans les en-têtes acceptés', async () => {
|
||||
const r = await fetch(`${base}/api/user/me`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: 'http://localhost:4200',
|
||||
'Access-Control-Request-Method': 'PATCH',
|
||||
'Access-Control-Request-Headers': 'authorization,content-type',
|
||||
},
|
||||
});
|
||||
assert.ok(r.status < 300, `preflight ${r.status}`);
|
||||
assert.equal(r.headers.get('access-control-allow-origin'), 'http://localhost:4200');
|
||||
assert.match(r.headers.get('access-control-allow-methods') || '', /PATCH/);
|
||||
assert.match(r.headers.get('access-control-allow-headers') || '', /X-API-Key/i);
|
||||
});
|
||||
|
||||
it('openapi : version = package.json + contrats détaillés + schéma apiKeyAuth', async () => {
|
||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(import.meta.dirname, '..', '..', 'package.json'), 'utf8'));
|
||||
const r = await J(`${base}/api/openapi.json`);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.info.version, pkg.version, 'info.version doit être la version package.json');
|
||||
assert.ok(r.body.paths['/details/{provider}/{videoId}'], 'contrat /details absent');
|
||||
assert.ok(r.body.paths['/transcript/{provider}/{videoId}'], 'contrat /transcript absent');
|
||||
assert.ok(r.body.paths['/playlists'], 'contrat /playlists absent');
|
||||
assert.ok(r.body.paths['/keys'], 'contrat /keys absent');
|
||||
assert.ok(r.body.components.securitySchemes.apiKeyAuth, 'schéma apiKeyAuth absent');
|
||||
});
|
||||
|
||||
it('clés d\'API : création, usage hors navigateur, listage sans jeton, révocation', async () => {
|
||||
const h = { ...auth(token), 'content-type': 'application/json' };
|
||||
const created = await J(`${base}/api/keys`, { method: 'POST', headers: h, body: JSON.stringify({ name: 'cov' }) });
|
||||
assert.equal(created.status, 201, JSON.stringify(created.body));
|
||||
assert.ok(String(created.body.token).startsWith('ntk_'), 'format de jeton');
|
||||
const keyId = created.body.id;
|
||||
|
||||
// Le jeton ouvre les routes protégées sans Authorization.
|
||||
const me = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': created.body.token } });
|
||||
assert.equal(me.status, 200, 'X-API-Key non accepté');
|
||||
|
||||
// Listage : préfixe affichable, jeton jamais renvoyé.
|
||||
const list = await J(`${base}/api/keys`, { headers: auth(token) });
|
||||
assert.equal(list.status, 200);
|
||||
const row = (list.body.items || []).find((k) => k.id === keyId);
|
||||
assert.ok(row, 'clé absente du listage');
|
||||
assert.equal(row.prefix, created.body.token.slice(0, 11));
|
||||
assert.ok(!JSON.stringify(list.body).includes(created.body.token), 'jeton exposé par le listage');
|
||||
|
||||
// Révocation immédiate + idempotence en 404.
|
||||
const del = await J(`${base}/api/keys/${keyId}`, { method: 'DELETE', headers: auth(token) });
|
||||
assert.equal(del.status, 200);
|
||||
const after = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': created.body.token } });
|
||||
assert.equal(after.status, 401, 'clé révoquée encore acceptée');
|
||||
const again = await J(`${base}/api/keys/${keyId}`, { method: 'DELETE', headers: auth(token) });
|
||||
assert.equal(again.status, 404, 'double révocation doit répondre 404');
|
||||
});
|
||||
|
||||
it('clés d\'API : jeton invalide => 401 (et pas de session par magie)', async () => {
|
||||
const r = await J(`${base}/api/user/me`, { headers: { 'X-API-Key': 'ntk_invalide' } });
|
||||
assert.equal(r.status, 401);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user