mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement Gros lot de travail synchronisé depuis le repo de dev. Les grands axes : migration de domaine (movix.date + miroirs), deux nouvelles sources films/séries (1jour1film, Cinestream) et une n ouvelle source livetv, un fournisseur OAuth « Se connecter avec Movix », une modération de contenu par IA revue, plus un lot de durcissement mémoire/réseau, sécurité et perf côté API et front. ## 🌐 Domaines & miroirs - Domaine canonique `movix.chat`/`movix.golf`/`movix.tax` → **movix.date** partout (front, allow-lists CORS/domaine, Discord RPC). Miroir `movix.health` → **movix.online** (Footer, Header, pages d'aide CaMarchePlus/Dns/Miroirs). Lien Telegram du footer → `mysticsaba.com`. ~10 clés i18n mises à jour. ## 🎬 Sources de streaming (VOD) - **J1F (1jour1film)** — nouvelle source de bout en bout (`routes/j1f.js`, monté sur `/api/j1f`). Résout le domaine tournant via une page `/go/`, décode les blobs base64 `J1F_SRV`/`j1fEpsData` (Dooplay/WP), ne garde que les players uniques, ignore les agrégateurs (frembed/vidsrc/videasy). Cache stale-while-revalidate dédié. Players 1J1F lus via l'extension/userscript. - **Cinestream** — nouvelle source films (`routes/cinestream.js`, scrape Next.js `cinestream.info`, match par id TMDB autoritaire). Wiflix bascule ses **films** sur Cinestream (bot-shield flemmix pénible sur les films) ; la **TV** reste sur flemmix. - **Wiflix** — `flemmix.farm` → **`flemmix.golf`** ; handshake cookie (`makeWiflixSearchRequest`, `h_check=25`) ; sélecteurs de template corrigés. - **Cpasmal** — axios+proxy → `makeCpasmalRequest` (CycleTLS JA3) car Cloudflare 403 ; recherche GET → POST form (le WAF bloque le GET). - **Fstream** — cookie anti-bot `fsschal` ; source épisodes JS statique (`/static/series/<id>.js`) avant l'AJAX `episodes_p.php` ; fallback `get_seasons.php`. - **FranceTV** — `fetchSeasonEpisodesViaDeepPage` (pagination JSON « Afficher plus »). - **Coflix** — `coflix.date` → `coflix.trade` ; logs 429 silencés en double. - **proxyManager** — cinq helpers de contournement Cloudflare exportés (`makeCinestreamRequest`, `make1j1fRequest`, `makeCpasmalRequest`, handshake Wiflix `makeWiflixSearchRequest`/`wiflixHandshake`, `classifyCloudflare429` worker-vs-site) ; CycleTLS + rotation ProxyScrape, ne throw jamais sur statut HTTP. Coflix/LecteurVideo quittent les Cloudflare Workers pour ProxyScrape. ## 📺 Live TV — remplacement de Sosplay/Vavoo par FCTV33 - Source sport **Sosplay → FCTV33** (API RBTV, protobuf brut) réécrite de bout en bout dans `liveTvRoutes.js`, **Vavoo retiré** (manifest/catalogue/stream) — tout passe par `/api/livetv/*`. - Pipeline neuf : décodeur protobuf maison, signature `sfver` (bs keys + MD5), auto-découverte des domaines API/player tournants (`hubu.ru/fctvlink` + Nuxt SSR / `params` rot47, cache 30 min), scraper de matchs (`scrapeFctvMatches` : équipes/logos, ligue, pays, statut live, tri live-first), résolution de flux (HLS natif d'abord, embed en secours), token CDN (XOR keystream + base64, gate `rb-session`). - Route `GET /api/livetv/fctv/playlist` : proxy m3u8 intelligent (résout le token, réécrit les segments, retry sur token périmé, modes `raw` extension / `proxy` VIP). - **Matchs plus réservés aux VIP** (seul `iptv_` le reste) : flux natif proxifié pour les VIP, stub `_fctvLocal` résolu côté client par l'extension/userscript (`RESOLVE_FCTV`), fallback embed sinon. - Front `LiveTV.tsx` : `matches` → `fctv`, UI accordéon par match (logos, badges ligue/pays, score/timer live, favori), disclaimer, fix icône `music`. Rotations d'hôtes : `witv.team`→`witv.football`, `livetv876.me`→`livetv901.me`. ## 🧩 Extensions · Userscript · Discord RPC - **FCTV en lecture native** : handlers `SETUP_FCTV_HEADERS`/`RESOLVE_FCTV` (règle DNR Referer/Origin/UA sur `/cfall/s*` + résolveur local IP-bindé : protobuf-lite + rot47 + token XOR). - **Vavoo retiré** , `cinepulse.lol` → `purstream.mx`. - Fix extracteur **Sibnet** (`Range: bytes=0-0`) ; fix bug userscript `matchToRegex` (patterns ancrés `^…$` cassant `/cfall/s` → dé-ancrés) ; override dev `localhost:25565`. - Bumps de version (Chrome 1.3.3→1.3.8, Firefox 1.5.1→1.5.5, userscript 1.4.0→1.4.7) ; PreMid nouveau `clientId` Discord + `regExp` élargi (1.0.3→1.0.4). ## 🔑 OAuth (fournisseur « Se connecter avec Movix ») & codes d'accès - Refonte du flux d'autorisation OAuth (`OAuthAuthorizePage.tsx`) + routes (`routes/authRoutes.js`, `routes/oauth.js`) et stockage des clients OAuth étoffé (`utils/oauthClientsDb.js`, `routes/adminOauthApps.js` pour la gestion admin des apps). - `AccessCodeForm.tsx` retouché en cohérence avec le nouveau flux ; `checkVip.js` ajusté. - Session centralisée : `accountAuth.ts` expose `clearStoredAuthSession()` / `broadcastAuthChange()` (liste `AUTH_KEYS` unifiée), désormais utilisés partout à la déconnexion. ## 💬 Modération de contenu (IA) - Modèle OpenRouter `google/gemini-2.5-flash-lite` → `deepseek/deepseek-v4-flash` (`commentsRoutes.js`, `sharedListsRoutes.js`) ; fonctions/logs renommés `moderateContentWithGemini` → `moderateContentWithAI` (découplés du nom du modèle). - Prompt durci (critères INSULTES / DEMANDE_AJOUT resserrés) pour éviter les faux positifs sur questions, signalements de bug et plaintes de contenu manquant. - `AdminComments.tsx` : libellé « par Gemini » → clé i18n générique. ## 🧊 Hydracker / Darkiworld - **Sortie du debrid** : AllDebrid/RealDebrid supprimés (`utils/hydrackerLive.js` : branche provider retirée ; vars `ALLDEBRID_*`/`REALDEBRID_*` sorties du `.env`) — hydracker renvoie directement `rawUrl`/`directDL`. Liens servis depuis les snapshots SQLite locaux (`utils/darkiworldSqlite.js`, `darkino-backups/`). - **Anti-scraping** sur `/decode/:id` (`routes/darkiworld.js`) : watchlist + alerte webhook Discord (`DISCORD_SCRAPER_WEBHOOK`) et empoisonnement/blocage de réponse pour les IP listées (`SCRAPER_BLOCKED_IPS`). - **Mémoire** : cache d'agents proxy borné (`utils/lruMap.js`) qui détruit les sockets des agents évincés. ## 🛠️ Admin — propriété des liens & équipe - **Propriété des liens** : les liens streaming gagnent l'attribution `added_by` et l'ownership est appliquée des deux côtés (uploader = ses liens, admin = tous). GET public masqué (`stripLinkOwner`), `GET /admin/streaming-links/:type/:id` authentifié conserve le tag ; `DELETE /admin/links` par URL unique ; `PUT /admin/links` masse admin-only. - **Équipe** : `GET/POST/DELETE /admin/admins` + `GET /admin/team/history` (audit), identité via `adminIdentity.js` (option `preferProfile` → premier profil Movix). UI `AdminTeam.tsx`. - **Privilèges** (`middleware/auth.js`) : `isAdmin` rejette `role !== 'admin'` (403) — les uploaders ne passent plus les routes admin-only. Migration `add_role_to_admins.sql`. ## 🎁 Wrapped v2 - Cartes de partage : logique canvas extraite (`utils/wrappedCanvas.ts`, `utils/wrappedShareCards.ts`), formats de partage supplémentaires + preview. - Nouvelles slides : jour record, répartition par jour de semaine, champion de rewatch, âge ciné (année de sortie médiane pondérée par le temps de visionnage). - Perf : données depuis le payload backend (zéro re-fetch TMDB), trailer lazy, percentile non bloquant (réchauffe en arrière-plan, lock Redis `NX`), shuffle seedé. ## 🔐 Auth · Profils · VIP - **Pseudo forcé** : `validateProfileName` (1–32 chars, strip control/zero-width, + `maxLength={32}` côté UI Profile/WatchParty) ; les pseudos OAuth trop longs → `requiresUsernameChange` + modale bloquante `RequireUsernameChange.tsx`. - **Sync durci** (`ProfileContext.tsx`, `syncPolicy.js`) : anti-replay cross-user, filtrage des ops à clés non-syncables (`access_code`/`access_code_expires` exclues), « wipe guard » qui préserve le local sur profil vide inattendu, dédupe des `loadProfiles()` concurrents. Logout centralisé (voir OAuth). - **VIP** : `is_vip` préservé au chargement de profil (corrige un downgrade VIP inter-onglets qui relançait la popup pub) ; `vipDonations.js` poll de secours `payment-status.php` (via `ipn_token`, colonne `paygate_ipn_token`) → plus d'invoices bloquées. ## ▶️ Lecteur (HLS) - Basse latence opt-in : `isLowLatencyEnabled('movies')` (scope movies/livetv, `lowLatencyPref.ts`) au lieu du mode forcé ; `FloatingPlayer` aligné. - Sous-titres cinep : wrapper m3u8 synthétique pour les URI `.vtt`/`.srt` directes, `isSubtitleLoadError` (une piste cassée ne tue plus la vidéo), rendu `<i>/<b>/<u>`. - Menu sources regroupé par langue (VF/VFQ/VFF/VOSTFR). ## 📡 Cast (AirPlay / Chromecast) - Retrait du contournement proxy darkibox (`buildApiProxyUrl`) → cast direct de l'URL ; `runtime.ts` supprime `API_PROXY_BASE_URL`/`buildApiProxyUrl` devenus morts. - Paramètre `streamType` (`BUFFERED` par défaut) propagé jusqu'à `loadMediaOnCastWithFallback`. ## 📢 Pubs - Modes de popup (`adPopupMode.ts`) : `normal`, `auto`, `click-anywhere` ; toggle « +18 » séparé (`adAdultMode.ts`). `SCRIPT_AD_MODE_ENABLED` → `false`, URL du script changée. ## 🔎 Recherche & priorité des sources - `searchUtils.ts` : le terme original contourne le filtre « trop générique » (fix zéro-résultat sur titres courts : Baki, Gantz). - `animeSignals.ts` : détection donghua (CN/TW/HK ou `zh` + Animation, hors C-drama). - `sourceAutoSelect.ts` : `VFF`/`VFQ` normalisés en `vf` (sinon reléguées derrière VOSTFR). `sourcePriorityPrefs.ts` : schéma v2 → v3 (nouvel ordre par défaut + `j1f`, migration non destructive des ordres personnalisés). `extractionPrefs.ts` : +daddylive. ## 🔒 Sécurité - Sanitisation d'URL avant rendu en `href` : `CommentsSection.tsx` (liens markdown `http(s)://`) et `Greenlight/AdminLinkSubmissions.tsx` — bloque les `javascript:`. ## ⚙️ Infra & perf - **server.js** : recyclage périodique des workers (12h) + `[memstats]` + hook `SIGUSR2` → heap-snapshot. - **app.js** : monte `/api/j1f`, retire la route statique `/oauth-icons`, passe `makeCpasmalRequest` au router Cpasmal, `coflix.date` → `coflix.trade`. - **lazyWithRetry.ts / main.tsx / ErrorBoundary.tsx** : récupération chunk-load réécrite (retry + reload budgété), garde `removeChild/insertBefore`, écran soft « Mise à jour » au lieu du crash-report. - **UI** : `TopProgressBar` écrit `transform` directement sur un ref (compositor-only, plus de re-render) ; `blur-text` relâche `will-change` après l'animation. - **proxiesembed/server.py** : `curl_cffi` (JA3) pour `cinep-proxy` + retry SOCKS5 sur 403 ; lookup clé VIP réaligné sur `checkVip.js` ; log WARNING par défaut ; SOCKS5 forcé IPv4 ; wrapper VTT/SRT pour hls.js ; re-label `application/zip` → `video/mp2t` ; `cinepulse.lol` → `purstream.mx`. ## 🧹 Nettoyage & suppressions - **Front** : carte promo « Loadix » sur Download (remplace l'encart beta), « 1jour1film » ajouté à « C'est quoi Movix », fallback Darkino/Nightflix gutté en no-op (`api.movix.chat` décommissionné), classes CSS mortes retirées, fix `fetchPriority` → `fetchpriority`. - **Backend** : scrapers/utilitaires morts retirés — `routes/dragiv.js`, `routes/topstream.js`, `routes/proxy.js`, `utils/cloudflareSession.js`, `utils/hydrackerBatch.js`, `utils/hydrackerQueue.js'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
817 lines
27 KiB
JavaScript
817 lines
27 KiB
JavaScript
/**
|
||
* Authentication routes.
|
||
* Extracted from server.js -- BIP39, Discord, and Google authentication.
|
||
* Mount point: app.use('/api/auth', router)
|
||
*/
|
||
|
||
const express = require('express');
|
||
const router = express.Router();
|
||
const axios = require('axios');
|
||
const crypto = require('crypto');
|
||
const bip39 = require('bip39');
|
||
const { v4: uuidv4 } = require('uuid');
|
||
const rateLimit = require('express-rate-limit');
|
||
const { ipKeyGenerator } = require('express-rate-limit');
|
||
|
||
const { issueJwt, getAuthIfValid } = require('../middleware/auth');
|
||
const { getPool } = require('../mysqlPool');
|
||
const { createRedisRateLimitStore } = require('../utils/redisRateLimitStore');
|
||
const { verifyTurnstileFromRequest } = require('../utils/turnstile');
|
||
const {
|
||
SUPPORTED_PROVIDERS,
|
||
isSupportedProvider,
|
||
getLinkedTarget,
|
||
getLinksForTarget,
|
||
setLink,
|
||
removeLinksForTargetProvider,
|
||
} = require('../utils/accountLinks');
|
||
|
||
// Rate limiter pour les endpoints d'authentification
|
||
// 10 tentatives par IP toutes les 15 minutes
|
||
const authRateLimit = rateLimit({
|
||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||
max: 10, // 10 requêtes max par fenêtre
|
||
store: createRedisRateLimitStore({ prefix: 'rate-limit:auth:login:' }),
|
||
passOnStoreError: true,
|
||
standardHeaders: true, // Retourne les headers RateLimit-* standard
|
||
legacyHeaders: false, // Désactive X-RateLimit-*
|
||
message: {
|
||
success: false,
|
||
error: 'Trop de tentatives de connexion. Réessayez dans 15 minutes.'
|
||
},
|
||
keyGenerator: (req) => req.headers['cf-connecting-ip'] || req.headers['x-forwarded-for']?.split(',')[0].trim() || ipKeyGenerator(req.ip),
|
||
validate: { xForwardedForHeader: false, ip: false }
|
||
});
|
||
|
||
// Récupération de la liste de mots française
|
||
const frenchWordlist = bip39.wordlists.french;
|
||
const DEFAULT_AVATAR = 'https://as2.ftcdn.net/v2/jpg/05/89/93/27/1000_F_589932782_vQAEAZhHnq1QCGu5ikwrYaQD0Mmurm0N.webp';
|
||
|
||
// Lazy imports from sync module
|
||
let _syncModule = null;
|
||
function getSyncModule() {
|
||
if (!_syncModule) _syncModule = require('./sync');
|
||
return _syncModule;
|
||
}
|
||
|
||
// === Session helpers ===
|
||
const generateDeviceFingerprint = (userAgent, ip) => {
|
||
const fingerprint = crypto.createHash('sha256')
|
||
.update(`${userAgent}-${ip || 'unknown'}-${Date.now()}`)
|
||
.digest('hex')
|
||
.substring(0, 32);
|
||
return fingerprint;
|
||
};
|
||
|
||
const encryptDeviceInfo = (deviceInfo) => {
|
||
const algorithm = 'aes-256-cbc';
|
||
const salt = crypto.randomBytes(16);
|
||
const key = crypto.scryptSync(process.env.JWT_SECRET, salt, 32);
|
||
const iv = crypto.randomBytes(16);
|
||
const cipher = crypto.createCipheriv(algorithm, key, iv);
|
||
let encrypted = cipher.update(deviceInfo, 'utf8', 'base64');
|
||
encrypted += cipher.final('base64');
|
||
return `${salt.toString('base64')}.${iv.toString('base64')}.${encrypted}`;
|
||
};
|
||
|
||
const createUserSession = async (userType, userId, req) => {
|
||
try {
|
||
const pool = getPool();
|
||
if (!pool) { console.error('MySQL pool not ready for session creation'); return null; }
|
||
|
||
const userAgent = req.headers['user-agent'] || 'Unknown';
|
||
const ip = req.ip || req.connection.remoteAddress || 'Unknown';
|
||
const sessionId = uuidv4();
|
||
const deviceInfo = generateDeviceFingerprint(userAgent, ip);
|
||
const encryptedDevice = encryptDeviceInfo(deviceInfo);
|
||
|
||
await pool.execute(
|
||
'INSERT INTO user_sessions (id, user_id, user_type, device, user_agent) VALUES (?, ?, ?, ?, ?)',
|
||
[sessionId, userId, userType, encryptedDevice, userAgent]
|
||
);
|
||
|
||
console.log(`[SESSION] Created new session ${sessionId} for ${userType}:${userId}`);
|
||
return sessionId;
|
||
} catch (error) {
|
||
console.error('Error creating user session:', error);
|
||
return null;
|
||
}
|
||
};
|
||
|
||
// === Helpers ===
|
||
|
||
function normalizeMnemonic(mnemonic) {
|
||
return mnemonic.normalize('NFKD').toLowerCase().trim().replace(/\s+/g, ' ');
|
||
}
|
||
|
||
function validateBip39Mnemonic(mnemonic) {
|
||
return bip39.validateMnemonic(mnemonic, frenchWordlist) || bip39.validateMnemonic(mnemonic);
|
||
}
|
||
|
||
function createBip39UserId(normalizedMnemonic) {
|
||
return crypto.createHash('sha256').update(normalizedMnemonic).digest('hex').substring(0, 16);
|
||
}
|
||
|
||
function buildBip39Profile(userId, username, avatar, existingProfile = null) {
|
||
return {
|
||
id: userId,
|
||
username: (username || existingProfile?.username || `Utilisateur-${userId.substring(0, 8)}`).trim(),
|
||
avatar: avatar || existingProfile?.avatar || DEFAULT_AVATAR,
|
||
provider: 'bip39',
|
||
createdAt: existingProfile?.createdAt || new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function buildDiscordProfile(user) {
|
||
return {
|
||
id: String(user.id),
|
||
username: user.username || `Discord-${user.id}`,
|
||
avatar: user.avatar
|
||
? `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`
|
||
: DEFAULT_AVATAR,
|
||
provider: 'discord',
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function buildGoogleProfile(user) {
|
||
return {
|
||
id: String(user.sub),
|
||
username: user.name || user.email || `Google-${user.sub}`,
|
||
avatar: user.picture || DEFAULT_AVATAR,
|
||
provider: 'google',
|
||
createdAt: new Date().toISOString(),
|
||
};
|
||
}
|
||
|
||
function parseStoredAuth(userData) {
|
||
if (!userData?.auth || typeof userData.auth !== 'string') return null;
|
||
try {
|
||
const authData = JSON.parse(userData.auth);
|
||
if (authData && authData.userProfile) return authData;
|
||
} catch (error) {
|
||
console.error('Error parsing stored auth data:', error.message);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function ensureStoredAuthData(userType, userId, authData, extraFields = {}) {
|
||
const { readUserData, writeUserData } = getSyncModule();
|
||
const userData = await readUserData(userType, userId) || {};
|
||
let shouldWrite = false;
|
||
|
||
if (!parseStoredAuth(userData)) {
|
||
userData.auth = JSON.stringify(authData);
|
||
shouldWrite = true;
|
||
}
|
||
|
||
for (const [key, value] of Object.entries(extraFields)) {
|
||
if (userData[key] !== value) {
|
||
userData[key] = value;
|
||
shouldWrite = true;
|
||
}
|
||
}
|
||
|
||
if (shouldWrite) {
|
||
userData.lastUpdated = Date.now();
|
||
await writeUserData(userType, userId, userData);
|
||
}
|
||
|
||
return userData;
|
||
}
|
||
|
||
async function getResolvedAuthData(userType, userId, fallbackProfile, fallbackProvider) {
|
||
const { readUserData } = getSyncModule();
|
||
const userData = await readUserData(userType, userId) || {};
|
||
const storedAuth = parseStoredAuth(userData);
|
||
|
||
if (storedAuth?.userProfile) {
|
||
return {
|
||
userData,
|
||
authData: {
|
||
...storedAuth,
|
||
provider: storedAuth.provider || storedAuth.userProfile.provider || fallbackProvider,
|
||
}
|
||
};
|
||
}
|
||
|
||
return {
|
||
userData,
|
||
authData: {
|
||
userProfile: fallbackProfile,
|
||
provider: fallbackProvider,
|
||
}
|
||
};
|
||
}
|
||
|
||
function getProviderLabel(provider) {
|
||
if (provider === 'discord') return 'Discord';
|
||
if (provider === 'google') return 'Google';
|
||
if (provider === 'bip39') return 'BIP39';
|
||
return 'ce moyen de connexion';
|
||
}
|
||
|
||
async function getAccountPrimaryProvider(userType, userId) {
|
||
const fallbackProvider = userType === 'bip39' ? 'bip39' : 'oauth';
|
||
const { userData, authData } = await getResolvedAuthData(
|
||
userType,
|
||
userId,
|
||
{ id: String(userId), provider: fallbackProvider },
|
||
fallbackProvider
|
||
);
|
||
|
||
const providerCandidates = [
|
||
authData?.userProfile?.provider,
|
||
authData?.provider,
|
||
userData?.oauth_provider,
|
||
];
|
||
|
||
for (const candidate of providerCandidates) {
|
||
if (isSupportedProvider(candidate)) {
|
||
return candidate;
|
||
}
|
||
}
|
||
|
||
return userType === 'bip39' ? 'bip39' : null;
|
||
}
|
||
|
||
async function ensureLinkManagementAccess(auth) {
|
||
const accountProvider = await getAccountPrimaryProvider(auth.userType, auth.userId);
|
||
if (!accountProvider) {
|
||
return {
|
||
allowed: false,
|
||
status: 403,
|
||
error: 'Impossible de déterminer le provider principal de ce compte. Reconnectez-vous avec le compte d’origine.',
|
||
};
|
||
}
|
||
|
||
if (auth.authMethod !== accountProvider) {
|
||
return {
|
||
allowed: false,
|
||
status: 403,
|
||
error: `Reconnectez-vous avec ${getProviderLabel(accountProvider)} pour gérer les liaisons de ce compte.`,
|
||
};
|
||
}
|
||
|
||
return { allowed: true, accountProvider };
|
||
}
|
||
|
||
async function getOrCreateBip39Account({ mnemonic, username = null, avatar = null, overwriteProfile = false }) {
|
||
const normalizedMnemonic = normalizeMnemonic(mnemonic);
|
||
if (!validateBip39Mnemonic(normalizedMnemonic)) {
|
||
return { success: false, error: 'Phrase secrète invalide' };
|
||
}
|
||
|
||
const userId = createBip39UserId(normalizedMnemonic);
|
||
const { readUserData, writeUserData } = getSyncModule();
|
||
const userData = await readUserData('bip39', userId) || {};
|
||
const storedAuth = parseStoredAuth(userData);
|
||
const existingProfile = storedAuth?.userProfile || null;
|
||
|
||
const shouldWriteProfile = overwriteProfile || !existingProfile;
|
||
const userProfile = buildBip39Profile(userId, username, avatar, existingProfile);
|
||
|
||
if (shouldWriteProfile) {
|
||
userData.auth = JSON.stringify({ userProfile, provider: 'bip39' });
|
||
userData.bip39_auth = 'true';
|
||
userData.lastUpdated = Date.now();
|
||
await writeUserData('bip39', userId, userData);
|
||
}
|
||
|
||
return { success: true, normalizedMnemonic, userId, userProfile };
|
||
}
|
||
|
||
async function verifyDiscordAccessToken(accessToken) {
|
||
const resp = await axios.get('https://discord.com/api/users/@me', {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
timeout: 10000,
|
||
});
|
||
|
||
const user = resp.data;
|
||
if (!user || !user.id) {
|
||
throw new Error('Token Discord invalide');
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
async function verifyGoogleAccessToken(accessToken) {
|
||
const resp = await axios.get('https://www.googleapis.com/oauth2/v3/userinfo', {
|
||
headers: { Authorization: `Bearer ${accessToken}` },
|
||
timeout: 10000,
|
||
});
|
||
|
||
const user = resp.data;
|
||
if (!user || !user.sub) {
|
||
throw new Error('Token Google invalide');
|
||
}
|
||
|
||
return user;
|
||
}
|
||
|
||
async function finalizeResolvedSession(req, {
|
||
provider,
|
||
providerUserId,
|
||
fallbackUserType,
|
||
sourceAuthData,
|
||
externalUser,
|
||
}) {
|
||
const sourceExtraFields = provider === 'bip39'
|
||
? { bip39_auth: 'true' }
|
||
: { oauth_provider: provider };
|
||
|
||
await ensureStoredAuthData(fallbackUserType, providerUserId, sourceAuthData, sourceExtraFields);
|
||
|
||
const linkedTarget = await getLinkedTarget(provider, providerUserId);
|
||
const resolvedUserType = linkedTarget?.targetUserType || fallbackUserType;
|
||
const resolvedUserId = linkedTarget?.targetUserId || String(providerUserId);
|
||
|
||
const sessionId = await createUserSession(resolvedUserType, resolvedUserId, req);
|
||
const token = issueJwt(resolvedUserType, resolvedUserId, sessionId, provider);
|
||
|
||
const fallbackResolvedProvider = resolvedUserType === 'bip39'
|
||
? 'bip39'
|
||
: sourceAuthData?.provider || provider;
|
||
|
||
const fallbackResolvedProfile = {
|
||
...(sourceAuthData?.userProfile || {}),
|
||
id: resolvedUserId,
|
||
provider: fallbackResolvedProvider,
|
||
};
|
||
|
||
const { authData } = await getResolvedAuthData(
|
||
resolvedUserType,
|
||
resolvedUserId,
|
||
fallbackResolvedProfile,
|
||
fallbackResolvedProvider
|
||
);
|
||
|
||
// Si le pseudo provider (Discord/Google) viole la policy (trop long, contient
|
||
// des caractères de contrôle/zero-width), on remonte un flag pour que le
|
||
// frontend force l'user à le changer via POST /api/auth/username.
|
||
const { isProfileNameValid } = require('../utils/syncPolicy');
|
||
const currentUsername = authData?.userProfile?.username;
|
||
const requiresUsernameChange = !isProfileNameValid(currentUsername);
|
||
|
||
return {
|
||
success: true,
|
||
sessionId,
|
||
token,
|
||
user: externalUser,
|
||
account: {
|
||
userType: resolvedUserType,
|
||
userId: resolvedUserId,
|
||
linked: Boolean(linkedTarget),
|
||
linkProvider: linkedTarget?.provider || null,
|
||
},
|
||
authData,
|
||
requiresUsernameChange,
|
||
};
|
||
}
|
||
|
||
async function resolveProviderIdentity(provider, body) {
|
||
switch (provider) {
|
||
case 'discord': {
|
||
const accessToken = body?.access_token;
|
||
if (!accessToken) {
|
||
return { success: false, status: 400, error: 'access_token requis' };
|
||
}
|
||
const user = await verifyDiscordAccessToken(accessToken);
|
||
return {
|
||
success: true,
|
||
provider,
|
||
providerUserId: String(user.id),
|
||
authData: { userProfile: buildDiscordProfile(user), provider: 'discord' },
|
||
externalUser: user,
|
||
};
|
||
}
|
||
case 'google': {
|
||
const accessToken = body?.access_token;
|
||
if (!accessToken) {
|
||
return { success: false, status: 400, error: 'access_token requis' };
|
||
}
|
||
const user = await verifyGoogleAccessToken(accessToken);
|
||
return {
|
||
success: true,
|
||
provider,
|
||
providerUserId: String(user.sub),
|
||
authData: { userProfile: buildGoogleProfile(user), provider: 'google' },
|
||
externalUser: user,
|
||
};
|
||
}
|
||
case 'bip39': {
|
||
const mnemonic = body?.mnemonic;
|
||
if (!mnemonic) {
|
||
return { success: false, status: 400, error: 'Phrase secrète requise' };
|
||
}
|
||
const account = await getOrCreateBip39Account({ mnemonic });
|
||
if (!account.success) {
|
||
return { success: false, status: 400, error: account.error };
|
||
}
|
||
return {
|
||
success: true,
|
||
provider,
|
||
providerUserId: account.userId,
|
||
authData: { userProfile: account.userProfile, provider: 'bip39' },
|
||
externalUser: {
|
||
id: account.userId,
|
||
username: account.userProfile.username,
|
||
avatar: account.userProfile.avatar,
|
||
},
|
||
};
|
||
}
|
||
default:
|
||
return { success: false, status: 400, error: 'Provider non supporté' };
|
||
}
|
||
}
|
||
|
||
function getProviderStatusMap(records) {
|
||
return SUPPORTED_PROVIDERS.reduce((acc, provider) => {
|
||
const record = records.find((item) => item.provider === provider) || null;
|
||
acc[provider] = record ? {
|
||
linked: true,
|
||
providerUserId: record.providerUserId,
|
||
linkedAt: record.linkedAt,
|
||
updatedAt: record.updatedAt,
|
||
} : {
|
||
linked: false,
|
||
providerUserId: null,
|
||
linkedAt: null,
|
||
updatedAt: null,
|
||
};
|
||
return acc;
|
||
}, {});
|
||
}
|
||
|
||
// === Routes ===
|
||
|
||
// GET /bip39/generate
|
||
router.get('/bip39/generate', (req, res) => {
|
||
try {
|
||
const mnemonic = bip39.generateMnemonic(128, undefined, frenchWordlist);
|
||
res.status(200).json({ success: true, mnemonic });
|
||
} catch (error) {
|
||
console.error('Error generating mnemonic:', error);
|
||
res.status(500).json({ success: false, error: 'Erreur lors de la génération de la phrase secrète' });
|
||
}
|
||
});
|
||
|
||
// POST /bip39/create
|
||
router.post('/bip39/create', authRateLimit, async (req, res) => {
|
||
try {
|
||
const { mnemonic, username, avatar, turnstileToken } = req.body;
|
||
if (!mnemonic || !username) {
|
||
return res.status(400).json({ success: false, error: 'Phrase secrète et nom d\'utilisateur requis' });
|
||
}
|
||
|
||
const turnstileResult = await verifyTurnstileFromRequest(req, turnstileToken);
|
||
if (!turnstileResult.valid) {
|
||
return res.status(turnstileResult.status).json({ success: false, error: turnstileResult.error });
|
||
}
|
||
|
||
const account = await getOrCreateBip39Account({
|
||
mnemonic,
|
||
username,
|
||
avatar,
|
||
overwriteProfile: true,
|
||
});
|
||
|
||
if (!account.success) {
|
||
return res.status(400).json({ success: false, error: account.error });
|
||
}
|
||
|
||
const payload = await finalizeResolvedSession(req, {
|
||
provider: 'bip39',
|
||
providerUserId: account.userId,
|
||
fallbackUserType: 'bip39',
|
||
sourceAuthData: { userProfile: account.userProfile, provider: 'bip39' },
|
||
externalUser: {
|
||
id: account.userId,
|
||
username: account.userProfile.username,
|
||
avatar: account.userProfile.avatar,
|
||
},
|
||
});
|
||
|
||
return res.status(200).json({
|
||
...payload,
|
||
userId: payload.account.userId,
|
||
userProfile: payload.authData.userProfile,
|
||
message: 'Compte créé avec succès',
|
||
});
|
||
} catch (error) {
|
||
console.error('Error creating BIP39 account:', error);
|
||
res.status(500).json({ success: false, error: 'Erreur lors de la création du compte' });
|
||
}
|
||
});
|
||
|
||
// POST /bip39/login
|
||
router.post('/bip39/login', authRateLimit, async (req, res) => {
|
||
try {
|
||
const { mnemonic, turnstileToken } = req.body;
|
||
if (!mnemonic) {
|
||
return res.status(400).json({ success: false, error: 'Phrase secrète requise' });
|
||
}
|
||
|
||
const turnstileResult = await verifyTurnstileFromRequest(req, turnstileToken);
|
||
if (!turnstileResult.valid) {
|
||
return res.status(turnstileResult.status).json({ success: false, error: turnstileResult.error });
|
||
}
|
||
|
||
const account = await getOrCreateBip39Account({ mnemonic });
|
||
if (!account.success) {
|
||
return res.status(400).json({ success: false, error: account.error });
|
||
}
|
||
|
||
const payload = await finalizeResolvedSession(req, {
|
||
provider: 'bip39',
|
||
providerUserId: account.userId,
|
||
fallbackUserType: 'bip39',
|
||
sourceAuthData: { userProfile: account.userProfile, provider: 'bip39' },
|
||
externalUser: {
|
||
id: account.userId,
|
||
username: account.userProfile.username,
|
||
avatar: account.userProfile.avatar,
|
||
},
|
||
});
|
||
|
||
return res.status(200).json({
|
||
...payload,
|
||
userId: payload.account.userId,
|
||
userProfile: payload.authData.userProfile,
|
||
message: 'Connexion réussie',
|
||
});
|
||
} catch (error) {
|
||
console.error('Error logging in with BIP39:', error);
|
||
res.status(500).json({ success: false, error: 'Erreur lors de la connexion' });
|
||
}
|
||
});
|
||
|
||
// POST /discord/verify
|
||
router.post('/discord/verify', authRateLimit, async (req, res) => {
|
||
try {
|
||
const { access_token: accessToken } = req.body || {};
|
||
if (!accessToken) {
|
||
return res.status(400).json({ success: false, error: 'access_token requis' });
|
||
}
|
||
|
||
const user = await verifyDiscordAccessToken(accessToken);
|
||
const payload = await finalizeResolvedSession(req, {
|
||
provider: 'discord',
|
||
providerUserId: String(user.id),
|
||
fallbackUserType: 'oauth',
|
||
sourceAuthData: { userProfile: buildDiscordProfile(user), provider: 'discord' },
|
||
externalUser: user,
|
||
});
|
||
|
||
return res.status(200).json(payload);
|
||
} catch (error) {
|
||
console.error('Discord verify error:', error.response?.status || error.message);
|
||
return res.status(401).json({ success: false, error: 'Échec de la vérification Discord' });
|
||
}
|
||
});
|
||
|
||
// POST /google/verify
|
||
router.post('/google/verify', authRateLimit, async (req, res) => {
|
||
try {
|
||
const { access_token: accessToken } = req.body || {};
|
||
if (!accessToken) {
|
||
return res.status(400).json({ success: false, error: 'access_token requis' });
|
||
}
|
||
|
||
const user = await verifyGoogleAccessToken(accessToken);
|
||
const payload = await finalizeResolvedSession(req, {
|
||
provider: 'google',
|
||
providerUserId: String(user.sub),
|
||
fallbackUserType: 'oauth',
|
||
sourceAuthData: { userProfile: buildGoogleProfile(user), provider: 'google' },
|
||
externalUser: user,
|
||
});
|
||
|
||
return res.status(200).json(payload);
|
||
} catch (error) {
|
||
console.error('Google verify error:', error.response?.status || error.message);
|
||
return res.status(401).json({ success: false, error: 'Échec de la vérification Google' });
|
||
}
|
||
});
|
||
|
||
// GET /links
|
||
router.get('/links', async (req, res) => {
|
||
try {
|
||
const auth = await getAuthIfValid(req);
|
||
if (!auth || !['oauth', 'bip39'].includes(auth.userType)) {
|
||
return res.status(401).json({ success: false, error: 'Non autorisé' });
|
||
}
|
||
|
||
const records = await getLinksForTarget(auth.userType, auth.userId);
|
||
const fallbackProvider = auth.userType === 'bip39' ? 'bip39' : 'oauth';
|
||
const { authData } = await getResolvedAuthData(
|
||
auth.userType,
|
||
auth.userId,
|
||
{ id: String(auth.userId), provider: fallbackProvider },
|
||
fallbackProvider
|
||
);
|
||
const resolvedProviderCandidate = authData?.userProfile?.provider || authData?.provider;
|
||
const accountProvider = await getAccountPrimaryProvider(auth.userType, auth.userId)
|
||
|| (isSupportedProvider(resolvedProviderCandidate) ? resolvedProviderCandidate : null)
|
||
|| (auth.userType === 'bip39' ? 'bip39' : null);
|
||
|
||
const { isProfileNameValid } = require('../utils/syncPolicy');
|
||
const currentUsername = authData?.userProfile?.username;
|
||
const requiresUsernameChange = !isProfileNameValid(currentUsername);
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
account: {
|
||
userType: auth.userType,
|
||
userId: auth.userId,
|
||
provider: accountProvider,
|
||
authMethod: isSupportedProvider(auth.authMethod) ? auth.authMethod : null,
|
||
canManageLinks: Boolean(accountProvider && auth.authMethod && auth.authMethod === accountProvider),
|
||
manageWithProvider: accountProvider,
|
||
},
|
||
links: getProviderStatusMap(records),
|
||
requiresUsernameChange,
|
||
currentUsername: currentUsername || null,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error getting account links:', error);
|
||
return res.status(500).json({ success: false, error: 'Erreur lors de la récupération des liaisons' });
|
||
}
|
||
});
|
||
|
||
// POST /links/:provider
|
||
router.post('/links/:provider', authRateLimit, async (req, res) => {
|
||
try {
|
||
const auth = await getAuthIfValid(req);
|
||
if (!auth || !['oauth', 'bip39'].includes(auth.userType)) {
|
||
return res.status(401).json({ success: false, error: 'Non autorisé' });
|
||
}
|
||
|
||
const accessCheck = await ensureLinkManagementAccess(auth);
|
||
if (!accessCheck.allowed) {
|
||
return res.status(accessCheck.status || 403).json({ success: false, error: accessCheck.error });
|
||
}
|
||
|
||
const provider = String(req.params.provider || '').toLowerCase();
|
||
if (!isSupportedProvider(provider)) {
|
||
return res.status(400).json({ success: false, error: 'Provider non supporté' });
|
||
}
|
||
|
||
const resolved = await resolveProviderIdentity(provider, req.body);
|
||
if (!resolved.success) {
|
||
return res.status(resolved.status || 400).json({ success: false, error: resolved.error });
|
||
}
|
||
|
||
const isSelfLink =
|
||
((provider === 'google' || provider === 'discord') && auth.userType === 'oauth' && String(auth.userId) === String(resolved.providerUserId)) ||
|
||
(provider === 'bip39' && auth.userType === 'bip39' && String(auth.userId) === String(resolved.providerUserId));
|
||
|
||
if (isSelfLink) {
|
||
return res.status(400).json({
|
||
success: false,
|
||
error: 'Ce moyen de connexion correspond déjà à ce compte.',
|
||
});
|
||
}
|
||
|
||
const extraFields = provider === 'bip39'
|
||
? { bip39_auth: 'true' }
|
||
: { oauth_provider: provider };
|
||
|
||
await ensureStoredAuthData(
|
||
provider === 'bip39' ? 'bip39' : 'oauth',
|
||
resolved.providerUserId,
|
||
resolved.authData,
|
||
extraFields
|
||
);
|
||
|
||
const link = await setLink({
|
||
provider,
|
||
providerUserId: resolved.providerUserId,
|
||
targetUserType: auth.userType,
|
||
targetUserId: auth.userId,
|
||
});
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
link: {
|
||
provider: link.provider,
|
||
providerUserId: link.providerUserId,
|
||
linkedAt: link.linkedAt,
|
||
updatedAt: link.updatedAt,
|
||
targetUserType: link.targetUserType,
|
||
targetUserId: link.targetUserId,
|
||
},
|
||
message: 'Compte lié avec succès',
|
||
});
|
||
} catch (error) {
|
||
console.error('Error linking account:', error);
|
||
return res.status(500).json({ success: false, error: 'Erreur lors de la liaison du compte' });
|
||
}
|
||
});
|
||
|
||
// DELETE /links/:provider
|
||
router.delete('/links/:provider', async (req, res) => {
|
||
try {
|
||
const auth = await getAuthIfValid(req);
|
||
if (!auth || !['oauth', 'bip39'].includes(auth.userType)) {
|
||
return res.status(401).json({ success: false, error: 'Non autorisé' });
|
||
}
|
||
|
||
const accessCheck = await ensureLinkManagementAccess(auth);
|
||
if (!accessCheck.allowed) {
|
||
return res.status(accessCheck.status || 403).json({ success: false, error: accessCheck.error });
|
||
}
|
||
|
||
const provider = String(req.params.provider || '').toLowerCase();
|
||
if (!isSupportedProvider(provider)) {
|
||
return res.status(400).json({ success: false, error: 'Provider non supporté' });
|
||
}
|
||
|
||
const removed = await removeLinksForTargetProvider(auth.userType, auth.userId, provider);
|
||
return res.status(200).json({
|
||
success: true,
|
||
removed,
|
||
message: removed
|
||
? 'Liaison supprimée avec succès'
|
||
: 'Aucune liaison active à supprimer',
|
||
});
|
||
} catch (error) {
|
||
console.error('Error unlinking account:', error);
|
||
return res.status(500).json({ success: false, error: 'Erreur lors de la suppression de la liaison' });
|
||
}
|
||
});
|
||
|
||
// POST /username — l'user met à jour son pseudo (auth.userProfile.username)
|
||
// quand le pseudo provider (Discord/Google) dépasse 32 caractères ou contient
|
||
// des caractères dangereux. Le frontend appelle ça après affichage de la
|
||
// modale bloquante. Met aussi à jour le profil par défaut si son nom matchait
|
||
// l'ancien username (pour garder la cohérence visuelle).
|
||
router.post('/username', authRateLimit, async (req, res) => {
|
||
try {
|
||
const auth = await getAuthIfValid(req);
|
||
if (!auth || !['oauth', 'bip39'].includes(auth.userType)) {
|
||
return res.status(401).json({ success: false, error: 'Non autorisé' });
|
||
}
|
||
|
||
const { validateProfileName, SyncPolicyError } = require('../utils/syncPolicy');
|
||
let cleanedName;
|
||
try {
|
||
cleanedName = validateProfileName(req.body?.username);
|
||
} catch (e) {
|
||
if (e instanceof SyncPolicyError) {
|
||
return res.status(e.status).json({ success: false, error: e.message, code: e.code });
|
||
}
|
||
throw e;
|
||
}
|
||
|
||
const { readUserData, writeUserData } = getSyncModule();
|
||
const userData = await readUserData(auth.userType, auth.userId) || {};
|
||
|
||
let parsedAuth = null;
|
||
if (typeof userData.auth === 'string') {
|
||
try { parsedAuth = JSON.parse(userData.auth); } catch { parsedAuth = null; }
|
||
}
|
||
if (!parsedAuth || !parsedAuth.userProfile) {
|
||
// Pas de userProfile stocké : on en crée un minimal pour porter le nom.
|
||
parsedAuth = {
|
||
...(parsedAuth || {}),
|
||
userProfile: {
|
||
id: String(auth.userId),
|
||
provider: auth.userType === 'bip39' ? 'bip39' : (parsedAuth?.provider || 'oauth'),
|
||
username: cleanedName,
|
||
createdAt: new Date().toISOString(),
|
||
},
|
||
};
|
||
} else {
|
||
const oldUsername = parsedAuth.userProfile.username;
|
||
parsedAuth.userProfile = { ...parsedAuth.userProfile, username: cleanedName };
|
||
// Met aussi à jour le nom du profil par défaut si l'user n'a jamais
|
||
// renommé son profil (sinon on ne touche pas — il a fait un choix conscient).
|
||
if (Array.isArray(userData.profiles) && typeof oldUsername === 'string') {
|
||
const defaultProfile = userData.profiles.find((p) => p && p.isDefault);
|
||
if (defaultProfile && defaultProfile.name === oldUsername) {
|
||
defaultProfile.name = cleanedName;
|
||
}
|
||
}
|
||
}
|
||
|
||
userData.auth = JSON.stringify(parsedAuth);
|
||
userData.lastUpdated = Date.now();
|
||
|
||
const success = await writeUserData(auth.userType, auth.userId, userData);
|
||
if (!success) {
|
||
return res.status(500).json({ success: false, error: 'Impossible de sauvegarder le nouveau pseudo' });
|
||
}
|
||
|
||
return res.status(200).json({
|
||
success: true,
|
||
username: cleanedName,
|
||
authData: parsedAuth,
|
||
});
|
||
} catch (error) {
|
||
console.error('Error updating username:', error);
|
||
return res.status(500).json({ success: false, error: 'Erreur lors de la mise à jour du pseudo' });
|
||
}
|
||
});
|
||
|
||
module.exports = router;
|