Implementation de la feature de gestion des fichiers sur les investissements

This commit is contained in:
ocroguennec committed 2026-08-29 18:49:01 +02:00
1 parent f6829dec9d
commit 8953d0bb35
21 files changed
+1729 -181

No files matched your search

+49 -3
View File
@@ -28,6 +28,15 @@ function dosDateTime(d = new Date()) {
return { time, date };
}
// ── Nettoyage d'un segment de chemin ZIP ────────────────────────────────────
// Retire les caractères interdits dans un nom de fichier/dossier cross-platform
// (\ / : * ? " < > | + caractères de contrôle) tout en conservant accents et
// espaces. Partagé par tous les exports ZIP de l'app (documents, investissements...)
// pour que la même règle de nommage s'applique partout.
export function sanitizeZipPart(name) {
return String(name).replace(/[\\/:*?"<>|\u0000-\u001F]/g, ' ').replace(/\s+/g, ' ').trim() || 'Sans nom';
}
/**
* createZip(entries) → Buffer
* entries: [{ name: string, data: Buffer|string }]
@@ -54,7 +63,14 @@ export function createZip(entries) {
const lh = Buffer.alloc(30 + nameBuf.length);
lh.writeUInt32LE(0x04034b50, 0);
lh.writeUInt16LE(20, 4);
lh.writeUInt16LE(0, 6);
// Bit 11 (0x0800) = "Language encoding flag (EFS)" — indique que le nom
// de fichier est encodé en UTF-8. Sans ce bit, les extracteurs qui ne
// devinent pas l'UTF-8 par défaut (Windows Explorer notamment) réinterprètent
// les octets UTF-8 des noms accentués avec la page de code système, d'où le
// mojibake constaté par Olivier ("Billet_électronique" -> "Billet_ Ⓡlectronique").
// Les noms sont toujours écrits en UTF-8 ci-dessus (Buffer.from(name, 'utf8')),
// ce bit est donc toujours correct à poser.
lh.writeUInt16LE(0x0800, 6);
lh.writeUInt16LE(method, 8);
lh.writeUInt16LE(modTime, 10);
lh.writeUInt16LE(modDate, 12);
@@ -77,7 +93,7 @@ export function createZip(entries) {
cd.writeUInt32LE(0x02014b50, 0);
cd.writeUInt16LE(20, 4);
cd.writeUInt16LE(20, 6);
cd.writeUInt16LE(0, 8);
cd.writeUInt16LE(0x0800, 8); // même flag UTF-8 (EFS) que l'en-tête local, cf. commentaire ci-dessus
cd.writeUInt16LE(h.method, 10);
cd.writeUInt16LE(h.modTime, 12);
cd.writeUInt16LE(h.modDate, 14);
@@ -113,6 +129,25 @@ export function createZip(entries) {
return Buffer.concat(chunks);
}
// ── Protection contre les "zip bombs" ───────────────────────────────────
// readZip() est utilisé pour relire des zips produits en interne par l'appli
// (exports/sauvegardes admin — contenu maîtrisé) MAIS AUSSI pour des zips
// envoyés directement par un utilisateur (import de dossier d'investissement,
// import PFU/plateformes/référentiel) — donc potentiellement malveillants.
// Un zip conçu avec des données très répétitives peut annoncer quelques Ko
// compressés pour plusieurs Go décompressés (ratio extrême), ce qui saturerait
// la mémoire du process si rien ne le limite. Deux garde-fous complémentaires :
// 1. Rejet précoce si la taille décompressée ANNONCÉE dans l'en-tête central
// dépasse déjà la limite (évite même de lancer la décompression).
// 2. `maxOutputLength` passé à zlib : filet de sécurité si l'en-tête ment sur
// la taille annoncée — zlib interrompt la décompression dès que la sortie
// réelle dépasse la limite, sans jamais allouer plus que nécessaire.
// Limites généreuses par rapport à l'usage réel de l'appli (sauvegarde complète
// = base + logos/icons/documents, quelques dizaines de Mo en pratique) tout en
// bornant strictement le pire cas.
export const MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES = 500 * 1024 * 1024; // 500 Mo par entrée
export const MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES = 1024 * 1024 * 1024; // 1 Go cumulé par zip
/**
* readZip(buffer) → [{ name: string, data: Buffer }]
*/
@@ -129,6 +164,7 @@ export function readZip(buffer) {
const entries = [];
let pos = cdOffset;
let totalUncompressed = 0;
for (let i = 0; i < entryCount; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('ZIP invalide : signature Central Directory incorrecte');
@@ -141,13 +177,23 @@ export function readZip(buffer) {
const localOffset = buffer.readUInt32LE(pos + 42);
const name = buffer.toString('utf8', pos + 46, pos + 46 + nameLen);
if (uncompSize > MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES) {
throw new Error(`ZIP invalide : l'entrée "${name}" dépasse la taille décompressée maximale autorisée (${Math.round(MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
}
totalUncompressed += uncompSize;
if (totalUncompressed > MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES) {
throw new Error(`ZIP invalide : la taille décompressée totale dépasse la limite autorisée (${Math.round(MAX_ZIP_TOTAL_UNCOMPRESSED_BYTES / 1024 / 1024)} Mo).`);
}
// Read local file header to get actual extra field length
const localNameLen = buffer.readUInt16LE(localOffset + 26);
const localExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + localNameLen + localExtraLen;
const compData = buffer.subarray(dataStart, dataStart + compSize);
const data = method === 0 ? compData : zlib.inflateRawSync(compData);
// maxOutputLength : filet de sécurité si l'en-tête ment sur uncompSize —
// voir commentaire au-dessus des constantes MAX_ZIP_*.
const data = method === 0 ? compData : zlib.inflateRawSync(compData, { maxOutputLength: MAX_ZIP_ENTRY_UNCOMPRESSED_BYTES });
entries.push({ name, data: Buffer.from(data) });
pos += 46 + nameLen + extraLen + commentLen;