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
+6 -5
View File
@@ -161,7 +161,7 @@ Application : [http://localhost:8080](http://localhost:8080) (mappage `8080:4000
```bash
npm install
cp assets/config.local.example.js assets/config.local.js # (optionnel)
cp assets/config.local.example.js assets/config.local.js # (optionnel — valeurs SANS SECRET uniquement)
npm run dev # front + api dev proxy
# API seule :
npm run api
@@ -227,7 +227,8 @@ Ajoutez au besoin `log-driver`, `log-opts`, `default-address-pools`, etc.
* `PORT` (4000), `NODE_ENV`, `JWT_SECRET`
* `ACCESS_TTL_MIN`, `REFRESH_TTL_DAYS`, `REMEMBER_TTL_DAYS`
* `YT_CACHE_TTL_MS`
* **Clés API** : `GEMINI_API_KEY`, `YOUTUBE_API_KEY` ou `YOUTUBE_API_KEYS` (CSV), `VIMEO_ACCESS_TOKEN`, `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`
* **Clés API** (toutes **côté serveur**, aucune n'est envoyée au navigateur) : `GEMINI_API_KEY`, `YOUTUBE_API_KEY` ou `YOUTUBE_API_KEYS` (CSV), `VIMEO_ACCESS_TOKEN`, `RUMBLE_API_KEY`, `TWITCH_CLIENT_ID`, `TWITCH_CLIENT_SECRET`
* **API ouverte aux clients** : `API_ALLOWED_ORIGINS` (CSV des origines autorisées en CORS, défaut `localhost:4200,4000,3000` — y ajouter l'URL publique de l'app pour des clients tiers), `METRICS_TOKEN` (si défini, `/metrics` exige `Authorization: Bearer …`), `DETAILS_RATE_LIMIT` (appels `/api/details` par minute, défaut 60)
* `NEWTUBE_DB_FILE` (chemin SQLite alternatif)
> Voir `docker-compose/.env.example` pour un point de départ.
@@ -272,14 +273,15 @@ Légende : **✅ livré** (câblé de bout en bout) · **🟡 partiellement livr
* ✅ **OAuth** (Google/Twitch) pour import favoris/abonnements — `/library/import` : abonnements + likes YouTube (lecture seule), follows Twitch → abonnements, preview + import, `GET /api/oauth/status|connections|preview`, `POST /api/oauth/:provider/import`
* ✅ **Live sur PeerTube & Rumble** — thème « En direct » + onglet Live de la chaîne : PeerTube via l'API native `?isLive=true` (recherche sepiasearch + vidéos de chaîne, items `type:'live'` + spectateurs), Rumble via la page SSR `rumble.com/browse/live` (route `/api/rumble/live`, items typés live ; onglet chaîne filtré par `channelId` — plafond : page 1 bornée à 50 directs). Odysee : **N/A** (aucune API publique de lives, probe 2026-10). Tests : `npm run test:live`
* ✅ **Import/Export playlists (JSON)** — `GET /playlists/export` (dump complet + items, format `newtube-playlists-v1`), `POST /playlists/import` (création des listes manquantes, dédoublonnage par titre au réimport, compteurs détaillés ; bornes 200 listes / 5 000 vidéos par liste) ; boutons **[Exporter] [Importer]** sur `/#/library/playlists`.
* ✅ **Page Administration** (`/#/admin`, entrée « Administration » de la section Informations dans la barre latérale) — `/healthz` + `/api/providers/metrics` rendus lisibles : statut API, mode YouTube, version et santé yt-dlp, clés actives / bannies (+ suffixes), anti-ban (cookies / PO token / proxy), cache de recherche par provider, compteurs 1 h par provider, quota YouTube du jour. Au passage : `healthz` servi aussi sous `/proxy/api/healthz` (le chemin que le front utilise en prod). *Journaux (logs) et exposition Prometheus restent à faire — voir Observabilité.*
* ✅ **Page Administration** (`/#/admin`, entrée « Administration » de la section Informations dans la barre latérale) — `/healthz` + `/api/providers/metrics` rendus lisibles : statut API, mode YouTube, version et santé yt-dlp, clés actives / bannies (+ suffixes), anti-ban (cookies / PO token / proxy), cache de recherche par provider, compteurs 1 h par provider, quota YouTube du jour. Au passage : `healthz` servi aussi sous `/proxy/api/healthz` (le chemin que le front utilise en prod). *Journaux et exposition Prometheus livrés — voir Observabilité.*
* ✅ **Cache TTL par provider** — `server/env-ttl.mjs` (`<BASE>_<PROVIDER>` → `<BASE>` → défaut, valeurs invalides ignorées, jamais de TTL nul) branché sur les caches **details** (`DETAILS_CACHE_TTL_MS_<P>`) et **transcripts** (`TRANSCRIPT_CACHE_TTL_<P>`) en plus de la recherche (`SEARCH_CACHE_TTL_MS_<P>`). Le cache **suggest** reste global par conception : sa clé agrégée couvre plusieurs providers d'un coup, un TTL par provider demanderait de scinder le cache. Tests : `npm run test:cache`
* ✅ **Menu du compte refondu** (bouton avatar, haut à droite du header) — panneau sombre à lignes « icône + titre gras + sous-titre gris » : **Thème** (thème réellement appliqué en sous-titre, pastilles dépliées au clic), **Administration** (`/#/admin`), **API** (`/proxy/api/openapi.json` dans un nouvel onglet), **Préférences** (`/#/account/preferences`), **Guide d'utilisation** (`/#/info/utilisation`), **À propos** (version, tagline, lien code source), **Sessions** (`/#/account/sessions`), **Déconnexion** ; pied de page **Version** = version officielle de l'application (`src/app/version.ts`, alignée sur le tag semver publié par `docker/deploy-img.sh`). Clés i18n déclarées des deux côtés (FR + EN)
* ✅ **Observabilité** — `GET /healthz` (mode `YT_SEARCH_MODE`, binaire yt-dlp, cache, quota du jour, clés masquées — testé par `test:api`), `GET /api/providers/metrics` et la **page Admin** qui les rend, **journal JSON structuré en prod** (une ligne par requête : `ts`, `reqId`, `method`, `route`, `status`, `ms`, adossé au header `X-Request-Id` renvoyé sur chaque réponse) et **`GET /metrics`** — exposition Prometheus sans dépendance (`http_requests_total` par route et code, somme/nombre de durées, démarrage + mémoire du processus ; `METRICS_TOKEN` verrouille l'accès si défini)
* ✅ **API production-ready (P0 + P1)** — **plus aucun secret servi au navigateur** : `/assets/config.local.js` est généré par le serveur **avant** les montages statiques (il l'emporte donc 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`, et les appels YouTube passent par `/api/yt` uniquement (clé, rotation, quota et cache côté serveur — suppression des appels directs à googleapis et des gardes « pas de clé ⇒ écran vide ») ; **CORS piloté par `API_ALLOWED_ORIGINS`** (CSV) + méthode `PATCH` (requis par `/user/preferences`) ; **clés d'API longue durée** : `GET/POST /api/keys` + `DELETE /api/keys/:id`, jeton `ntk_…` affiché une seule fois, stocké en SHA-256 avec préfixe affichable, header `X-API-Key` accepté par les **deux** middlewares d'auth (toutes les routes protégées deviennent scriptables), `last_used_at` renseigné à chaque usage ; **`X-Request-Id`** en réponse sur chaque requête ; **`/api/details` rate-limité** (`DETAILS_RATE_LIMIT`, 60/min, réponse JSON — `/api/transcript` avait déjà le sien) ; **version unique `package.json`** (menu du compte + `info.version` de l'OpenAPI). Tests : `npm run test:api`
### 🟡 Partiellement livré
* 🟡 **Tags** — ✅ tables `tags` / `video_tags` (stockage des listes « Aimés » et « À regarder plus tard », recherche serveur dans ces listes) ; ⏳ tags **libres** créés par l'utilisateur sur n'importe quelle vidéo + recherche de vidéos par tag
* 🟡 **Observabilité** — ✅ `GET /healthz` (mode `YT_SEARCH_MODE`, binaire yt-dlp, cache, quota du jour, clés masquées — testé par `test:api`), `GET /api/providers/metrics` et la **page Admin** qui les rend (voir ✅) ; ⏳ journaux (aucun endpoint de logs aujourd'hui) + exposition métriques au format Prometheus
* 🟡 **Qualité vidéo** — ✅ sélecteur de qualité sur `/#/watch` (formats yt-dlp, tri décroissant `1080p → 144p`, reprise de la lecture sur le flux choisi) ; ⏳ mode **« auto » intelligent** (choix automatique du flux selon la bande passante)
* 🟡 **Traduction UI (i18n)** — ✅ socle FR/EN (`src/services/i18n.service.ts`, chaque clé déclarée des deux côtés) couvrant barre de recherche, barre de filtres, pages thèmes ; ⏳ couverture **élargie** aux écrans encore en texte FR hardcodé (`/watch`, `/shorts`, bibliothèque)
@@ -290,7 +292,6 @@ Légende : **✅ livré** (câblé de bout en bout) · **🟡 partiellement livr
* ⏳ Mode **« TV »**
* ⏳ **Theming avancé** (polices, densité, accents)
* ⏳ **Playlists natives Dailymotion & PeerTube** (les 3 autres fournisseurs sont en **N/A** — voir la matrice : API Twitch supprimée, Odysee/Rumble sans API d'écriture) : inscription d'app OAuth Dailymotion, JWT par instance PeerTube
* ⏳ **Logs & Prometheus** (volet restant de l'Observabilité — la page Admin est livrée)
* ⏳ **Nouveaux fournisseurs** — plan détaillé ci-dessous
### 🆕 Nouveaux fournisseurs — plan d'implémentation
+1 -1
View File
@@ -17,7 +17,7 @@
"browser": "index.tsx",
"tsConfig": "tsconfig.json",
"assets": [
"assets",
{ "glob": "**/*", "input": "assets", "output": "assets", "ignore": ["config.local.js"] },
"public",
"index.css",
"index.html",
+17 -17
View File
@@ -1,15 +1,16 @@
// Local (non-versioned) config for API keys.
// Copy this file to `assets/config.local.js` and fill in your keys.
// IMPORTANT: Restrict keys in their provider consoles (HTTP referrers, APIs enabled).
// Local (non-versioned) config for BROWSER-SAFE values only.
// Copy this file to `assets/config.local.js` to override a browser-safe value.
// Optional: the server already generates its own /assets/config.local.js from
// its environment and serves it BEFORE any static file.
//
// SECURITY NOTE: everything set here is shipped to the browser. Only put
// browser-safe, referrer-restricted keys in this file. Server-only secrets
// (TWITCH_CLIENT_SECRET, GEMINI_API_KEY, JWT_SECRET...) must be provided via
// environment variables on the backend instead — the app consumes them through
// /api/twitch-token and /api/ai/* endpoints.
// YouTube Data API v3 key (browser-safe only if properly restricted by HTTP referrer)
window.YOUTUBE_API_KEY = 'PUT_YOUR_YOUTUBE_API_KEY_HERE';
// SECURITY NOTE: everything set here is shipped to the browser. NEVER put a
// secret in this file. Server-only keys — YOUTUBE_API_KEY(S), TWITCH_CLIENT_SECRET,
// GEMINI_API_KEY, RUMBLE_API_KEY, VIMEO_ACCESS_TOKEN, JWT_SECRET — belong in the
// server environment (docker-compose/.env); the app consumes them through
// /api/yt/*, /api/twitch-token and /api/ai/* endpoints.
//
// The image build skips this file entirely (not copied into the image, not
// bundled into dist/browser/assets), so it can never leak into production.
// Dailymotion Player ID (required since Feb 2026: embeds without a Player ID
// return HTTP 403 "Forbidden"). Create a Player in Dailymotion Studio
@@ -18,14 +19,13 @@ window.YOUTUBE_API_KEY = 'PUT_YOUR_YOUTUBE_API_KEY_HERE';
// If unset, the app falls back to a public demo player ID (x8lr5).
// window.DAILYMOTION_PLAYER_ID = 'x8lr5';
// Optional: you can also provide multiple keys (CSV) via server env YOUTUBE_API_KEYS.
// Rumble API key (optional — Rumble now works via server-side scraping)
// window.RUMBLE_API_KEY = 'PUT_YOUR_RUMBLE_API_KEY_HERE';
// YouTube: the key lives on the server only (YOUTUBE_API_KEY / YOUTUBE_API_KEYS
// in docker-compose/.env) — requests go through /api/yt, never through here.
// Odysee: no API key is required when using the built-in proxy.
// Rumble: server-side scraping, no key required.
// Twitch: configured via server env (TWITCH_CLIENT_ID / TWITCH_CLIENT_SECRET).
// Gemini: configured via server env (GEMINI_API_KEY).
// Twitch: TWITCH_CLIENT_ID (public) may be mirrored here, the secret stays on
// the server. Gemini: server env GEMINI_API_KEY only.
// You can add other browser-safe provider keys/tokens here if needed in the future.
+19
View File
@@ -0,0 +1,19 @@
-- Clés d'API pour les clients hors navigateur (production-ready, P0-3).
--
-- Le jeton en clair n'est JAMAIS stocké : seule l'empreinte SHA-256 l'est, le
-- jeton complet n'est affiché qu'une seule fois à la création. Le préfixe sert
-- à l'affichage / identification sans exposer le secret.
CREATE TABLE IF NOT EXISTS api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE, -- sha256hex du jeton complet
prefix TEXT NOT NULL, -- 8 premiers caractères, affichables
created_at TEXT NOT NULL,
last_used_at TEXT,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
+9
View File
@@ -13,6 +13,15 @@ TWITCH_CLIENT_ID=votre_client_id_twitch_ici
TWITCH_CLIENT_SECRET=votre_client_secret_twitch_ici
# TWITCH_REDIRECT_URI=http://localhost:4200/api/oauth/twitch/callback
# --- API ouverte aux clients tiers (P0/P1 production-ready) ---
# Origines autorisées en CORS (CSV) : ajouter l'URL publique de l'app pour des
# clients navigateur tiers ; les clients curl/script ne sont pas concernés.
# API_ALLOWED_ORIGINS=http://localhost:4200,http://localhost:4000,http://localhost:3000
# Si défini, /metrics exige Authorization: Bearer <valeur> (ou ?token=)
# METRICS_TOKEN=
# Appels /api/details par minute (yt-dlp, défaut 60)
# DETAILS_RATE_LIMIT=60
# Google OAuth (import abonnements + favoris YouTube, lecture seule)
# GOOGLE_CLIENT_ID=votre_client_id_google_ici
# GOOGLE_CLIENT_SECRET=votre_client_secret_google_ici
+3 -7
View File
@@ -51,13 +51,9 @@ COPY --from=builder /app/db/migrations /app/db-migrations/
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
# Copy config
COPY --from=builder /app/assets/config.local.js ./assets/config.local.js
# Après la ligne COPY pour config.local.js
RUN ls -la /app/assets/ && \
echo "Contenu de config.local.js:" && \
cat /app/assets/config.local.js || echo "Fichier config.local.js non trouvé"
# Copy config (browser-safe only). config.local.js is intentionally NOT shipped:
# the server generates /assets/config.local.js from its env, and any local copy
# may contain secrets that must never reach the browser.
# Expose API/web port
EXPOSE 4000
+3 -1
View File
@@ -67,7 +67,9 @@
}
</script>
<link rel="stylesheet" href="/index.css">
<!-- Local, non-versioned config (define YOUTUBE_API_KEY, GEMINI_API_KEY, etc.) -->
<!-- Local, non-versioned config for BROWSER-SAFE values (DAILYMOTION_PLAYER_ID, TWITCH_CLIENT_ID).
Server-only secrets (YOUTUBE_API_KEY, TWITCH_CLIENT_SECRET, GEMINI_API_KEY…) stay in the
server env and are served through dedicated endpoints — never here. -->
<script src="assets/config.local.js"></script>
<!-- Dynamically generated config from environment variables -->
<script src="assets/config.js"></script>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "newtube",
"private": true,
"version": "0.0.0",
"version": "1.0.59",
"type": "module",
"scripts": {
"dev": "ng serve",
+30
View File
@@ -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 }) {
+216 -43
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;
}
const requestLogger = (req, res, next) => {
if (IS_PROD) {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
return next();
// --- 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}` || '/';
}
console.log(`[${new Date().toISOString()}] ${req.method} ${req.originalUrl}`);
} 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) => {
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));
}
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);
});
+94 -1
View File
@@ -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);
});
});
+9 -7
View File
@@ -1,10 +1,12 @@
/**
* Version officielle de l'application NewTube, affichée dans le menu du compte
* (header) et le pied de page « À propos ».
* Version officielle de l'application NewTube (menu du compte, pied de page
* « À propos »).
*
* Source unique côté front : elle suit le tag semver publié par
* `docker/deploy-img.sh` (le registre incrémente le patch à chaque déploiement).
* Dernier tag publié avant cette livraison : 1.0.58 → cette livraison sort en
* 1.0.59. Bumper cette valeur à chaque livraison.
* Source UNIQUE : `package.json`. La même valeur alimente `info.version` de
* `/api/openapi.json` (server/index.mjs lit `../package.json`) et doit suivre
* le tag semver publié par `docker/deploy-img.sh` — bumper package.json à
* chaque livraison suffit alors à tout aligner.
*/
export const APP_VERSION = '1.0.59';
import * as pkg from '../../package.json';
export const APP_VERSION: string = (pkg as { version?: string }).version || '0.0.0';
+1 -1
View File
@@ -116,7 +116,7 @@ export class HomeComponent {
if (!readiness2.ready) {
this.notice.set(readiness2.reason || 'Le provider sélectionné n\'est pas prêt.');
} else if (provider === 'youtube') {
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez que votre YOUTUBE_API_KEY est valide et que les restrictions HTTP referrer incluent http://localhost:4200/*.');
this.notice.set('Aucune vidéo tendance YouTube chargée. Vérifiez la clé YOUTUBE_API_KEY côté serveur (docker-compose/.env) : elle n\'est plus exposée au navigateur.');
} else if (provider === 'peertube') {
const inst = this.instances.activePeerTubeInstance();
this.notice.set(`PeerTube: les vidéos ne sont pas disponibles depuis l'instance "${inst}" pour le moment. Essayez une autre instance dans l'en-tête.`);
+14 -9
View File
@@ -26,6 +26,9 @@ export class InstanceService {
// Disponibilité Twitch connue via le serveur (le secret reste côté back)
private twitchReadySignal = signal<boolean | null>(null); // null = inconnu
// Disponibilité YouTube : la clé vit aussi côté serveur (P0 sécurité), on la
// demande à /healthz une fois au démarrage. null = réponse pas encore reçue.
private youtubeReadySignal = signal<boolean | null>(null);
private storedRegion = ((): string | null => {
try { return localStorage.getItem(LS_REGION_KEY); } catch { return null; }
@@ -43,6 +46,13 @@ export class InstanceService {
next: (res) => this.twitchReadySignal.set(Boolean(res?.accessToken)),
error: () => this.twitchReadySignal.set(false),
});
// Idem pour YouTube : une clé configurée côté serveur (YOUTUBE_API_KEY).
// /healthz expose `youtube.keys.count` sans jamais révéler la valeur.
this.http.get<any>('/api/healthz').subscribe({
next: (h) => this.youtubeReadySignal.set(Number(h?.youtube?.keys?.count || 0) > 0),
// Indéterminé : on laisse passer, l'appel /api/yt rendra l'erreur réelle.
error: () => this.youtubeReadySignal.set(true),
});
}
readonly selectedProviderLabel = computed(() => {
@@ -57,15 +67,10 @@ export class InstanceService {
const g: any = globalThis as any;
if (p === 'youtube') {
const hasKey = !!(
g?.YOUTUBE_API_KEY ||
g?.process?.env?.YOUTUBE_API_KEY ||
((typeof import.meta !== 'undefined' && (import.meta as any).env)
? ((import.meta as any).env.YOUTUBE_API_KEY || (import.meta as any).env.NG_APP_YOUTUBE_API_KEY)
: undefined)
);
if (!hasKey) {
return { ready: false, reason: 'YouTube is selected but YOUTUBE_API_KEY is missing. Create assets/config.local.js with window.YOUTUBE_API_KEY = "..." and reload.' };
// La clé ne shippe plus dans le navigateur (P0 sécurité) : on se fie à la
// sonde /healthz faite au démarrage. false = aucune clé côté serveur.
if (this.youtubeReadySignal() === false) {
return { ready: false, reason: 'YouTube needs YOUTUBE_API_KEY set on the server (docker-compose/.env) — API keys are no longer shipped to the browser.' };
}
return { ready: true };
}
+23 -142
View File
@@ -25,18 +25,9 @@ export class YoutubeApiService {
private searchCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
private trendingCache = new Map<string, { ts: number; data: PagedResult<Video> }>();
// ---- YouTube multi-key rotation ----
private ytKeys: string[] = [];
private ytKeyIndex = 0;
private ytKeyBans = new Map<string, number>(); // key -> bannedUntil epoch ms
// Ban duration (default 6h) can be overridden via window.YT_KEY_BAN_MS
private readonly YT_KEY_BAN_MS = (() => {
try { const v = Number((globalThis as any)?.YT_KEY_BAN_MS); return Number.isFinite(v) && v > 0 ? v : 6 * 60 * 60 * 1000; } catch { return 6 * 60 * 60 * 1000; }
})();
constructor(private http: HttpClient, private instances: InstanceService, private i18n: I18nService, private users: UserService) {
this.getTwitchAuthToken().subscribe(this.twitchAuthToken$);
this.ytKeys = this.loadYouTubeKeys();
}
/**
@@ -508,11 +499,8 @@ export class YoutubeApiService {
const region = this.instances.region();
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) {
console.warn('YOUTUBE_API_KEY not found. Set it to enable YouTube trending.');
return of({ items: [], nextCursor: null });
}
// Pas de clé côté client : /api/yt injecte la clé serveur (rotation,
// quota, cache). Clé absente ou épuisée = 503 renvoyé par le serveur.
const params = new URLSearchParams({
chart: 'mostPopular',
regionCode: String(region),
@@ -523,7 +511,6 @@ export class YoutubeApiService {
safeSearch: 'moderate',
// Video category for trending (Music = 10, Gaming = 20, etc.)
videoCategoryId: '0', // All categories
key: String(key),
});
if (cursor) params.set('pageToken', cursor);
@@ -535,7 +522,7 @@ export class YoutubeApiService {
return of(cached.data);
}
const path = `/youtube/v3/videos`;
return this.fetchYouTube(path, params, key).pipe(
return this.fetchYouTube(path, params).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeItemToVideo(i)),
nextCursor: res.nextPageToken || null,
@@ -545,15 +532,12 @@ export class YoutubeApiService {
return out;
}),
catchError((e) => {
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
// Surface quotaExceeded (erreur quota remontée par le serveur /api/yt)
try {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube trending error', e);
@@ -660,8 +644,7 @@ export class YoutubeApiService {
if (!query) return of({ items: [], nextCursor: null });
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) return of({ items: [], nextCursor: null });
const params = new URLSearchParams({
type: 'video',
// search.list only supports 'snippet'
@@ -674,7 +657,6 @@ export class YoutubeApiService {
safeSearch: 'moderate',
// Only return videos playable in an iframe (avoids player error 153).
videoEmbeddable: 'true',
key: String(key),
});
// Prefer results matching user's language when available
try {
@@ -690,7 +672,7 @@ export class YoutubeApiService {
return of(cached.data);
}
const path = `/youtube/v3/search`;
return this.fetchYouTube(path, params, key).pipe(
return this.fetchYouTube(path, params).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
nextCursor: res.nextPageToken || null,
@@ -716,15 +698,12 @@ export class YoutubeApiService {
);
}),
catchError((e) => {
// Surface quotaExceeded if present + ban dead keys (quota ou clé expirée/invalide)
// Surface quotaExceeded (erreur quota remontée par le serveur /api/yt)
try {
const reason = e?.error?.error?.errors?.[0]?.reason || '';
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube search error', e);
@@ -955,8 +934,7 @@ export class YoutubeApiService {
*/
public searchYouTubeLivePage(query?: string, cursor?: string | null): Observable<PagedResult<Video>> {
const region = this.instances.region();
const key = this.getActiveYouTubeKey();
if (!key) return of({ items: [], nextCursor: null });
const q = String(query || '').trim() || 'live';
const params = new URLSearchParams({
type: 'video',
@@ -968,7 +946,6 @@ export class YoutubeApiService {
videoEmbeddable: 'true',
eventType: 'live',
order: 'viewCount',
key: String(key),
});
if (cursor) params.set('pageToken', cursor);
const cacheKey = `yt:live|${region}|${q}|${cursor || ''}`;
@@ -978,7 +955,7 @@ export class YoutubeApiService {
return of(cached.data);
}
const path = `/youtube/v3/search`;
return this.fetchYouTube(path, params, key).pipe(
return this.fetchYouTube(path, params).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i, true)),
nextCursor: res.nextPageToken || null,
@@ -1006,9 +983,6 @@ export class YoutubeApiService {
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube live search error', e);
@@ -1035,8 +1009,7 @@ export class YoutubeApiService {
const q = String(query || '').trim() || 'shorts';
switch (provider) {
case 'youtube': {
const key = this.getActiveYouTubeKey();
if (!key) return of({ items: [], nextCursor: null });
const params = new URLSearchParams({
type: 'video',
part: 'snippet',
@@ -1048,7 +1021,6 @@ export class YoutubeApiService {
// Le filtre clé : sans lui, "shorts" ne retourne que des vidéos longues.
videoDuration: 'short',
order: 'viewCount',
key: String(key),
});
try {
// Langue de l'utilisateur en priorité (relevanceLanguage YouTube).
@@ -1063,7 +1035,7 @@ export class YoutubeApiService {
return of(cached.data);
}
const path = `/youtube/v3/search`;
return this.fetchYouTube(path, params, key).pipe(
return this.fetchYouTube(path, params).pipe(
map(res => ({
items: (res.items || []).map((i: any) => this.mapYouTubeSearchItemToVideo(i)),
nextCursor: res.nextPageToken || null,
@@ -1093,9 +1065,6 @@ export class YoutubeApiService {
const msg = String(e?.error?.error?.message || e?.message || '');
if (e?.status === 403 && (reason === 'quotaExceeded' || /quota/i.test(msg))) {
this.quotaExceeded$.next(true);
this.banYouTubeKey(key);
} else if (e?.status === 400 && (reason === 'API_KEY_INVALID' || /api key (expired|invalid)/i.test(msg))) {
this.banYouTubeKey(key);
}
} catch {}
console.error('YouTube shorts search error', e);
@@ -2022,77 +1991,6 @@ export class YoutubeApiService {
return h * 3600 + mn * 60 + s;
}
private resolveYouTubeApiKey(): string | undefined {
const g: any = globalThis as any;
const fromGlobal = g?.YOUTUBE_API_KEY;
const fromImportMeta = (typeof import.meta !== 'undefined' && (import.meta as any).env)
? ((import.meta as any).env.YOUTUBE_API_KEY || (import.meta as any).env.NG_APP_YOUTUBE_API_KEY)
: undefined;
const fromProcess = (g?.process?.env) ? (g.process.env.YOUTUBE_API_KEY || g.process.env.NG_APP_YOUTUBE_API_KEY) : undefined;
return fromGlobal || fromImportMeta || fromProcess || undefined;
}
// ---- Multi-key helpers ----
private loadYouTubeKeys(): string[] {
const g: any = globalThis as any;
const keys: string[] = [];
try {
// Prefer explicit array window.YOUTUBE_API_KEYS
const arr = Array.isArray(g?.YOUTUBE_API_KEYS) ? g.YOUTUBE_API_KEYS : null;
if (arr && arr.length) keys.push(...arr.map((s: any) => String(s).trim()).filter(Boolean));
} catch {}
try {
// Comma-separated string support
const csv = g?.YOUTUBE_API_KEYS;
if (!Array.isArray(csv) && typeof csv === 'string' && csv.includes(',')) {
keys.push(...csv.split(',').map((s: string) => s.trim()).filter(Boolean));
}
} catch {}
try {
// Env-based multi-keys
const im: any = (typeof import.meta !== 'undefined' && (import.meta as any).env) ? (import.meta as any).env : undefined;
const fromEnvCsv = im?.YOUTUBE_API_KEYS || im?.NG_APP_YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.YOUTUBE_API_KEYS || (globalThis as any)?.process?.env?.NG_APP_YOUTUBE_API_KEYS;
if (typeof fromEnvCsv === 'string' && fromEnvCsv.includes(',')) {
keys.push(...fromEnvCsv.split(',').map((s: string) => s.trim()).filter(Boolean));
}
} catch {}
// Fallback single key
const single = this.resolveYouTubeApiKey();
if (single) keys.push(String(single));
// De-duplicate and return
const uniq = Array.from(new Set(keys.filter(Boolean)));
return uniq;
}
private isKeyBanned(k: string): boolean {
const until = this.ytKeyBans.get(k) || 0;
return Date.now() < until;
}
private banYouTubeKey(k: string) {
if (!k) return;
const until = Date.now() + this.YT_KEY_BAN_MS;
this.ytKeyBans.set(k, until);
}
private getActiveYouTubeKey(): string | undefined {
// If no keys loaded, attempt to load once
if (!this.ytKeys || this.ytKeys.length === 0) this.ytKeys = this.loadYouTubeKeys();
const n = this.ytKeys.length;
if (!n) return undefined;
// Round-robin until we find a non-banned key
for (let i = 0; i < n; i++) {
const idx = (this.ytKeyIndex + i) % n;
const candidate = this.ytKeys[idx];
if (!this.isKeyBanned(candidate)) {
this.ytKeyIndex = (idx + 1) % n; // advance pointer for next call
return candidate;
}
}
// All keys currently banned
return undefined;
}
private resolveTwitchClientId(): string | undefined {
const g: any = globalThis as any;
return this.twitchServerClientId || g?.TWITCH_CLIENT_ID || (typeof import.meta !== 'undefined' && (import.meta as any).env?.TWITCH_CLIENT_ID) || g?.process?.env?.TWITCH_CLIENT_ID;
@@ -2162,26 +2060,15 @@ export class YoutubeApiService {
return totalSeconds;
}
// Dev proxy helpers with fallback to direct URLs (returns Observable<any>)
private fetchYouTube(path: string, params: URLSearchParams, _activeKey?: string) {
// Serveur d'abord : `/api/yt` injecte la clé serveur ET gère rotation/quota/
// cache ; `/proxy/api/yt` est le proxy de dev. Aucun appel direct à
// googleapis : les clés ne quittent plus le serveur (P0 sécurité).
private fetchYouTube(path: string, params: URLSearchParams) {
const qs = params.toString();
const cachedProd = `/api/yt${path}?${qs}`; // server cache in prod
const cachedDev = `/proxy/api/yt${path}?${qs}`; // dev proxy to server cache
const direct = `https://www.googleapis.com${path}?${qs}`;
const proxied = `/proxy/yt${path}?${qs}`;
// Try prod server cache → dev server cache → direct → dev proxy
return this.http.get<any>(cachedProd).pipe(
catchError(() => this.http.get<any>(cachedDev).pipe(
catchError((_e1) => this.http.get<any>(direct).pipe(
catchError((err) => {
console.warn('YouTube direct call failed, trying /proxy/yt...', err);
return this.http.get<any>(proxied);
})
))
))
);
const prod = `/api/yt${path}?${qs}`;
const dev = `/proxy/api/yt${path}?${qs}`;
return this.http.get<any>(prod).pipe(catchError(() => this.http.get<any>(dev)));
}
private fetchDailymotion(path: string, params: URLSearchParams) {
const prod = `/api/dm${path}?${params.toString()}`;
const proxied = `/proxy/dm${path}?${params.toString()}`;
@@ -2209,18 +2096,14 @@ export class YoutubeApiService {
* a -1 marker so Shorts/feed callers can drop them (player error 153).
*/
public getYouTubeDurations(ids: string[]): Observable<Record<string, number>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) {
return of({});
}
if (!ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
part: 'contentDetails,status',
id: ids.join(','),
});
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
return this.fetchYouTube('/youtube/v3/videos', params).pipe(
map((res: any) => Array.isArray(res?.items) ? res.items : []),
map((items: any[]) => items.reduce((acc: Record<string, number>, item: any) => {
const duration = item?.contentDetails?.duration || 'PT0S';
@@ -2243,10 +2126,8 @@ export class YoutubeApiService {
* Non-embeddable entries get duration -1 so callers can drop them (error 153).
*/
private getYouTubeContentAndStats(ids: string[]): Observable<Record<string, { duration: number; views: number; language?: string }>> {
const apiKey = this.getActiveYouTubeKey();
if (!apiKey || !ids.length) return of({});
if (!ids.length) return of({});
const params = new URLSearchParams({
key: apiKey,
// `snippet` fournit `defaultAudioLanguage` : seul signal de langue
// exploitable pour filtrer les Shorts FR/EN.
part: 'contentDetails,statistics,status,snippet',
@@ -2255,7 +2136,7 @@ export class YoutubeApiService {
// Add safeSearch for consistency
safeSearch: 'moderate',
});
return this.fetchYouTube('/youtube/v3/videos', params, apiKey).pipe(
return this.fetchYouTube('/youtube/v3/videos', params).pipe(
map((res: any) => Array.isArray(res?.items) ? res.items : []),
map((items: any[]) => {
const out: Record<string, { duration: number; views: number; language?: string }> = {};
+1
View File
@@ -14,6 +14,7 @@
"downlevelIteration": true,
"experimentalDecorators": true,
"moduleResolution": "node",
"resolveJsonModule": true,
"importHelpers": true,
"target": "ES2022",
"module": "ES2022",