mirror of
https://github.com/movixcorp/MovixOpenSource.git
synced 2026-07-26 16:32:07 +00:00
Modification du Oauth : Pouvoir manager les apps depuis le panel admin Correction de l'affichage des images des admins Majs des urls extension, userscript et premid Correction d'un crash sur les commentaires Ajout d'un light mode sur le frontend
435 lines
12 KiB
JavaScript
435 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',
|
|
// SECURITY (audit P0) : `is_vip` retiré du sync — c'est juste un cache UI
|
|
// côté frontend qui doit être recalculé via /api/check-vip à chaque session.
|
|
// Le laisser syncable permettait à n'importe qui de forger son statut 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
|
|
};
|