251 lines
10 KiB
TypeScript
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(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/&/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 };
|
|
}
|