Runner: Analyse-Skript und Datenschutz-Zusatzseite einsetzen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 10:02:52 +00:00
1 parent add7fa4add
commit 7de9f1aaa3
6 files changed
+309

No files matched your search

+66
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
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 `<script data-wk-analytics src="${baseUrl}w.js" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
}
// 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 = /<script\b[^>]*\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 = /<body\b[^>]*>/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`);
}
+3
View File
@@ -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 || '',
+3
View File
@@ -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}`));
@@ -0,0 +1,44 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutz – Reichweitenmessung</title>
<style>
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 4rem}
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
.entwurf{background:#fff7d6;border:1px solid #e0c200;padding:.75rem 1rem;border-radius:6px}
a{color:#0b57d0}
</style>
</head>
<body>
<h1>Datenschutz bei der Reichweitenmessung</h1>
<p class="entwurf"><strong>Vorläufiger Entwurf:</strong> Dieser Text wird vor der Veröffentlichung noch redaktionell und rechtlich überarbeitet.</p>
<h2>Verantwortlicher</h2>
<p>{{company}}<br>{{address}}<br>E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
<h2>Zweck</h2>
<p>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.</p>
<h2>Was erfasst wird</h2>
<p><strong>Ohne Deine Einwilligung</strong> 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.</p>
<p><strong>Mit Deiner Einwilligung</strong> (Klick auf „Annehmen“) erfassen wir zusätzlich Interaktionen wie Verweildauer, Scrolltiefe und Klickpositionen auf der Seite (ohne Eingaben in Formulare).</p>
<h2>Rechtsgrundlagen</h2>
<p>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).</p>
<h2>Empfänger</h2>
<p>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.</p>
<h2>Speicherdauer</h2>
<p>Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, danach bleiben nur zusammengefasste Zahlen ohne Personenbezug. Deine Auswahl im Browser gilt sechs Monate.</p>
<h2>Widerruf</h2>
<p>Du kannst Deine Entscheidung jederzeit ändern: Klicke dazu auf „Cookie-Einstellungen“ am Seitenende.</p>
<h2>Deine Rechte</h2>
<p>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.</p>
</body>
</html>
+20
View File
@@ -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 };
}
+173
View File
@@ -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 = `<script data-wk-analytics src="${BASE}w.js" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
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 </head>, node_modules/Punktordner übersprungen', async () => {
const d = dir();
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
put(d, 'sub/seite.htm', '<head></head><p>b</p>');
put(d, 'node_modules/p/x.html', '<head></head>');
put(d, '.hidden/x.html', '<head></head>');
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'index.html'), `<html><head><title>x</title>${TAG}</head><body>a</body></html>`);
assert.equal(get(d, 'sub/seite.htm'), `<head>${TAG}</head><p>b</p>`);
assert.equal(get(d, 'node_modules/p/x.html'), '<head></head>');
assert.equal(get(d, '.hidden/x.html'), '<head></head>');
});
test('zweimal ausführen: kein Doppel; alter Tag wird ersetzt', async () => {
const d = dir();
put(d, 'index.html', '<head></head>');
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', '<head><script data-wk-analytics src="https://alt/w.js" data-site="x" defer></script></head><body></body>');
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'b.html'), `<head>${TAG}</head><body></body>`);
});
test('ohne head: nach <body …>; ohne body: am Dateianfang; großes </HEAD>', async () => {
const d = dir();
put(d, 'a.html', '<body class="x">hi</body>');
put(d, 'b.html', '<p>nur text</p>');
put(d, 'c.html', '<HEAD><TITLE>x</TITLE></HEAD><BODY>y</BODY>');
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'a.html'), `<body class="x">${TAG}hi</body>`);
assert.equal(get(d, 'b.html'), `${TAG}<p>nur text</p>`);
assert.equal(get(d, 'c.html'), `<HEAD><TITLE>x</TITLE>${TAG}</HEAD><BODY>y</BODY>`);
});
test('Nicht-UTF8-Bytes bleiben erhalten', async () => {
const d = dir();
const before = Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('</head>')]);
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('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('</head>')]));
});
test('Datei > 5 MB wird übersprungen (Logzeile)', async () => {
const d = dir();
const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('<head></head>', 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', '<head></head>');
await injectAnalytics(d, A({ privacy: { company: '<script>alert(1)</script> & Co', address: 'A "B" <C>', email: "x'y@z.de" } }), { baseUrl: BASE });
const p = get(d, 'datenschutz-analyse.html');
assert.ok(p.includes('&lt;script&gt;alert(1)&lt;/script&gt; &amp; Co'));
assert.ok(!p.includes('<script>alert(1)'));
assert.ok(p.includes('A &quot;B&quot; &lt;C&gt;') && p.includes('x&#39;y@z.de'));
assert.ok(!p.includes('{{'));
assert.equal(count(p, 'data-wk-analytics'), 1);
for (const s of ['Verantwortlicher', 'Zweck', 'Einwilligung', 'Rechtsgrundlage', 'Auftragsverarbeiter', 'Hetzner', 'Speicherdauer', 'Cookie-Einstellungen', 'Rechte']) assert.ok(p.includes(s), s);
});
test('ohne analytics: nichts geändert, keine Seite', async () => {
const d = dir();
put(d, 'index.html', '<head></head>');
await injectAnalytics(d, undefined, { baseUrl: BASE });
assert.equal(get(d, 'index.html'), '<head></head>');
assert.deepEqual(readdirSync(d), ['index.html']);
});
test('ungültige Eingaben werfen (siteKey, Farbe, Basis)', async () => {
const d = dir(); put(d, 'index.html', '<head></head>');
await assert.rejects(injectAnalytics(d, A({ siteKey: 'ABC' }), { baseUrl: BASE }));
await assert.rejects(injectAnalytics(d, A({ color: 'red' }), { baseUrl: BASE }));
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'http://x/a/' }));
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'https://x/a' }));
assert.equal(get(d, 'index.html'), '<head></head>');
});
test('validateJob: analytics streng, Weiterreichen in den Job', () => {
const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
const ok = validateJob({ ...base, analytics: { ...A(), extra: 1 } });
assert.equal(ok.ok, true);
assert.deepEqual(ok.job.analytics, A());
assert.equal(validateJob(base).job.analytics, undefined);
const bad = [
A({ siteKey: 'kurz' }), A({ siteKey: 'A'.repeat(20) }), A({ color: '#ABCDEF' }), A({ color: '#abc' }),
A({ privacy: undefined }), A({ privacy: { ...A().privacy, company: 'x'.repeat(201) } }),
A({ privacy: { ...A().privacy, address: 'x'.repeat(301) } }), A({ privacy: { ...A().privacy, email: 'x'.repeat(201) } }),
A({ privacy: { ...A().privacy, company: 'a\nb' } }), A({ privacy: { ...A().privacy, email: 5 } }), 'x', [], null,
];
for (const a of bad) { const r = validateJob({ ...base, analytics: a }); assert.equal(r.ok, false, JSON.stringify(a)); assert.match(r.error, /Analyse/); }
});
test('loadConfig: analyticsPublicBase Vorgabe, Override, ungültig wirft', () => {
assert.equal(loadConfig({}).analyticsPublicBase, BASE);
assert.equal(loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q/' }).analyticsPublicBase, 'https://x.de/q/');
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'http://x.de/q/' }));
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q' }));
});
// ---- Integration über publish ----
function setup(cfg = {}, fsOver = {}) {
const root = mkdtempSync(join(tmpdir(), 'injjobs-'));
const config = { giteaUrl: 'https://git.x', giteaToken: 't', previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, analyticsPublicBase: BASE, ...cfg };
mkdirSync(config.previewsRoot, { recursive: true });
const git = async ({ dir: d }) => { put(d, 'index.html', '<html><head></head><body>NEU</body></html>'); put(d, 'x/y.html', '<p>y</p>'); };
const jobs = createJobs({ config, docker: { run: async () => ({ code: 0 }) }, git, mem: () => 1e10, fs: { chownR: async () => {}, ...fsOver } });
return { config, jobs };
}
const job = (o = {}) => ({ sub: 'demo', repo: 'own/rep', sha: 'abcdef1', branch: 'main', mode: 'auto', env: {}, ...o });
const wait = async (jobs, id) => { for (let i = 0; i < 400; i++) { const r = jobs.get(id); if (r.state === 'done' || r.state === 'failed') return r; await new Promise((r2) => setTimeout(r2, 5)); } throw new Error('timeout'); };
test('publish: Job mit analytics injiziert im veröffentlichten Ordner', async () => {
const s = setup();
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A() })));
assert.equal(r.state, 'done');
const t = join(s.config.previewsRoot, 'demo');
assert.ok(get(t, 'index.html').includes(TAG));
assert.ok(get(t, 'x/y.html').startsWith(TAG));
assert.ok(existsSync(join(t, 'datenschutz-analyse.html')));
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
});
test('publish: Job ohne analytics bleibt unverändert', async () => {
const s = setup();
const r = await wait(s.jobs, s.jobs.submit(job()));
assert.equal(r.state, 'done');
const t = join(s.config.previewsRoot, 'demo');
assert.equal(get(t, 'index.html'), '<html><head></head><body>NEU</body></html>');
assert.ok(!existsSync(join(t, 'datenschutz-analyse.html')));
});
test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt (V4)', async () => {
const s = setup({}, { readFile: async () => { throw new Error('kaputt'); } });
const t = join(s.config.previewsRoot, 'demo');
mkdirSync(t); writeFileSync(join(t, 'index.html'), 'ALT');
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A() })));
assert.equal(r.state, 'failed');
assert.equal(get(t, 'index.html'), 'ALT');
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
});
test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => {
const s = setup();
const { workDir } = await s.jobs.prepareWorkspace(job({ analytics: A() }), { workDir: join(s.config.workRoot, 'dev', 'x') });
assert.equal(get(workDir, 'index.html'), '<html><head></head><body>NEU</body></html>');
assert.ok(!existsSync(join(workDir, 'datenschutz-analyse.html')));
});