diff --git a/runner/src/analytics-inject.js b/runner/src/analytics-inject.js new file mode 100644 index 0000000..d1a41ab --- /dev/null +++ b/runner/src/analytics-inject.js @@ -0,0 +1,66 @@ +// Website-Analyse: Skript-Tag in veröffentlichte HTML-Dateien einsetzen und Datenschutz-Zusatzseite schreiben. +import * as nodeFs from 'node:fs/promises'; +import { join } from 'node:path'; + +export const RE_SITE_KEY = /^[a-z0-9]{16,40}$/; +export const RE_COLOR = /^#[0-9a-f]{6}$/; +const MAX_HTML_BYTES = 5 * 1024 * 1024; +const PAGE = 'datenschutz-analyse.html'; +const TEMPLATE_URL = new URL('./templates/datenschutz-analyse.html', import.meta.url); + +const esc = (s) => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); + +export const isValidBase = (b) => typeof b === 'string' && /^https:\/\/[^\s"'<>]+\/$/.test(b); + +export function scriptTag(analytics, baseUrl) { + // Attributwerte nur aus geprüften Eingaben (Regex), daher ohne Maskierung sicher. + return ``; +} + +// Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben. +export function injectIntoHtml(buf, tag) { + let s = buf.toString('latin1'); + const old = /]*\bdata-wk-analytics\b[^>]*>\s*<\/script\s*>/gi; + if (old.test(s)) { + let first = true; + s = s.replace(old, () => (first ? ((first = false), tag) : '')); + } else { + const head = s.search(/<\/head\s*>/i); + if (head >= 0) s = s.slice(0, head) + tag + s.slice(head); + else { + const m = /]*>/i.exec(s); + if (m) { const at = m.index + m[0].length; s = s.slice(0, at) + tag + s.slice(at); } else s = tag + s; + } + } + return Buffer.from(s, 'latin1'); +} + +async function* walk(fs, dir) { + for (const e of await fs.readdir(dir, { withFileTypes: true })) { + if (e.name.startsWith('.') || e.name === 'node_modules') continue; + const p = join(dir, e.name); + if (e.isDirectory()) yield* walk(fs, p); + else if (e.isFile() && /\.html?$/i.test(e.name)) yield p; + } +} + +export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = nodeFs } = {}) { + if (!analytics) return; + if (!RE_SITE_KEY.test(analytics.siteKey || '')) throw new Error('Analyse: ungültiger siteKey'); + if (!RE_COLOR.test(analytics.color || '')) throw new Error('Analyse: ungültige Farbe'); + if (!isValidBase(baseUrl)) throw new Error('Analyse: ungültige öffentliche Basis-URL'); + const tag = scriptTag(analytics, baseUrl); + const p = analytics.privacy || {}; + const template = await fs.readFile(TEMPLATE_URL, 'utf8'); + const page = template.replace(/\{\{(company|address|email)\}\}/g, (_, k) => esc(p[k] ?? '')); + await fs.writeFile(join(dir, PAGE), page, 'utf8'); + await fs.chmod(join(dir, PAGE), 0o644); + let n = 0; + for await (const file of walk(fs, dir)) { + const rel = file.slice(dir.length + 1); + if ((await fs.stat(file)).size > MAX_HTML_BYTES) { onLine?.(`Analyse: ${rel} übersprungen (größer als 5 MB)`); continue; } + await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag)); + n++; + } + onLine?.(`Analyse-Skript in ${n} HTML-Datei(en) eingesetzt`); +} diff --git a/runner/src/config.js b/runner/src/config.js index a6c50c1..1ddb3ee 100644 --- a/runner/src/config.js +++ b/runner/src/config.js @@ -1,6 +1,9 @@ // Konfiguration aus Umgebungsvariablen. Pfade sind HOST-Pfade (R6): der Runner mountet sie identisch. export function loadConfig(env = process.env) { + const analyticsPublicBase = env.ANALYTICS_PUBLIC_BASE || 'https://project.webklar.com/a/'; + if (!/^https:\/\/[^\s"'<>]+\/$/.test(analyticsPublicBase)) throw new Error('ANALYTICS_PUBLIC_BASE muss mit https:// beginnen und mit / enden'); return { + analyticsPublicBase, token: env.PREVIEW_RUNNER_TOKEN || '', giteaUrl: env.GITEA_URL || '', giteaToken: env.GITEA_TOKEN || '', diff --git a/runner/src/jobs.js b/runner/src/jobs.js index 1c11f68..d63db5c 100644 --- a/runner/src/jobs.js +++ b/runner/src/jobs.js @@ -7,6 +7,7 @@ import { join, dirname, sep } from 'node:path'; import { buildRunArgs } from './docker.js'; import { detectFromDir } from './detect.js'; import { readMemAvailable } from './server.js'; +import { injectAnalytics } from './analytics-inject.js'; const LOG_LINES = 300; const MAX_LINE = 2000; @@ -254,6 +255,8 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem await fs.mkdir(config.previewsRoot, { recursive: true }); await fs.copyTree(fs, srcDir, fresh, { skipGit, skipNodeModules }); await ensureIndexHtml(fs, fresh, job.displayName || job.sub, onLine); + // Analyse (optional): auf dem .new-Ordner vor dem Tausch; Fehler brechen die Veröffentlichung ab, die alte Preview bleibt (V4). + if (job.analytics) await injectAnalytics(fresh, job.analytics, { baseUrl: config.analyticsPublicBase || 'https://project.webklar.com/a/', onLine, fs }); try { await fs.rename(target, old); } catch (e) { if (e.code !== 'ENOENT') throw e; hadOld = false; } try { await fs.rename(fresh, target); } catch (e) { if (hadOld) await fs.rename(old, target).catch((e2) => onLine?.(`Rollback fehlgeschlagen: ${e2.message}`)); diff --git a/runner/src/templates/datenschutz-analyse.html b/runner/src/templates/datenschutz-analyse.html new file mode 100644 index 0000000..448312a --- /dev/null +++ b/runner/src/templates/datenschutz-analyse.html @@ -0,0 +1,44 @@ + + + + + + +Datenschutz – Reichweitenmessung + + + +

Datenschutz bei der Reichweitenmessung

+

Vorläufiger Entwurf: Dieser Text wird vor der Veröffentlichung noch redaktionell und rechtlich überarbeitet.

+ +

Verantwortlicher

+

{{company}}
{{address}}
E-Mail: {{email}}

+ +

Zweck

+

Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern (zum Beispiel welche Seiten gelesen werden und wo Besucher hängen bleiben). Es gibt keine Werbung und kein Profiling.

+ +

Was erfasst wird

+

Ohne Deine Einwilligung wird nur gezählt, dass eine Seite aufgerufen wurde (Seitenpfad, Verweis-Website, Gerätegröße, grobes Land). Es werden keine Cookies gesetzt, keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Eine tagesweise wechselnde, nicht zurückrechenbare Kennung dient nur dazu, Besuche zu zählen.

+

Mit Deiner Einwilligung (Klick auf „Annehmen“) erfassen wir zusätzlich Interaktionen wie Verweildauer, Scrolltiefe und Klickpositionen auf der Seite (ohne Eingaben in Formulare).

+ +

Rechtsgrundlagen

+

Zählung ohne Einwilligung: berechtigtes Interesse, Art. 6 Abs. 1 lit. f DSGVO. Erweiterte Messung: Einwilligung, Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG, soweit auf Informationen im Endgerät zugegriffen wird (Speicherung Deiner Wahl im lokalen Speicher des Browsers).

+ +

Empfänger

+

Die Messung wird von der WEBklar als Auftragsverarbeiter für uns betrieben. Die Daten liegen auf Servern der Hetzner Online GmbH in Deutschland. Eine Übermittlung in Drittländer findet nicht statt.

+ +

Speicherdauer

+

Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, danach bleiben nur zusammengefasste Zahlen ohne Personenbezug. Deine Auswahl im Browser gilt sechs Monate.

+ +

Widerruf

+

Du kannst Deine Entscheidung jederzeit ändern: Klicke dazu auf „Cookie-Einstellungen“ am Seitenende.

+ +

Deine Rechte

+

Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung, Widerspruch und Datenübertragbarkeit sowie das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Wende Dich dazu an die oben genannte Adresse.

+ + diff --git a/runner/src/validate.js b/runner/src/validate.js index 0327490..15671d0 100644 --- a/runner/src/validate.js +++ b/runner/src/validate.js @@ -4,6 +4,8 @@ const RE_REPO = /^[\w.-]+\/[\w.-]+$/; const RE_SHA = /^[0-9a-f]{7,40}$/; const RE_BRANCH = /^[\w./-]{1,100}$/; const RE_ENV = /^[A-Z_][A-Z0-9_]*$/; +const RE_SITE_KEY = /^[a-z0-9]{16,40}$/; +const RE_COLOR = /^#[0-9a-f]{6}$/; const MODES = ['auto', 'build', 'dev']; export const isValidRunId = (s) => typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s); @@ -45,9 +47,27 @@ export function validateJob(body) { env[n] = v; } } + let analytics; + if (body.analytics !== undefined) { + const a = body.analytics; + if (!a || typeof a !== 'object' || Array.isArray(a)) return { ok: false, error: 'Ungültige Analyse-Angaben' }; + if (typeof a.siteKey !== 'string' || !RE_SITE_KEY.test(a.siteKey)) return { ok: false, error: 'Ungültiger Analyse-Schlüssel' }; + if (typeof a.color !== 'string' || !RE_COLOR.test(a.color)) return { ok: false, error: 'Ungültige Analyse-Farbe' }; + const pr = a.privacy; + if (!pr || typeof pr !== 'object' || Array.isArray(pr)) return { ok: false, error: 'Ungültige Analyse-Datenschutzangaben' }; + const privacy = {}; + for (const [k, max] of [['company', 200], ['address', 300], ['email', 200]]) { + const v = pr[k]; + // eslint-disable-next-line no-control-regex + if (typeof v !== 'string' || !v.trim() || v.length > max || /[\x00-\x1f\x7f]/.test(v)) return { ok: false, error: `Ungültige Analyse-Datenschutzangabe: ${k}` }; + privacy[k] = v; + } + analytics = { siteKey: a.siteKey, color: a.color, privacy }; + } // Whitelist: keine ungeprüften Client-Felder im Job const job = { sub, repo, sha, branch, mode, env }; if (index !== undefined) job.index = index; if (displayName !== undefined) job.displayName = displayName; + if (analytics !== undefined) job.analytics = analytics; return { ok: true, job }; } diff --git a/runner/test/analytics-inject.test.js b/runner/test/analytics-inject.test.js new file mode 100644 index 0000000..04bb280 --- /dev/null +++ b/runner/test/analytics-inject.test.js @@ -0,0 +1,173 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { injectAnalytics } from '../src/analytics-inject.js'; +import { validateJob } from '../src/validate.js'; +import { loadConfig } from '../src/config.js'; +import { createJobs } from '../src/jobs.js'; + +const BASE = 'https://project.webklar.com/a/'; +const KEY = 'abcdef0123456789abcd'; +const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o }); +const TAG = ``; +const dir = () => mkdtempSync(join(tmpdir(), 'inj-')); +const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); }; +const get = (d, f) => readFileSync(join(d, f), 'utf8'); +const count = (s, sub) => s.split(sub).length - 1; + +test('mehrere Dateien inkl. Unterordner: Tag vor , node_modules/Punktordner übersprungen', async () => { + const d = dir(); + put(d, 'index.html', 'xa'); + put(d, 'sub/seite.htm', '

b

'); + put(d, 'node_modules/p/x.html', ''); + put(d, '.hidden/x.html', ''); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.equal(get(d, 'index.html'), `x${TAG}a`); + assert.equal(get(d, 'sub/seite.htm'), `${TAG}

b

`); + assert.equal(get(d, 'node_modules/p/x.html'), ''); + assert.equal(get(d, '.hidden/x.html'), ''); +}); + +test('zweimal ausführen: kein Doppel; alter Tag wird ersetzt', async () => { + const d = dir(); + put(d, 'index.html', ''); + await injectAnalytics(d, A(), { baseUrl: BASE }); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.equal(count(get(d, 'index.html'), 'data-wk-analytics'), 1); + put(d, 'b.html', ''); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.equal(get(d, 'b.html'), `${TAG}`); +}); + +test('ohne head: nach ; ohne body: am Dateianfang; großes ', async () => { + const d = dir(); + put(d, 'a.html', 'hi'); + put(d, 'b.html', '

nur text

'); + put(d, 'c.html', 'xy'); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.equal(get(d, 'a.html'), `${TAG}hi`); + assert.equal(get(d, 'b.html'), `${TAG}

nur text

`); + assert.equal(get(d, 'c.html'), `x${TAG}y`); +}); + +test('Nicht-UTF8-Bytes bleiben erhalten', async () => { + const d = dir(); + const before = Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('')]); + writeFileSync(join(d, 'l.html'), before); + await injectAnalytics(d, A(), { baseUrl: BASE }); + const after = readFileSync(join(d, 'l.html')); + assert.deepEqual(after, Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('')])); +}); + +test('Datei > 5 MB wird übersprungen (Logzeile)', async () => { + const d = dir(); + const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('', 0); + writeFileSync(join(d, 'gross.html'), big); + const lines = []; + await injectAnalytics(d, A(), { baseUrl: BASE, onLine: (l) => lines.push(l) }); + assert.deepEqual(readFileSync(join(d, 'gross.html')), big); + assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l))); +}); + +test('Datenschutzseite: Platzhalter maskiert, Skript auch dort, Pflichtabschnitte', async () => { + const d = dir(); + put(d, 'index.html', ''); + await injectAnalytics(d, A({ privacy: { company: ' & Co', address: 'A "B" ', email: "x'y@z.de" } }), { baseUrl: BASE }); + const p = get(d, 'datenschutz-analyse.html'); + assert.ok(p.includes('<script>alert(1)</script> & Co')); + assert.ok(!p.includes('