diff --git a/README.md b/README.md index ec88e2d..6e05618 100644 --- a/README.md +++ b/README.md @@ -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` (`_` → `` → défaut, valeurs invalides ignorées, jamais de TTL nul) branché sur les caches **details** (`DETAILS_CACHE_TTL_MS_

`) et **transcripts** (`TRANSCRIPT_CACHE_TTL_

`) en plus de la recherche (`SEARCH_CACHE_TTL_MS_

`). 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 diff --git a/angular.json b/angular.json index a3f8150..49bb51a 100644 --- a/angular.json +++ b/angular.json @@ -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", diff --git a/assets/config.local.example.js b/assets/config.local.example.js index c66fa43..06f4454 100644 --- a/assets/config.local.example.js +++ b/assets/config.local.example.js @@ -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. diff --git a/db/migrations/20261002_add_api_keys.sql b/db/migrations/20261002_add_api_keys.sql new file mode 100644 index 0000000..588fa0b --- /dev/null +++ b/db/migrations/20261002_add_api_keys.sql @@ -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); diff --git a/docker-compose/.env.example b/docker-compose/.env.example index 1445705..01cca7b 100644 --- a/docker-compose/.env.example +++ b/docker-compose/.env.example @@ -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 (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 diff --git a/docker/Dockerfile.origi b/docker/Dockerfile.origi index bb0c278..399fe49 100644 --- a/docker/Dockerfile.origi +++ b/docker/Dockerfile.origi @@ -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 diff --git a/index.html b/index.html index c4cbc02..0d17850 100644 --- a/index.html +++ b/index.html @@ -67,7 +67,9 @@ } - + diff --git a/package.json b/package.json index 10c86b3..5c3f6e7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "newtube", "private": true, - "version": "0.0.0", + "version": "1.0.59", "type": "module", "scripts": { "dev": "ng serve", diff --git a/server/db.mjs b/server/db.mjs index d77cf4c..0ac39fc 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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 }) { diff --git a/server/index.mjs b/server/index.mjs index 51085f5..2874af7 100644 --- a/server/index.mjs +++ b/server/index.mjs @@ -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 ` 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 ` (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); }); diff --git a/server/tests/api_coverage.test.mjs b/server/tests/api_coverage.test.mjs index 26a6278..817c958 100644 --- a/server/tests/api_coverage.test.mjs +++ b/server/tests/api_coverage.test.mjs @@ -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); + }); +}); diff --git a/src/app/version.ts b/src/app/version.ts index 4fa35da..a3a6608 100644 --- a/src/app/version.ts +++ b/src/app/version.ts @@ -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'; diff --git a/src/components/home/home.component.ts b/src/components/home/home.component.ts index b6255d9..f8aa6fb 100644 --- a/src/components/home/home.component.ts +++ b/src/components/home/home.component.ts @@ -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.`); diff --git a/src/services/instance.service.ts b/src/services/instance.service.ts index d6fbb2f..5b75318 100644 --- a/src/services/instance.service.ts +++ b/src/services/instance.service.ts @@ -26,6 +26,9 @@ export class InstanceService { // Disponibilité Twitch connue via le serveur (le secret reste côté back) private twitchReadySignal = signal(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(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('/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 }; } diff --git a/src/services/youtube-api.service.ts b/src/services/youtube-api.service.ts index 911f435..46aabf8 100644 --- a/src/services/youtube-api.service.ts +++ b/src/services/youtube-api.service.ts @@ -25,18 +25,9 @@ export class YoutubeApiService { private searchCache = new Map }>(); private trendingCache = new Map }>(); - // ---- YouTube multi-key rotation ---- - private ytKeys: string[] = []; - private ytKeyIndex = 0; - private ytKeyBans = new Map(); // 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> { 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) - 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(cachedProd).pipe( - catchError(() => this.http.get(cachedDev).pipe( - catchError((_e1) => this.http.get(direct).pipe( - catchError((err) => { - console.warn('YouTube direct call failed, trying /proxy/yt...', err); - return this.http.get(proxied); - }) - )) - )) - ); + const prod = `/api/yt${path}?${qs}`; + const dev = `/proxy/api/yt${path}?${qs}`; + return this.http.get(prod).pipe(catchError(() => this.http.get(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> { - 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, 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> { - 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 = {}; diff --git a/tsconfig.json b/tsconfig.json index 92d00a3..a3425f6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ "downlevelIteration": true, "experimentalDecorators": true, "moduleResolution": "node", + "resolveJsonModule": true, "importHelpers": true, "target": "ES2022", "module": "ES2022",