This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
export const MAX_IMPORT_FILE_BYTES = 5 * 1024 * 1024;
|
||||
export const MAX_IMPORT_ROWS = 2_000;
|
||||
export const MAX_IMPORT_COLUMNS = 100;
|
||||
export const MAX_IMPORT_CELL_CHARS = 10_000;
|
||||
const MAX_XLSX_ENTRIES = 256;
|
||||
const MAX_XLSX_UNCOMPRESSED_BYTES = 20 * 1024 * 1024;
|
||||
|
||||
export interface ParsedTable {
|
||||
fileName: string;
|
||||
sheetName: string | null;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export function parseTabularFile(input: {
|
||||
fileName: string;
|
||||
mimeType?: string;
|
||||
bytes: Uint8Array;
|
||||
}): ParsedTable {
|
||||
if (input.bytes.byteLength === 0) throw new Error('The selected file is empty.');
|
||||
if (input.bytes.byteLength > MAX_IMPORT_FILE_BYTES) {
|
||||
throw new Error('Import files may not exceed 5 MB.');
|
||||
}
|
||||
const fileName = input.fileName.trim().slice(0, 255);
|
||||
const lowerName = fileName.toLowerCase();
|
||||
if (lowerName.endsWith('.csv') || input.mimeType === 'text/csv') {
|
||||
const text = new TextDecoder('utf-8', { fatal: true }).decode(input.bytes);
|
||||
const { headers, rows, warnings } = parseCsv(text);
|
||||
return { fileName, sheetName: null, headers, rows, warnings };
|
||||
}
|
||||
if (
|
||||
lowerName.endsWith('.xlsx') ||
|
||||
input.mimeType === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
) {
|
||||
const { sheetName, headers, rows, warnings } = parseXlsx(input.bytes);
|
||||
return { fileName, sheetName, headers, rows, warnings };
|
||||
}
|
||||
throw new Error('Use a UTF-8 CSV or .xlsx workbook. Legacy .xls files are not supported.');
|
||||
}
|
||||
|
||||
export function parseCsv(source: string): Omit<ParsedTable, 'fileName' | 'sheetName'> {
|
||||
if (source.includes('\0')) throw new Error('The CSV contains invalid null bytes.');
|
||||
const table: string[][] = [];
|
||||
let row: string[] = [];
|
||||
let field = '';
|
||||
let quoted = false;
|
||||
|
||||
const pushField = () => {
|
||||
if (field.length > MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.');
|
||||
row.push(field);
|
||||
field = '';
|
||||
if (row.length > MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.');
|
||||
};
|
||||
const pushRow = () => {
|
||||
pushField();
|
||||
table.push(row);
|
||||
row = [];
|
||||
if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.');
|
||||
};
|
||||
|
||||
const text = source.charCodeAt(0) === 0xfeff ? source.slice(1) : source;
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index]!;
|
||||
if (quoted) {
|
||||
if (character === '"') {
|
||||
if (text[index + 1] === '"') {
|
||||
field += '"';
|
||||
index += 1;
|
||||
} else {
|
||||
quoted = false;
|
||||
}
|
||||
} else {
|
||||
field += character;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (character === '"' && field.length === 0) quoted = true;
|
||||
else if (character === ',') pushField();
|
||||
else if (character === '\n') pushRow();
|
||||
else if (character === '\r' && text[index + 1] === '\n') continue;
|
||||
else if (character === '\r') pushRow();
|
||||
else field += character;
|
||||
}
|
||||
if (quoted) throw new Error('The CSV ends inside a quoted cell.');
|
||||
if (field.length > 0 || row.length > 0) pushRow();
|
||||
|
||||
return normaliseTabularRows(table, []);
|
||||
}
|
||||
|
||||
function parseXlsx(bytes: Uint8Array): {
|
||||
sheetName: string;
|
||||
headers: string[];
|
||||
rows: string[][];
|
||||
warnings: string[];
|
||||
} {
|
||||
const entries = readZip(bytes);
|
||||
const workbook = readXml(entries, 'xl/workbook.xml');
|
||||
const relationships = readXml(entries, 'xl/_rels/workbook.xml.rels');
|
||||
rejectActiveXml(workbook);
|
||||
rejectActiveXml(relationships);
|
||||
|
||||
const sheets = [...workbook.matchAll(/<sheet\b[^>]*>/gi)]
|
||||
.map((match) => ({
|
||||
name: xmlAttribute(match[0], 'name'),
|
||||
relationshipId: xmlAttribute(match[0], 'r:id'),
|
||||
hidden: ['hidden', 'veryHidden'].includes(xmlAttribute(match[0], 'state') ?? ''),
|
||||
}))
|
||||
.filter((sheet) => sheet.name && sheet.relationshipId && !sheet.hidden);
|
||||
const firstSheet = sheets[0];
|
||||
if (!firstSheet?.name || !firstSheet.relationshipId) {
|
||||
throw new Error('The workbook has no visible worksheet.');
|
||||
}
|
||||
const relationship = [...relationships.matchAll(/<Relationship\b[^>]*>/gi)]
|
||||
.map((match) => match[0])
|
||||
.find((tag) => xmlAttribute(tag, 'Id') === firstSheet.relationshipId);
|
||||
const target = relationship ? xmlAttribute(relationship, 'Target') : null;
|
||||
if (!target) throw new Error('The workbook worksheet relationship is invalid.');
|
||||
const sheetPath = normaliseZipPath(target.startsWith('/') ? target.slice(1) : `xl/${target}`);
|
||||
const sharedStrings = entries.has('xl/sharedStrings.xml')
|
||||
? parseSharedStrings(readXml(entries, 'xl/sharedStrings.xml'))
|
||||
: [];
|
||||
const worksheet = readXml(entries, sheetPath);
|
||||
const parsed = parseWorksheetXml(worksheet, sharedStrings);
|
||||
return { sheetName: decodeXml(firstSheet.name), ...parsed };
|
||||
}
|
||||
|
||||
export function parseWorksheetXml(
|
||||
worksheet: string,
|
||||
sharedStrings: readonly string[] = [],
|
||||
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
|
||||
rejectActiveXml(worksheet);
|
||||
const table: string[][] = [];
|
||||
const warnings: string[] = [];
|
||||
let sawFormula = false;
|
||||
for (const rowMatch of worksheet.matchAll(/<row\b[^>]*>([\s\S]*?)<\/row>/gi)) {
|
||||
const row: string[] = [];
|
||||
let sequentialColumn = 0;
|
||||
for (const cellMatch of rowMatch[1]!.matchAll(/<c\b([^>]*)>([\s\S]*?)<\/c>/gi)) {
|
||||
const attributes = cellMatch[1]!;
|
||||
const body = cellMatch[2]!;
|
||||
const reference = xmlAttribute(attributes, 'r');
|
||||
const column = reference ? columnFromReference(reference) : sequentialColumn;
|
||||
if (column >= MAX_IMPORT_COLUMNS) throw new Error('Imports may not exceed 100 columns.');
|
||||
const type = xmlAttribute(attributes, 't') ?? 'n';
|
||||
const rawValue = body.match(/<v\b[^>]*>([\s\S]*?)<\/v>/i)?.[1] ?? '';
|
||||
if (/<f\b/i.test(body)) sawFormula = true;
|
||||
let value: string;
|
||||
if (type === 's') value = sharedStrings[Number(rawValue)] ?? '';
|
||||
else if (type === 'inlineStr') value = extractTextNodes(body);
|
||||
else if (type === 'b') value = rawValue === '1' ? 'true' : 'false';
|
||||
else value = decodeXml(rawValue);
|
||||
if (value.length > MAX_IMPORT_CELL_CHARS) throw new Error('A cell exceeds 10,000 characters.');
|
||||
row[column] = value;
|
||||
sequentialColumn = column + 1;
|
||||
}
|
||||
if (row.some((value) => value !== undefined && value !== '')) table.push(row);
|
||||
if (table.length > MAX_IMPORT_ROWS + 1) throw new Error('Imports may not exceed 2,000 data rows.');
|
||||
}
|
||||
if (sawFormula) {
|
||||
warnings.push('Formula cells were not executed; only cached values stored in the workbook were read.');
|
||||
}
|
||||
return normaliseTabularRows(table, warnings);
|
||||
}
|
||||
|
||||
export function normaliseTabularRows(
|
||||
table: string[][],
|
||||
warnings: string[],
|
||||
): Omit<ParsedTable, 'fileName' | 'sheetName'> {
|
||||
while (table.length > 0 && table.at(-1)!.every((cell) => !cell?.trim())) table.pop();
|
||||
const headerRow = table.shift();
|
||||
if (!headerRow) throw new Error('The file has no header row.');
|
||||
let width = headerRow.length;
|
||||
while (width > 0 && !headerRow[width - 1]?.trim()) width -= 1;
|
||||
if (width === 0) throw new Error('The file has no named columns.');
|
||||
const headers = headerRow.slice(0, width).map((header) => header.trim());
|
||||
if (headers.some((header) => !header)) throw new Error('Every imported column needs a header.');
|
||||
const normalised = headers.map((header) => header.toLocaleLowerCase());
|
||||
if (new Set(normalised).size !== normalised.length) {
|
||||
throw new Error('Column headers must be unique, ignoring letter case.');
|
||||
}
|
||||
const rows = table
|
||||
.map((sourceRow) => Array.from({ length: width }, (_, index) => sourceRow[index] ?? ''))
|
||||
.filter((sourceRow) => sourceRow.some((cell) => cell.trim() !== ''));
|
||||
if (rows.length === 0) throw new Error('The file has headers but no data rows.');
|
||||
if (rows.length > MAX_IMPORT_ROWS) throw new Error('Imports may not exceed 2,000 data rows.');
|
||||
if (rows.some((sourceRow) => sourceRow.some((cell) => /^[=+@]/.test(cell.trim())))) {
|
||||
warnings.push('Formula-like text from the source remains inert text and is never executed.');
|
||||
}
|
||||
return { headers, rows, warnings };
|
||||
}
|
||||
|
||||
function readZip(bytes: Uint8Array): Map<string, Uint8Array> {
|
||||
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
let end = -1;
|
||||
for (let offset = bytes.byteLength - 22; offset >= Math.max(0, bytes.byteLength - 65_557); offset -= 1) {
|
||||
if (view.getUint32(offset, true) === 0x06054b50) {
|
||||
end = offset;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (end < 0) throw new Error('The .xlsx ZIP directory is invalid.');
|
||||
const count = view.getUint16(end + 10, true);
|
||||
const centralOffset = view.getUint32(end + 16, true);
|
||||
if (count > MAX_XLSX_ENTRIES || count === 0xffff || centralOffset === 0xffffffff) {
|
||||
throw new Error('The workbook archive is too large or uses unsupported ZIP64 metadata.');
|
||||
}
|
||||
const entries = new Map<string, Uint8Array>();
|
||||
let offset = centralOffset;
|
||||
let totalUncompressed = 0;
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
if (offset + 46 > bytes.byteLength || view.getUint32(offset, true) !== 0x02014b50) {
|
||||
throw new Error('The .xlsx ZIP directory is corrupt.');
|
||||
}
|
||||
const flags = view.getUint16(offset + 8, true);
|
||||
const method = view.getUint16(offset + 10, true);
|
||||
const compressedSize = view.getUint32(offset + 20, true);
|
||||
const uncompressedSize = view.getUint32(offset + 24, true);
|
||||
const nameLength = view.getUint16(offset + 28, true);
|
||||
const extraLength = view.getUint16(offset + 30, true);
|
||||
const commentLength = view.getUint16(offset + 32, true);
|
||||
const localOffset = view.getUint32(offset + 42, true);
|
||||
const name = new TextDecoder().decode(bytes.subarray(offset + 46, offset + 46 + nameLength));
|
||||
const safeName = normaliseZipPath(name);
|
||||
if ((flags & 1) !== 0) throw new Error('Encrypted workbooks are not supported.');
|
||||
if (method !== 0 && method !== 8) throw new Error('The workbook uses unsupported ZIP compression.');
|
||||
totalUncompressed += uncompressedSize;
|
||||
if (totalUncompressed > MAX_XLSX_UNCOMPRESSED_BYTES) {
|
||||
throw new Error('The expanded workbook may not exceed 20 MB.');
|
||||
}
|
||||
if (localOffset + 30 > bytes.byteLength || view.getUint32(localOffset, true) !== 0x04034b50) {
|
||||
throw new Error('The workbook contains an invalid ZIP entry.');
|
||||
}
|
||||
const localNameLength = view.getUint16(localOffset + 26, true);
|
||||
const localExtraLength = view.getUint16(localOffset + 28, true);
|
||||
const dataOffset = localOffset + 30 + localNameLength + localExtraLength;
|
||||
const compressed = bytes.subarray(dataOffset, dataOffset + compressedSize);
|
||||
if (compressed.byteLength !== compressedSize) throw new Error('The workbook ZIP entry is truncated.');
|
||||
const output = method === 0
|
||||
? Uint8Array.from(compressed)
|
||||
: inflateRawSync(compressed, { maxOutputLength: MAX_XLSX_UNCOMPRESSED_BYTES });
|
||||
if (output.byteLength !== uncompressedSize) throw new Error('The workbook ZIP entry size is inconsistent.');
|
||||
entries.set(safeName, output);
|
||||
offset += 46 + nameLength + extraLength + commentLength;
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function readXml(entries: Map<string, Uint8Array>, name: string): string {
|
||||
const bytes = entries.get(name);
|
||||
if (!bytes) throw new Error(`The workbook is missing ${name}.`);
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
||||
}
|
||||
|
||||
function rejectActiveXml(xml: string): void {
|
||||
if (/<!DOCTYPE|<!ENTITY/i.test(xml)) throw new Error('Workbook XML declarations are not allowed.');
|
||||
}
|
||||
|
||||
function normaliseZipPath(path: string): string {
|
||||
const segments: string[] = [];
|
||||
for (const segment of path.replace(/\\/g, '/').split('/')) {
|
||||
if (!segment || segment === '.') continue;
|
||||
if (segment === '..') {
|
||||
if (segments.length === 0) throw new Error('The workbook contains an unsafe ZIP path.');
|
||||
segments.pop();
|
||||
} else {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
return segments.join('/');
|
||||
}
|
||||
|
||||
function xmlAttribute(tag: string, name: string): string | null {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const match = tag.match(new RegExp(`${escaped}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`, 'i'));
|
||||
return match ? decodeXml(match[1] ?? match[2] ?? '') : null;
|
||||
}
|
||||
|
||||
function parseSharedStrings(xml: string): string[] {
|
||||
rejectActiveXml(xml);
|
||||
return [...xml.matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/gi)].map((match) =>
|
||||
extractTextNodes(match[1]!),
|
||||
);
|
||||
}
|
||||
|
||||
function extractTextNodes(xml: string): string {
|
||||
return [...xml.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/gi)]
|
||||
.map((match) => decodeXml(match[1]!))
|
||||
.join('');
|
||||
}
|
||||
|
||||
function decodeXml(value: string): string {
|
||||
return value.replace(/&(?:#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (entity) => {
|
||||
if (entity === '&') return '&';
|
||||
if (entity === '<') return '<';
|
||||
if (entity === '>') return '>';
|
||||
if (entity === '"') return '"';
|
||||
if (entity === ''') return "'";
|
||||
const numeric = entity.startsWith('&#x')
|
||||
? Number.parseInt(entity.slice(3, -1), 16)
|
||||
: Number.parseInt(entity.slice(2, -1), 10);
|
||||
return Number.isFinite(numeric) ? String.fromCodePoint(numeric) : entity;
|
||||
});
|
||||
}
|
||||
|
||||
function columnFromReference(reference: string): number {
|
||||
const letters = reference.match(/^[A-Za-z]+/)?.[0];
|
||||
if (!letters) throw new Error('The worksheet contains an invalid cell reference.');
|
||||
let column = 0;
|
||||
for (const letter of letters.toUpperCase()) column = column * 26 + letter.charCodeAt(0) - 64;
|
||||
return column - 1;
|
||||
}
|
||||
Reference in New Issue
Block a user