Files
dh-inspeccion-v2/api-v3/src/asset-imports/asset-import-parser.ts
T

251 lines
10 KiB
TypeScript

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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function stripXmlText(xml: string): string {
const parts: string[] = [];
for (const match of xml.matchAll(/<t(?:\s[^>]*)?>([\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<string[]> {
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<void> {
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<string> {
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<string, string>();
for (const relation of relationshipsXml.matchAll(/<Relationship\b([^>]*)\/?\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(/<sheet\b([^>]*)\/?\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(/<si(?:\s[^>]*)?>([\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 = /<v(?:\s[^>]*)?>([\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(/<row\b[^>]*>([\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(/<c\b([^>]*)>([\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<ParsedWorkbook> {
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 };
}