MovixOpenSource/API/Mainapi/utils/syncPolicy.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

515 lines
15 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.

const path = require('path');
const ALLOWED_SYNC_USER_TYPES = Object.freeze(['oauth', 'bip39']);
const RESERVED_OBJECT_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
const SAFE_SYNC_KEY_PATTERN = /^[A-Za-z0-9:_-]{1,120}$/;
const SAFE_USER_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const SAFE_PROFILE_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/;
const SAFE_GUEST_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/;
const SYNCABLE_EXACT_KEYS = new Set([
'access_code',
'access_code_expires',
'adWarningAccepted',
'anti_spoiler',
'continueWatching',
'custom_lists',
'episodeReleaseAlerts',
'favorite_collections',
'favorite_movie',
'favorite_movies',
'favorites_tv',
'live_tv_favorite_channels',
'live_tv_favorite_iptv_categories',
'miniPlayerPosition',
'miniPlayerVolume',
'movix_intro_enabled',
'movix_intro_seen',
'privacy_data_collection',
'recentEmojis',
'screensaver_enabled',
'screensaver_mode',
'screensaver_timeout',
'shared_list_favorites',
'snow_enabled',
'subtitleStyle',
'support_popup_seen',
'user_language',
// `is_vip` est un cache UI côté frontend. Le statut réel est validé côté
// serveur sur chaque requête sensible via /api/check-vip (x-access-key) ;
// forger `is_vip` en localStorage (sync ou DevTools) ne donne aucun accès
// VIP réel, juste un affichage trompeur. On le garde syncable pour que le
// statut suive l'utilisateur entre appareils sans recheck immédiat.
'is_vip',
'watched_movie',
'watched_tv',
'watchPartyNickname'
]);
const SYNCABLE_PREFIXES = Object.freeze([
'favorite_',
'favorites_',
'miniPlayer',
'movix_intro_',
'player',
'progress_',
'screensaver_',
'settings_',
'watchlist_',
'watched_'
]);
const BLOCKED_SYNC_KEYS = new Set([
'access_token',
'auth',
'auth_method',
'auth_token',
'avatar_url',
'bip39_auth',
'clear',
'discord_auth',
'discord_last_check',
'discord_rate_limit',
'discord_token',
'discord_user',
'episodeAlertsLastCheck',
'google_auth',
'google_token',
'google_user',
'guest_uuid',
'is_admin',
'lastCommentTime',
'lastReplyTime',
'movix_pending_auth_action',
'removeItem',
'resolved_user_id',
'resolved_user_type',
'selectedProfile',
'selected_profile',
'selected_profile_id',
'session_id',
'setItem',
'user_id',
'user_name',
'user_type'
]);
const SYNC_LIMITS = Object.freeze({
maxRequestBytes: 5 * 1024 * 1024,
maxOpsPerRequest: 200,
maxKeysPerProfile: 600,
maxDeltaProperties: 200,
maxStringValueBytes: 256 * 1024,
maxStructuredValueBytes: 128 * 1024,
maxProfileBytes: 5 * 1024 * 1024,
maxLegacyUserBytes: 512 * 1024,
maxUserDataBytes: 1024 * 1024,
maxValueDepth: 8
});
// Bornes affichage des identifiants utilisateur.
// `profileName` = nom du profil Movix (créé via /api/profiles ou /api/oauth/profiles).
// `displayName` = pseudo OAuth (Discord/Google) tel qu'on l'affiche dans l'UI.
// Discord limite à 32 chars ; Google peut aller plus loin → on tronque à 64.
const IDENTITY_LIMITS = Object.freeze({
profileNameMin: 1,
profileNameMax: 32,
displayNameMax: 64,
});
class SyncPolicyError extends Error {
constructor(message, status = 400, code = 'SYNC_POLICY_ERROR') {
super(message);
this.name = 'SyncPolicyError';
this.status = status;
this.code = code;
}
}
/**
* Valide un nom de profil utilisateur.
*
* - Trim
* - Retire les caractères de contrôle (U+0000U+001F, U+007F) qui peuvent
* casser l'affichage ou être utilisés pour des attaques de spoofing.
* - Refuse les caractères zero-width (U+200B-U+200D, U+FEFF) pour la même raison.
* - Borne la longueur entre `profileNameMin` (1) et `profileNameMax` (32).
*
* Throw `SyncPolicyError` si invalide. Sinon retourne la chaîne nettoyée.
*/
// Caracteres dangereux a stripper avant validation/affichage des noms :
// \x00-\x1F\x7F : controle ASCII
// \u200B-\u200D : zero-width (spoofing)
// \uFEFF : BOM
const DANGEROUS_NAME_CHARS = /[\x00-\x1F\x7F\u200B-\u200D\uFEFF]/g;
function cleanNameInput(raw) {
return String(raw).replace(DANGEROUS_NAME_CHARS, '').trim();
}
function validateProfileName(raw) {
if (typeof raw !== 'string') {
throw new SyncPolicyError('Le nom de profil doit etre une chaine', 400, 'INVALID_PROFILE_NAME');
}
const cleaned = cleanNameInput(raw);
if (cleaned.length < IDENTITY_LIMITS.profileNameMin) {
throw new SyncPolicyError('Le nom de profil ne peut pas etre vide', 400, 'PROFILE_NAME_TOO_SHORT');
}
if (cleaned.length > IDENTITY_LIMITS.profileNameMax) {
throw new SyncPolicyError(
`Le nom de profil ne peut pas depasser ${IDENTITY_LIMITS.profileNameMax} caracteres`,
400,
'PROFILE_NAME_TOO_LONG',
);
}
return cleaned;
}
/**
* Version booléenne de validateProfileName : ne throw pas, retourne juste
* un boolean. Utilisé sur les chemins de lecture (vérifier si le username
* stocké respecte la policy → si non, forcer l'user à le changer).
*/
function isProfileNameValid(raw) {
if (typeof raw !== 'string') return false;
const cleaned = cleanNameInput(raw);
return cleaned.length >= IDENTITY_LIMITS.profileNameMin
&& cleaned.length <= IDENTITY_LIMITS.profileNameMax;
}
/**
* Tronque proprement un display name (pseudo OAuth provider) pour l'affichage.
* Pas de throw : on accepte les valeurs legacy deja stockees, on les coupe juste
* si elles depassent. Ajoute une ellipse si tronque.
*/
function truncateDisplayName(raw, max = IDENTITY_LIMITS.displayNameMax) {
if (typeof raw !== 'string') return '';
const cleaned = cleanNameInput(raw);
if (cleaned.length <= max) return cleaned;
return cleaned.slice(0, Math.max(1, max - 1)) + "\u2026";
}
function canonicalPath(value) {
const resolved = path.resolve(value);
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
}
function isPathInside(baseDir, targetPath) {
const normalizedBase = canonicalPath(baseDir);
const normalizedTarget = canonicalPath(targetPath);
const baseWithSep = normalizedBase.endsWith(path.sep) ? normalizedBase : `${normalizedBase}${path.sep}`;
return normalizedTarget === normalizedBase || normalizedTarget.startsWith(baseWithSep);
}
function resolveInside(baseDir, ...segments) {
const resolved = path.resolve(baseDir, ...segments);
if (!isPathInside(baseDir, resolved)) {
throw new SyncPolicyError('Chemin de stockage invalide', 400, 'INVALID_STORAGE_PATH');
}
return resolved;
}
function getUtf8ByteLength(value) {
const serialized = typeof value === 'string' ? value : JSON.stringify(value);
return Buffer.byteLength(serialized, 'utf8');
}
function assertWithinBytes(value, maxBytes, message, status = 413, code = 'PAYLOAD_TOO_LARGE') {
if (getUtf8ByteLength(value) > maxBytes) {
throw new SyncPolicyError(message, status, code);
}
}
function isPlainObject(value) {
return Object.prototype.toString.call(value) === '[object Object]';
}
function ensureSafeSegment(value, label, pattern, code = 'INVALID_IDENTIFIER') {
if (typeof value !== 'string' || !value.trim()) {
throw new SyncPolicyError(`${label} manquant`, 400, code);
}
const normalized = value.trim();
if (!pattern.test(normalized)) {
throw new SyncPolicyError(`${label} invalide`, 400, code);
}
return normalized;
}
function ensureValidUserType(userType) {
if (!ALLOWED_SYNC_USER_TYPES.includes(userType)) {
throw new SyncPolicyError('Type d\'utilisateur invalide', 400, 'INVALID_USER_TYPE');
}
return userType;
}
function ensureSafeUserId(userId) {
return ensureSafeSegment(userId, 'userId', SAFE_USER_ID_PATTERN, 'INVALID_USER_ID');
}
function ensureSafeProfileId(profileId) {
return ensureSafeSegment(profileId, 'profileId', SAFE_PROFILE_ID_PATTERN, 'INVALID_PROFILE_ID');
}
function ensureSafeGuestId(guestId) {
return ensureSafeSegment(guestId, 'guestId', SAFE_GUEST_ID_PATTERN, 'INVALID_GUEST_ID');
}
function ensureSafeNestedKey(key) {
if (typeof key !== 'string' || !key || key.length > 120 || RESERVED_OBJECT_KEYS.has(key)) {
throw new SyncPolicyError('Cle interne invalide dans les donnees synchronisees', 400, 'INVALID_NESTED_KEY');
}
return key;
}
function isSyncableStorageKey(key) {
if (typeof key !== 'string') return false;
if (BLOCKED_SYNC_KEYS.has(key)) return false;
if (RESERVED_OBJECT_KEYS.has(key)) return false;
if (!SAFE_SYNC_KEY_PATTERN.test(key)) return false;
if (SYNCABLE_EXACT_KEYS.has(key)) return true;
return SYNCABLE_PREFIXES.some((prefix) => key.startsWith(prefix));
}
function ensureSyncableStorageKey(key) {
if (!isSyncableStorageKey(key)) {
throw new SyncPolicyError(`Cle de synchronisation refusee: ${String(key)}`, 400, 'INVALID_SYNC_KEY');
}
return key;
}
function sanitizeStructuredValue(value, depth = 0) {
if (depth > SYNC_LIMITS.maxValueDepth) {
throw new SyncPolicyError('Structure de donnees trop profonde', 400, 'VALUE_TOO_DEEP');
}
if (value === null) {
return null;
}
const valueType = typeof value;
if (valueType === 'string') {
assertWithinBytes(value, SYNC_LIMITS.maxStructuredValueBytes, 'Valeur trop volumineuse', 413, 'VALUE_TOO_LARGE');
return value;
}
if (valueType === 'number') {
if (!Number.isFinite(value)) {
throw new SyncPolicyError('Nombre invalide dans les donnees synchronisees', 400, 'INVALID_NUMBER');
}
return value;
}
if (valueType === 'boolean') {
return value;
}
if (Array.isArray(value)) {
const next = value.map((item) => sanitizeStructuredValue(item, depth + 1));
assertWithinBytes(next, SYNC_LIMITS.maxStructuredValueBytes, 'Tableau trop volumineux', 413, 'VALUE_TOO_LARGE');
return next;
}
if (!isPlainObject(value)) {
throw new SyncPolicyError('Structure de donnees non supportee', 400, 'INVALID_VALUE_TYPE');
}
const next = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
next[ensureSafeNestedKey(entryKey)] = sanitizeStructuredValue(entryValue, depth + 1);
}
assertWithinBytes(next, SYNC_LIMITS.maxStructuredValueBytes, 'Objet trop volumineux', 413, 'VALUE_TOO_LARGE');
return next;
}
function sanitizeDelta(delta) {
if (!isPlainObject(delta)) {
throw new SyncPolicyError('Delta de synchronisation invalide', 400, 'INVALID_DELTA');
}
const rawSet = delta.set === undefined ? {} : delta.set;
const rawRemove = delta.remove === undefined ? [] : delta.remove;
if (!isPlainObject(rawSet) || !Array.isArray(rawRemove)) {
throw new SyncPolicyError('Delta de synchronisation invalide', 400, 'INVALID_DELTA');
}
const setEntries = Object.entries(rawSet);
if (setEntries.length + rawRemove.length > SYNC_LIMITS.maxDeltaProperties) {
throw new SyncPolicyError('Trop de modifications dans une seule operation', 413, 'DELTA_TOO_LARGE');
}
const set = {};
for (const [key, value] of setEntries) {
set[ensureSafeNestedKey(key)] = sanitizeStructuredValue(value);
}
const remove = rawRemove.map((key) => ensureSafeNestedKey(key));
return { set, remove };
}
function validateAndNormalizeOps(ops) {
if (!Array.isArray(ops)) {
throw new SyncPolicyError('Operations de synchronisation invalides', 400, 'INVALID_OPS');
}
if (ops.length > SYNC_LIMITS.maxOpsPerRequest) {
throw new SyncPolicyError('Trop d\'operations de synchronisation dans une seule requete', 413, 'TOO_MANY_OPS');
}
return ops.map((rawOp) => {
if (!isPlainObject(rawOp)) {
throw new SyncPolicyError('Operation de synchronisation invalide', 400, 'INVALID_OP');
}
const key = ensureSyncableStorageKey(rawOp.key);
const op = typeof rawOp.op === 'string' ? rawOp.op : '';
switch (op) {
case 'set': {
if (typeof rawOp.value !== 'string') {
throw new SyncPolicyError('Valeur de synchronisation invalide', 400, 'INVALID_SET_VALUE');
}
assertWithinBytes(rawOp.value, SYNC_LIMITS.maxStringValueBytes, 'Valeur trop volumineuse', 413, 'VALUE_TOO_LARGE');
return { op, key, value: rawOp.value };
}
case 'remove':
case 'arrayClear':
return { op, key };
case 'arrayAdd':
case 'arrayRemove': {
const value = sanitizeStructuredValue(rawOp.value);
return { op, key, value };
}
case 'objPatch':
return { op, key, delta: sanitizeDelta(rawOp.delta) };
default:
throw new SyncPolicyError('Operation de synchronisation inconnue', 400, 'INVALID_OP');
}
});
}
function sanitizeStorageObject(rawData, options = {}) {
const {
maxBytes = SYNC_LIMITS.maxProfileBytes,
maxKeys = SYNC_LIMITS.maxKeysPerProfile
} = options;
const sanitized = {};
let changed = !isPlainObject(rawData);
if (isPlainObject(rawData)) {
for (const [key, value] of Object.entries(rawData)) {
if (!isSyncableStorageKey(key) || typeof value !== 'string') {
changed = true;
continue;
}
if (getUtf8ByteLength(value) > SYNC_LIMITS.maxStringValueBytes) {
changed = true;
continue;
}
sanitized[key] = value;
}
}
const keyCount = Object.keys(sanitized).length;
if (keyCount > maxKeys) {
throw new SyncPolicyError('Trop de cles synchronisees pour ce profil', 413, 'TOO_MANY_KEYS');
}
const bytes = keyCount === 0 ? 0 : getUtf8ByteLength(sanitized);
if (bytes > maxBytes) {
throw new SyncPolicyError('Quota de stockage depasse pour ce profil', 413, 'PROFILE_QUOTA_EXCEEDED');
}
return {
data: sanitized,
changed,
stats: {
bytes,
keyCount
}
};
}
function sanitizeProfileData(rawData) {
return sanitizeStorageObject(rawData, {
maxBytes: SYNC_LIMITS.maxProfileBytes,
maxKeys: SYNC_LIMITS.maxKeysPerProfile
});
}
function sanitizeLegacySyncData(rawData) {
return sanitizeStorageObject(rawData, {
maxBytes: SYNC_LIMITS.maxLegacyUserBytes,
maxKeys: SYNC_LIMITS.maxKeysPerProfile
});
}
function assertUserDataSize(rawData) {
assertWithinBytes(rawData, SYNC_LIMITS.maxUserDataBytes, 'Donnees utilisateur trop volumineuses', 413, 'USER_DATA_TOO_LARGE');
}
function getUserDataFilePath({ usersDir, guestsDir }, userType, userId) {
if (userType === 'guest') {
const safeGuestId = ensureSafeGuestId(userId);
return resolveInside(guestsDir, `guest-${safeGuestId}.json`);
}
const safeUserType = ensureValidUserType(userType);
const safeUserId = ensureSafeUserId(userId);
const fileName = safeUserType === 'bip39' ? `bip39-${safeUserId}.json` : `${safeUserId}.json`;
return resolveInside(usersDir, fileName);
}
function getProfileFilePath(usersDir, userType, userId, profileId) {
const safeUserType = ensureValidUserType(userType);
const safeUserId = ensureSafeUserId(userId);
const safeProfileId = ensureSafeProfileId(profileId);
return resolveInside(usersDir, 'profiles', safeUserType, safeUserId, `${safeProfileId}.json`);
}
function getOwnedProfile(userData, profileId) {
const safeProfileId = ensureSafeProfileId(profileId);
const profiles = Array.isArray(userData?.profiles) ? userData.profiles : [];
const profile = profiles.find((item) => item && item.id === safeProfileId);
if (!profile) {
throw new SyncPolicyError('Profil introuvable', 404, 'PROFILE_NOT_FOUND');
}
return profile;
}
module.exports = {
ALLOWED_SYNC_USER_TYPES,
BLOCKED_SYNC_KEYS,
IDENTITY_LIMITS,
SYNC_LIMITS,
SyncPolicyError,
assertUserDataSize,
ensureSafeProfileId,
ensureSafeUserId,
ensureValidUserType,
getOwnedProfile,
getProfileFilePath,
getUserDataFilePath,
getUtf8ByteLength,
isSyncableStorageKey,
resolveInside,
sanitizeLegacySyncData,
isProfileNameValid,
sanitizeProfileData,
truncateDisplayName,
validateAndNormalizeOps,
validateProfileName
};