import { execFile as execFileCallback } from 'node:child_process'; import { promisify } from 'node:util'; import { BadRequestException } from '@nestjs/common'; const execFile = promisify(execFileCallback); export const MAX_ASSET_IMPORT_BYTES = 25 * 1024 * 1024; const MAX_XLSX_UNCOMPRESSED_BYTES = 120 * 1024 * 1024; const MAX_XLSX_ENTRIES = 2500; const MAX_ROWS_PER_SHEET = 50_000; const MAX_COLUMNS = 120; export interface UploadedImportFile { originalname: string; mimetype: string; size: number; buffer: Buffer; } export interface ParsedSheet { name: string; rows: string[][]; } export interface ParsedWorkbook { kind: 'XLSX' | 'CSV'; sheets: ParsedSheet[]; } function importFileError(code: string, message: string): BadRequestException { return new BadRequestException({ code, message }); } export function inspectImportFile(file: UploadedImportFile | undefined): { extension: '.xlsx' | '.csv'; mimeType: string } { if (!file?.buffer?.length) throw importFileError('IMPORT_FILE_REQUIRED', 'Seleccioná un archivo XLSX o CSV'); if (file.buffer.length > MAX_ASSET_IMPORT_BYTES) throw importFileError('IMPORT_FILE_TOO_LARGE', 'El archivo supera el límite de 25 MB'); const lower = file.originalname.toLowerCase(); if (lower.endsWith('.xlsx')) { if (!(file.buffer[0] === 0x50 && file.buffer[1] === 0x4b)) { throw importFileError('INVALID_XLSX_FILE', 'El contenido no corresponde a un archivo XLSX válido'); } return { extension: '.xlsx', mimeType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }; } if (lower.endsWith('.csv')) { if (file.buffer.includes(0)) throw importFileError('INVALID_CSV_FILE', 'El CSV contiene datos binarios no admitidos'); return { extension: '.csv', mimeType: 'text/csv' }; } throw importFileError('UNSUPPORTED_IMPORT_FILE', 'Sólo se admiten archivos .xlsx y .csv'); } function decodeXml(value: string): string { return value .replace(/&#x([0-9a-f]+);/gi, (_match, hex: string) => String.fromCodePoint(Number.parseInt(hex, 16))) .replace(/&#([0-9]+);/g, (_match, decimal: string) => String.fromCodePoint(Number.parseInt(decimal, 10))) .replace(/"/g, '"') .replace(/'/g, "'") .replace(/</g, '<') .replace(/>/g, '>') .replace(/&/g, '&'); } function stripXmlText(xml: string): string { const parts: string[] = []; for (const match of xml.matchAll(/]*)?>([\s\S]*?)<\/t>/g)) parts.push(decodeXml(match[1] ?? '')); return parts.join(''); } function columnIndex(reference: string): number { const match = /^([A-Z]+)\d+$/i.exec(reference); if (!match) return -1; let result = 0; for (const char of match[1]!.toUpperCase()) result = result * 26 + (char.charCodeAt(0) - 64); return result - 1; } async function zipList(filePath: string): Promise { let stdout: string; try { ({ stdout } = await execFile('unzip', ['-Z1', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 })); } catch { throw importFileError('XLSX_UNZIP_UNAVAILABLE', 'No se pudo inspeccionar el XLSX. Verificá que el archivo no esté dañado'); } const entries = stdout.split(/\r?\n/).map((item) => item.trim()).filter(Boolean); if (entries.length > MAX_XLSX_ENTRIES) throw importFileError('XLSX_TOO_COMPLEX', 'El XLSX contiene demasiados archivos internos'); if (entries.some((entry) => entry.startsWith('/') || entry.split('/').includes('..'))) { throw importFileError('INVALID_XLSX_PATH', 'El XLSX contiene rutas internas inválidas'); } return entries; } async function assertZipSize(filePath: string): Promise { try { const { stdout } = await execFile('unzip', ['-l', filePath], { encoding: 'utf8', maxBuffer: 4 * 1024 * 1024 }); const summary = stdout.split(/\r?\n/).reverse().find((line) => /\bfiles?\b/.test(line)); const bytes = summary ? Number(/^\s*(\d+)/.exec(summary)?.[1] ?? 0) : 0; if (Number.isFinite(bytes) && bytes > MAX_XLSX_UNCOMPRESSED_BYTES) { throw importFileError('XLSX_UNCOMPRESSED_TOO_LARGE', 'El contenido descomprimido del XLSX supera el límite de seguridad'); } } catch (error) { if (error instanceof BadRequestException) throw error; throw importFileError('INVALID_XLSX_FILE', 'No se pudo leer la estructura interna del XLSX'); } } async function zipEntry(filePath: string, entry: string): Promise { try { const { stdout } = await execFile('unzip', ['-p', filePath, entry], { encoding: 'utf8', maxBuffer: MAX_XLSX_UNCOMPRESSED_BYTES, }); return stdout; } catch { throw importFileError('INVALID_XLSX_FILE', `No se pudo leer ${entry} dentro del XLSX`); } } function workbookSheets(workbookXml: string, relationshipsXml: string): Array<{ name: string; path: string }> { const relationTargets = new Map(); for (const relation of relationshipsXml.matchAll(/]*)\/?\s*>/g)) { const attributes = relation[1] ?? ''; const id = /\bId="([^"]+)"/.exec(attributes)?.[1]; const target = /\bTarget="([^"]+)"/.exec(attributes)?.[1]; if (id && target) relationTargets.set(id, target); } const result: Array<{ name: string; path: string }> = []; for (const sheet of workbookXml.matchAll(/]*)\/?\s*>/g)) { const attributes = sheet[1] ?? ''; const name = decodeXml(/\bname="([^"]+)"/.exec(attributes)?.[1] ?? 'Hoja'); const relationId = /\br:id="([^"]+)"/.exec(attributes)?.[1]; if (!relationId) continue; const target = relationTargets.get(relationId); if (!target) continue; const clean = target.replace(/^\//, ''); const path = clean.startsWith('xl/') ? clean : `xl/${clean.replace(/^\.\//, '')}`; result.push({ name, path }); } return result; } function parseSharedStrings(xml: string): string[] { const result: string[] = []; for (const match of xml.matchAll(/]*)?>([\s\S]*?)<\/si>/g)) result.push(stripXmlText(match[1] ?? '')); return result; } function cellValue(cellXml: string, cellType: string | undefined, sharedStrings: string[]): string { if (cellType === 'inlineStr') return stripXmlText(cellXml).trim(); const raw = /]*)?>([\s\S]*?)<\/v>/.exec(cellXml)?.[1] ?? ''; const value = decodeXml(raw); if (cellType === 's') { const index = Number.parseInt(value, 10); return Number.isInteger(index) ? (sharedStrings[index] ?? '') : ''; } if (cellType === 'b') return value === '1' ? 'TRUE' : 'FALSE'; if (cellType === 'str') return value; return value; } function parseSheetXml(xml: string, sharedStrings: string[]): string[][] { const rows: string[][] = []; let count = 0; for (const rowMatch of xml.matchAll(/]*>([\s\S]*?)<\/row>/g)) { if (++count > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `La hoja supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`); const values: string[] = []; const rowXml = rowMatch[1] ?? ''; for (const cellMatch of rowXml.matchAll(/]*)>([\s\S]*?)<\/c>/g)) { const attributes = cellMatch[1] ?? ''; const reference = /\br="([A-Z]+\d+)"/i.exec(attributes)?.[1]; if (!reference) continue; const index = columnIndex(reference); if (index < 0 || index >= MAX_COLUMNS) continue; const type = /\bt="([^"]+)"/.exec(attributes)?.[1]; values[index] = cellValue(cellMatch[2] ?? '', type, sharedStrings).trim(); } while (values.length && !values[values.length - 1]) values.pop(); rows.push(values.map((value) => value ?? '')); } return rows; } function detectDelimiter(firstLines: string[]): ',' | ';' | '\t' { const candidates: Array<',' | ';' | '\t'> = [',', ';', '\t']; let best: ',' | ';' | '\t' = ','; let score = -1; for (const candidate of candidates) { const current = firstLines.reduce((sum, line) => sum + line.split(candidate).length - 1, 0); if (current > score) { score = current; best = candidate; } } return best; } export function parseCsv(text: string): string[][] { const normalized = text.replace(/^\uFEFF/, ''); const sample = normalized.split(/\r?\n/).slice(0, 8); const delimiter = detectDelimiter(sample); const rows: string[][] = []; let row: string[] = []; let value = ''; let quoted = false; for (let i = 0; i < normalized.length; i += 1) { const char = normalized[i]!; if (char === '"') { if (quoted && normalized[i + 1] === '"') { value += '"'; i += 1; } else quoted = !quoted; continue; } if (!quoted && char === delimiter) { row.push(value.trim()); value = ''; continue; } if (!quoted && (char === '\n' || char === '\r')) { if (char === '\r' && normalized[i + 1] === '\n') i += 1; row.push(value.trim()); value = ''; if (row.some(Boolean)) rows.push(row); row = []; if (rows.length > MAX_ROWS_PER_SHEET) throw importFileError('IMPORT_TOO_MANY_ROWS', `El archivo supera ${MAX_ROWS_PER_SHEET.toLocaleString('es-AR')} filas`); continue; } value += char; } if (value.length || row.length) { row.push(value.trim()); if (row.some(Boolean)) rows.push(row); } return rows; } export async function parseImportWorkbook(filePath: string, extension: '.xlsx' | '.csv'): Promise { if (extension === '.csv') { const { readFile } = await import('node:fs/promises'); const text = await readFile(filePath, 'utf8'); return { kind: 'CSV', sheets: [{ name: 'CSV', rows: parseCsv(text) }] }; } await assertZipSize(filePath); const entries = await zipList(filePath); if (!entries.includes('xl/workbook.xml') || !entries.includes('xl/_rels/workbook.xml.rels')) { throw importFileError('INVALID_XLSX_FILE', 'El archivo no contiene una estructura XLSX compatible'); } const [workbookXml, relationshipsXml] = await Promise.all([ zipEntry(filePath, 'xl/workbook.xml'), zipEntry(filePath, 'xl/_rels/workbook.xml.rels'), ]); const sharedStrings = entries.includes('xl/sharedStrings.xml') ? parseSharedStrings(await zipEntry(filePath, 'xl/sharedStrings.xml')) : []; const sheets = workbookSheets(workbookXml, relationshipsXml); if (!sheets.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'El XLSX no contiene hojas legibles'); const parsed: ParsedSheet[] = []; for (const sheet of sheets.slice(0, 30)) { if (!entries.includes(sheet.path)) continue; const xml = await zipEntry(filePath, sheet.path); parsed.push({ name: sheet.name, rows: parseSheetXml(xml, sharedStrings) }); } if (!parsed.length) throw importFileError('XLSX_WITHOUT_SHEETS', 'No se pudo leer ninguna hoja del XLSX'); return { kind: 'XLSX', sheets: parsed }; }