import ExcelJS from "exceljs"; import { todayIso } from "./format"; // Shared by every /api/export/* route: define columns once as { header, get }, // get both a semicolon CSV (Excel-DE friendly) and a real .xlsx workbook from // the same data + column definitions. kind: "date" tells the xlsx writer to // emit a real date cell (not a text string) for ISO ("YYYY-MM-DD") values. export type ExportColumn = { header: string; get: (row: T) => string | number | boolean | null; kind?: "date"; }; // CSV/Excel formula injection (CWE-1236): a cell whose text begins with // =, +, -, or @ is interpreted as a formula by Excel/Sheets/LibreOffice on // open, not as literal text — dangerous when the source data (employee // names, job titles, free-text notes, audit details, ...) can contain // attacker- or user-supplied strings. Prefixing with a single quote is the // standard mitigation (OWASP CSV Injection cheat sheet); it forces the cell // to render as text at the cost of a visible leading ' for the rare // legitimate value that starts with one of these characters. export function sanitizeForSpreadsheetCell(text: string): string { return /^[=+\-@]/.test(text) ? `'${text}` : text; } function csvCell(value: string | number | boolean | null): string { if (value === null || value === undefined) return ""; const text = typeof value === "boolean" ? (value ? "Ja" : "Nein") : sanitizeForSpreadsheetCell(String(value)); return /[";\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text; } // Leading BOM + semicolon delimiter: Excel's German locale default, and what // makes umlauts render correctly instead of mojibake on open. export function toCsv(rows: T[], columns: ExportColumn[]): string { const lines = [columns.map((c) => csvCell(c.header)).join(";")]; for (const row of rows) { lines.push(columns.map((c) => csvCell(c.get(row))).join(";")); } return "" + lines.join("\r\n"); } // Anchored at UTC midnight, not local: ExcelJS converts a JS Date to an Excel // serial straight off getTime() with no timezone adjustment, so a Date built // at *local* midnight in a positive-offset zone (Vienna) lands on the previous // day's serial and every date cell in the workbook renders one day early. function parseIsoDate(value: string): Date | null { const d = new Date(`${value}T00:00:00Z`); return Number.isNaN(d.getTime()) ? null : d; } export async function toXlsx(rows: T[], columns: ExportColumn[], sheetName: string): Promise { const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet(sheetName.slice(0, 31)); // Keyed by position, not by header text: split columns take their header // from the data (a team name, a weekday), so two columns can legitimately // collide — and ExcelJS silently drops the second one when two share a key. sheet.columns = columns.map((c, i) => ({ header: c.header, key: String(i), width: Math.min(40, Math.max(12, c.header.length + 4)), style: c.kind === "date" ? { numFmt: "dd.mm.yyyy" } : undefined, })); sheet.getRow(1).font = { bold: true }; sheet.autoFilter = { from: { row: 1, column: 1 }, to: { row: 1, column: columns.length } }; sheet.views = [{ state: "frozen", ySplit: 1 }]; for (const row of rows) { const record: Record = {}; for (const [i, c] of columns.entries()) { const value = c.get(row); record[String(i)] = c.kind === "date" && typeof value === "string" && value ? (parseIsoDate(value) ?? value) : typeof value === "string" ? sanitizeForSpreadsheetCell(value) : value; } sheet.addRow(record); } const written = await workbook.xlsx.writeBuffer(); return new Uint8Array(written); } // The base carries values that originate in the query string (event type, // measure, dimension) and ends up inside a Content-Disposition header, so it // is reduced to a filename-safe slug here rather than trusted. Callers also // validate those params; this is the backstop that makes header injection // impossible regardless. export function exportFilename(base: string, format: "csv" | "xlsx"): string { const slug = base .normalize("NFKD") .replace(/[^a-zA-Z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 80); return `${slug || "export"}-${todayIso()}.${format}`; } export function exportResponseHeaders(filename: string, format: "csv" | "xlsx"): HeadersInit { const contentType = format === "xlsx" ? "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" : "text/csv; charset=utf-8"; return { "Content-Type": contentType, "Content-Disposition": `attachment; filename="${filename}"`, }; }