MovixOpenSource/API/Mainapi/utils/syncPolicy.js
Movix 1401dc20f3 tird release
mon compte github a été détecté comme du spam je crois
2026-04-28 17:55:00 +02:00

433 lines
12 KiB
JavaScript

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',
'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
});
class SyncPolicyError extends Error {
constructor(message, status = 400, code = 'SYNC_POLICY_ERROR') {
super(message);
this.name = 'SyncPolicyError';
this.status = status;
this.code = code;
}
}
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,
SYNC_LIMITS,
SyncPolicyError,
assertUserDataSize,
ensureSafeProfileId,
ensureSafeUserId,
ensureValidUserType,
getOwnedProfile,
getProfileFilePath,
getUserDataFilePath,
getUtf8ByteLength,
isSyncableStorageKey,
resolveInside,
sanitizeLegacySyncData,
sanitizeProfileData,
validateAndNormalizeOps
};