MovixOpenSource/API/Mainapi/routes/authRoutes.js
Movix 8657a856f8 feat: live TV FCTV, fournisseur OAuth, sources J1F/Cinestream, modération IA & durcissement
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>
2026-07-05 18:22:13 +02:00

817 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 dorigine.',
};
}
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;