feat(import): harden workbook parser boundaries

This commit is contained in:
2026-03-31 22:48:30 +02:00
parent 3e8b1702bc
commit db50e2e555
20 changed files with 936 additions and 174 deletions
@@ -1,4 +1,3 @@
import * as XLSX from "xlsx";
import {
EstimateExportFormat,
type EstimateExportArtifactPayload,
@@ -8,6 +7,8 @@ import {
} from "@capakraken/shared";
import { summarizeEstimateDemandLines } from "./metrics.js";
type ExcelJsModule = typeof import("exceljs");
type ExportProjectRef = {
id: string;
name: string;
@@ -109,6 +110,18 @@ type ExportMetric = {
updatedAt: Date;
};
type ExportSheetRow = Record<string, unknown>;
let _excelJs: ExcelJsModule | null = null;
async function getExcelJS() {
if (!_excelJs) {
_excelJs = await import("exceljs");
}
return _excelJs;
}
export interface EstimateExportSource {
estimate: {
id: string;
@@ -508,6 +521,52 @@ function base64ByteLength(content: string) {
return Math.floor((content.length * 3) / 4) - padding;
}
function buildSheetColumns(rows: ExportSheetRow[]) {
return Array.from(
rows.reduce((keys, row) => {
for (const key of Object.keys(row)) {
keys.add(key);
}
return keys;
}, new Set<string>()),
);
}
function toWorksheetCellValue(value: unknown): boolean | Date | number | string {
if (value == null) {
return "";
}
if (value instanceof Date) {
return value;
}
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
return value;
}
return stringifyValue(value);
}
function appendWorksheetFromRows(
workbook: InstanceType<ExcelJsModule["Workbook"]>,
sheetName: string,
rows: ExportSheetRow[],
): void {
const worksheet = workbook.addWorksheet(sheetName);
const columns = buildSheetColumns(rows);
if (columns.length === 0) {
return;
}
worksheet.addRow(columns);
for (const row of rows) {
worksheet.addRow(columns.map((column) => toWorksheetCellValue(row[column])));
}
}
function buildTextPayload(
format: EstimateExportFormat,
content: string,
@@ -536,17 +595,18 @@ function buildTextPayload(
};
}
function buildXlsxPayload(
async function buildXlsxPayload(
source: EstimateExportSource,
summary: EstimateExportSummary,
): EstimateExportArtifactPayload {
): Promise<EstimateExportArtifactPayload> {
const overviewRows = buildOverviewRows(source, summary);
const assumptionRows = buildAssumptionRows(source.version.assumptions);
const scopeRows = buildScopeRows(source.version.scopeItems);
const demandRows = buildDemandRows(source);
const resourceRows = buildResourceRows(source.version.resourceSnapshots);
const metricRows = buildMetricRows(source.version.metrics);
const workbook = XLSX.utils.book_new();
const ExcelJS = await getExcelJS();
const workbook = new ExcelJS.Workbook();
const sheets = [
{ name: "Overview", rows: overviewRows },
{ name: "Assumptions", rows: assumptionRows },
@@ -557,17 +617,11 @@ function buildXlsxPayload(
] as const;
for (const sheet of sheets) {
XLSX.utils.book_append_sheet(
workbook,
XLSX.utils.json_to_sheet(sheet.rows),
sheet.name,
);
appendWorksheetFromRows(workbook, sheet.name, sheet.rows);
}
const content = XLSX.write(workbook, {
type: "base64",
bookType: "xlsx",
});
const buffer = await workbook.xlsx.writeBuffer();
const content = Buffer.from(buffer).toString("base64");
return {
schemaVersion: 1,
@@ -593,10 +647,10 @@ function buildXlsxPayload(
};
}
export function serializeEstimateExport(
export async function serializeEstimateExport(
source: EstimateExportSource,
format: EstimateExportFormat,
): EstimateExportArtifactPayload {
): Promise<EstimateExportArtifactPayload> {
const summary = buildSummary(source);
if (format === EstimateExportFormat.JSON) {