Files
alpenwerk-hr/lib/export.ts
Andrei Laas 38e35e2ff9
Some checks failed
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
CI / Lint, Typen, Tests, Build (push) Has been cancelled
Kein Formelschutz in einer Datei, die eine Maschine einliest
Das vorangestellte Hochkomma schuetzt einen Menschen, der eine CSV in Excel
oeffnet: ein Wert mit fuehrendem =, +, - oder @ waere dort sonst eine Formel.
In einer Load-Datei ist es das Gegenteil von Schutz. Jede oesterreichische
Telefonnummer beginnt mit "+", und in LOLYO stuende danach in jedem Konto
'+4366... als Nummer -- ohne dass es beim Erzeugen jemandem auffiele.

toCsv bekommt dafuer formelschutz in der CsvForm, voreingestellt an. Nur LOLYO
schaltet es ab. Die Telefonspalten von Cornerstone sind bisher leer, deshalb
bleibt die Datei dort, wie sie ist.
2026-09-28 13:47:49 +02:00

129 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<T> = {
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, trenner: string, formelschutz: boolean): string {
if (value === null || value === undefined) return "";
const roh = typeof value === "boolean" ? (value ? "Ja" : "Nein") : String(value);
const text = formelschutz ? sanitizeForSpreadsheetCell(roh) : roh;
// Das Trennzeichen gehört in die Prüfung: mit Komma als Trenner muss ein
// Komma im Wert maskiert werden, sonst zerfällt die Zeile in zwei Spalten.
return text.includes(trenner) || /["\n\r]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
/**
* Wie die Datei aussehen soll.
*
* Die Vorgaben sind für Excel in deutscher Einstellung gemacht: Semikolon als
* Trenner und ein BOM voran, damit Umlaute nicht als Buchstabensalat
* ankommen. Ein fremdes System will oft das Gegenteil — Cornerstone etwa
* erwartet Kommas, und ein BOM machte dort aus der ersten Spalte eine, die in
* keiner Zuordnung vorkommt, weil das unsichtbare Zeichen im Namen steckt.
*
* `formelschutz: false` schaltet das vorangestellte Hochkomma ab (siehe
* sanitizeForSpreadsheetCell). Es gehört in eine Datei, die ein Mensch in Excel
* öffnet, und es verdirbt eine, die eine Maschine einliest: eine österreichische
* Telefonnummer beginnt mit „+", und im Zielsystem stünde danach `'+43…` als
* Nummer. Nur für Dateien setzen, die ausschliesslich ein Fremdsystem liest.
*/
export type CsvForm = { trenner?: "," | ";"; bom?: boolean; formelschutz?: boolean };
export function toCsv<T>(rows: T[], columns: ExportColumn<T>[], form: CsvForm = {}): string {
const trenner = form.trenner ?? ";";
const bom = form.bom ?? true;
const schutz = form.formelschutz ?? true;
const lines = [columns.map((c) => csvCell(c.header, trenner, schutz)).join(trenner)];
for (const row of rows) {
lines.push(columns.map((c) => csvCell(c.get(row), trenner, schutz)).join(trenner));
}
return (bom ? "" : "") + 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<T>(rows: T[], columns: ExportColumn<T>[], sheetName: string): Promise<Uint8Array> {
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<string, string | number | boolean | Date | null> = {};
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}"`,
};
}