feat(import): harden workbook parser boundaries
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user