From 546d3c84d03fcda67da26090c7c781ecc0183894 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 18:50:14 +0000 Subject: [PATCH] Export: Formelschutz nur fuer Text (inkl. Tab/CR), CSV mit Rechten 0600, Runbook Co-Authored-By: Claude Opus 5.5 --- analytics/scripts/export-site.mjs | 9 +++++++-- analytics/test/export.test.js | 22 +++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/analytics/scripts/export-site.mjs b/analytics/scripts/export-site.mjs index c2f7ad4..0dffec6 100644 --- a/analytics/scripts/export-site.mjs +++ b/analytics/scripts/export-site.mjs @@ -1,5 +1,10 @@ #!/usr/bin/env node // CSV-Export einer Website (Tageswerte, Einzelereignisse, Klicks). Öffnet die Datenbank nur lesend. +// +// Ablauf (Runbook): Im Container webklar-analytics ausführen (Code liegt dort unter /analytics, schreibbar ist nur /srv/webklar-analytics): +// docker exec webklar-analytics node /analytics/scripts/export-site.mjs --out /srv/webklar-analytics/exports/ +// Die CSV-Dateien enthalten Besucher-Hashes (Rechte 0600) und müssen dem Kunden als Kopie innerhalb von zwei Wochen +// übergeben werden (AVV Nr. 9); danach den Export-Ordner wieder löschen. import { DatabaseSync } from 'node:sqlite'; import { mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -11,7 +16,7 @@ const iso = (ts) => new Date(ts).toISOString(); function cell(v) { if (v === null || v === undefined) return ''; let s = String(v); - if (/^[=+\-@]/.test(s)) s = `'${s}`; + if (typeof v === 'string' && /^[=+\-@\t\r]/.test(s)) s = `'${s}`; return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s; } const csv = (head, rows) => [head, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n'; @@ -31,7 +36,7 @@ export function exportSite({ dbPath, siteKey, outDir }) { ev.map((r) => [iso(r.ts), r.type, r.path, r.vp, r.ref, r.country, r.device, r.visitor, r.sec, r.scroll, r.kind, r.host])), [`${siteKey}-klicks.csv`]: csv(['time', 'path', 'vp', 'x', 'y', 'sel'], cl.map((r) => [iso(r.ts), r.path, r.vp, r.x, r.y, r.sel])), }; - for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, 'utf8'); + for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, { encoding: 'utf8', mode: 0o600 }); return Object.keys(files).map((n) => join(outDir, n)); } finally { db.close(); } } diff --git a/analytics/test/export.test.js b/analytics/test/export.test.js index 142290b..2a3554b 100644 --- a/analytics/test/export.test.js +++ b/analytics/test/export.test.js @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import crypto from 'node:crypto'; -import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'; +import { mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -71,3 +71,23 @@ test('CLI: unbekannter oder ungültiger Schlüssel => Exit 1 ohne Dateien', () = assert.equal(ok.status, 0); assert.equal(readdirSync(join(dir, 'ok')).length, 3); }); + +test('Export: Formelschutz nur fuer Text (Tab, CR, Minus), Zahlen bleiben unveraendert, Dateien 0600', () => { + const { dir, dbPath } = setup(); + const db = new DatabaseSync(dbPath); + const ts = Date.UTC(2026, 8, 1, 10); + const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)'); + ins.run(KEY, ts, '/', 'd', -3, -4.5, '\tcmd'); + ins.run(KEY, ts, '/', 'd', 1, 2, '\rcmd'); + ins.run(KEY, ts, '/', 'd', 1, 2, '-cmd'); + db.close(); + const out = join(dir, 'out'); + const files = exportSite({ dbPath, siteKey: KEY, outDir: out }); + const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8'); + assert.ok(c.includes(',-3,-4.5,'), 'negative Zahlen ohne Praefix'); + assert.ok(!c.includes("'-3")); + assert.ok(c.includes(",\"'\tcmd\"") || c.includes(",'\tcmd"), 'Tab gesichert'); + assert.ok(c.includes(`"'\rcmd"`), 'CR gesichert'); + assert.ok(c.includes(",'-cmd")); + for (const f of files) assert.equal(statSync(f).mode & 0o777, 0o600); +});