diff --git a/runner/src/analytics-inject.js b/runner/src/analytics-inject.js index 66e2a27..2f7604a 100644 --- a/runner/src/analytics-inject.js +++ b/runner/src/analytics-inject.js @@ -20,6 +20,11 @@ export function scriptTag(analytics, baseUrl) { return ``; } +// Öffnungszeiten live: siteKey und Basis-URL werden vor dem Aufruf per Regex geprüft (injectZeiten), daher ohne Maskierung sicher. +export function zeitenScriptTag(zeiten, baseUrl) { + return ``; +} + // Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik). function findSpans(s) { const spans = []; @@ -45,11 +50,16 @@ function findSpans(s) { return spans; } +const RE_MARKER = /^data-wk-[a-z]+$/; + // Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben. -export function injectIntoHtml(buf, tag) { +// Die Marke (Attribut des Tags, z. B. data-wk-zeiten) erkennt einen früher eingesetzten Tag; sie wird geprüft, weil sie in einen regulären Ausdruck eingeht. +export function injectIntoHtml(buf, tag, marker = 'data-wk-analytics') { + if (typeof marker !== 'string' || !RE_MARKER.test(marker)) throw new Error('Ungültiger Marker'); + const reOld = new RegExp(`\\b${marker}\\b`, 'i'); let s = buf.toString('latin1'); const spans = findSpans(s); - const olds = spans.filter((x) => x.kind === 'script' && /\bdata-wk-analytics\b/i.test(s.slice(x.start, s.indexOf('>', x.start) + 1))); + const olds = spans.filter((x) => x.kind === 'script' && reOld.test(s.slice(x.start, s.indexOf('>', x.start) + 1))); if (olds.length) { for (let i = olds.length - 1; i >= 0; i--) s = s.slice(0, olds[i].start) + (i === 0 ? tag : '') + s.slice(olds[i].end); return Buffer.from(s, 'latin1'); @@ -74,6 +84,18 @@ async function* walk(fs, dir) { } } +// Setzt den Tag in alle HTML-Dateien unterhalb von dir ein (Dateien über 5 MB bleiben unberührt) und liefert die Anzahl. +async function injectTagIntoDir(fs, dir, tag, marker, label, onLine) { + 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?.(`${label}: ${rel} übersprungen (größer als 5 MB)`); continue; } + await fs.writeFile(file, injectIntoHtml(await fs.readFile(file), tag, marker)); + n++; + } + return n; +} + 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'); @@ -86,12 +108,15 @@ export async function injectAnalytics(dir, analytics, { baseUrl, onLine, fs = no const page = withAddr.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++; - } + const n = await injectTagIntoDir(fs, dir, tag, 'data-wk-analytics', 'Analyse', onLine); onLine?.(`Analyse-Skript in ${n} HTML-Datei(en) eingesetzt`); } + +// Öffnungszeiten live: nur der Skript-Tag, keine Datenschutzseite. +export async function injectZeiten(dir, zeiten, { baseUrl, onLine, fs = nodeFs } = {}) { + if (!zeiten) return; + if (typeof zeiten.siteKey !== 'string' || !RE_SITE_KEY.test(zeiten.siteKey)) throw new Error('Zeiten: ungültiger siteKey'); + if (!isValidBase(baseUrl)) throw new Error('Zeiten: ungültige öffentliche Basis-URL'); + const n = await injectTagIntoDir(fs, dir, zeitenScriptTag(zeiten, baseUrl), 'data-wk-zeiten', 'Zeiten', onLine); + onLine?.(`Zeiten-Skript in ${n} HTML-Datei(en) eingesetzt`); +} diff --git a/runner/src/config.js b/runner/src/config.js index 1ddb3ee..d699298 100644 --- a/runner/src/config.js +++ b/runner/src/config.js @@ -2,8 +2,11 @@ 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'); + const zeitenPublicBase = env.ZEITEN_PUBLIC_BASE || 'https://project.webklar.com/embed/'; + if (!/^https:\/\/[^\s"'<>]+\/$/.test(zeitenPublicBase)) throw new Error('ZEITEN_PUBLIC_BASE muss mit https:// beginnen und mit / enden'); return { analyticsPublicBase, + zeitenPublicBase, 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 c062f2e..1e32c18 100644 --- a/runner/src/jobs.js +++ b/runner/src/jobs.js @@ -7,7 +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'; +import { injectAnalytics, injectZeiten } from './analytics-inject.js'; const LOG_LINES = 300; const MAX_LINE = 2000; @@ -257,6 +257,8 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem 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, onLine, fs }); + // Öffnungszeiten live: gleiche Runde, gleiche Fehlerfolge (Job failed, alte Preview bleibt). Nie im Dev-Pfad. + if (job.zeiten) await injectZeiten(fresh, job.zeiten, { baseUrl: config.zeitenPublicBase, 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/validate.js b/runner/src/validate.js index 78d4810..5ddb7b0 100644 --- a/runner/src/validate.js +++ b/runner/src/validate.js @@ -64,10 +64,19 @@ export function validateJob(body) { } analytics = { siteKey: a.siteKey, color: a.color, privacy }; } + // Öffnungszeiten live: nur der siteKey, Zusatzfelder werden verworfen. + let zeiten; + if (body.zeiten !== undefined && body.zeiten !== null) { + const z = body.zeiten; + if (!z || typeof z !== 'object' || Array.isArray(z)) return { ok: false, error: 'Ungültige Zeiten-Angaben' }; + if (typeof z.siteKey !== 'string' || !RE_SITE_KEY.test(z.siteKey)) return { ok: false, error: 'Ungültiger Zeiten-Schlüssel' }; + zeiten = { siteKey: z.siteKey }; + } // 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; + if (zeiten !== undefined) job.zeiten = zeiten; return { ok: true, job }; } diff --git a/runner/test/zeiten-inject.test.js b/runner/test/zeiten-inject.test.js new file mode 100644 index 0000000..13538bf --- /dev/null +++ b/runner/test/zeiten-inject.test.js @@ -0,0 +1,248 @@ +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 { injectZeiten, zeitenScriptTag, injectIntoHtml, 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/embed/'; +const ABASE = 'https://project.webklar.com/a/'; +const KEY = 'abcdefghij234567'; +const Z = (o = {}) => ({ siteKey: KEY, ...o }); +const TAG = ``; +const A = () => ({ siteKey: 'abcdef0123456789abcd', color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } }); +const dir = () => mkdtempSync(join(tmpdir(), 'zinj-')); +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('zeitenScriptTag: genau die vereinbarte Form', () => { + assert.equal(zeitenScriptTag(Z(), BASE), TAG); +}); + +test('Tag steht vor , node_modules/Punktordner übersprungen, kein Datenschutzseite', async () => { + const d = dir(); + put(d, 'index.html', '
b
'); + put(d, 'node_modules/p/x.html', ''); + put(d, '.hidden/x.html', ''); + const lines = []; + await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) }); + assert.equal(get(d, 'index.html'), `b
`); + assert.equal(get(d, 'node_modules/p/x.html'), ''); + assert.equal(get(d, '.hidden/x.html'), ''); + assert.deepEqual(lines, ['Zeiten-Skript in 2 HTML-Datei(en) eingesetzt']); +}); + +test('zweimal einsetzen ergibt genau einen Tag', async () => { + const d = dir(); + put(d, 'index.html', ''); + await injectZeiten(d, Z(), { baseUrl: BASE }); + await injectZeiten(d, Z(), { baseUrl: BASE }); + assert.equal(count(get(d, 'index.html'), 'data-wk-zeiten'), 1); + assert.equal(get(d, 'index.html'), `${TAG}`); +}); + +test('alter Zeiten-Tag wird ersetzt', async () => { + const d = dir(); + put(d, 'b.html', ''); + await injectZeiten(d, Z(), { baseUrl: BASE }); + assert.equal(get(d, 'b.html'), `${TAG}`); +}); + +test('alter Tag im Kommentar wird nicht ersetzt', async () => { + const d = dir(); + const old = ''; + put(d, 'a.html', ``); + await injectZeiten(d, Z(), { baseUrl: BASE }); + assert.equal(get(d, 'a.html'), `${TAG}`); +}); + +test('ohne head: nach ; ohne body: am Dateianfang', async () => { + const d = dir(); + put(d, 'a.html', 'hi'); + put(d, 'b.html', 'nur text
'); + await injectZeiten(d, Z(), { baseUrl: BASE }); + assert.equal(get(d, 'a.html'), `${TAG}hi`); + assert.equal(get(d, 'b.html'), `${TAG}nur text
`); +}); + +test('Nicht-UTF8-Bytes bleiben erhalten', async () => { + const d = dir(); + writeFileSync(join(d, 'l.html'), Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('')])); + await injectZeiten(d, Z(), { baseUrl: BASE }); + assert.deepEqual(readFileSync(join(d, 'l.html')), Buffer.concat([Buffer.from(''), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('')])); +}); + +test('Datei > 5 MB wird übersprungen (Logzeile), genau 5 MB wird bearbeitet', async () => { + const d = dir(); + const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('', 0); + const edge = Buffer.alloc(5 * 1024 * 1024, 0x20); edge.write('', 0); + writeFileSync(join(d, 'gross.html'), big); + writeFileSync(join(d, 'grenze.html'), edge); + const lines = []; + await injectZeiten(d, Z(), { baseUrl: BASE, onLine: (l) => lines.push(l) }); + assert.deepEqual(readFileSync(join(d, 'gross.html')), big); + assert.ok(get(d, 'grenze.html').includes('data-wk-zeiten')); + assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l))); +}); + +test('Analyse und Zeiten nacheinander: je ein Tag, in beiden Reihenfolgen und mit Wiederholung', async () => { + for (const order of ['az', 'za']) { + const d = dir(); + put(d, 'index.html', 'y
'); }; + 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 zeiten setzt das Tag im veröffentlichten Ordner, ohne Datenschutzseite', async () => { + const s = setup(); + const r = await wait(s.jobs, s.jobs.submit(job({ zeiten: Z() }))); + 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(!get(t, 'index.html').includes('data-wk-analytics')); + assert.ok(!existsSync(join(t, 'datenschutz-analyse.html'))); + assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']); +}); + +test('publish: analytics und zeiten in einem Job: je ein Tag', async () => { + const s = setup(); + const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A(), zeiten: Z() }))); + assert.equal(r.state, 'done'); + const t = join(s.config.previewsRoot, 'demo'); + for (const f of ['index.html', 'x/y.html']) { + const c = get(t, f); + assert.equal(count(c, 'data-wk-analytics'), 1, f); + assert.equal(count(c, 'data-wk-zeiten'), 1, f); + assert.ok(c.includes(TAG), f); + } + assert.ok(existsSync(join(t, 'datenschutz-analyse.html'))); +}); + +test('publish: Job ohne zeiten bleibt unverändert', async () => { + const s = setup(); + const r = await wait(s.jobs, s.jobs.submit(job())); + assert.equal(r.state, 'done'); + assert.equal(get(join(s.config.previewsRoot, 'demo'), 'index.html'), 'NEU'); +}); + +test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt', 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({ zeiten: Z() }))); + assert.equal(r.state, 'failed'); + assert.equal(get(t, 'index.html'), 'ALT'); + assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']); +}); + +test('publish: ungültige Basis-URL in der Konfiguration ⇒ failed, alter Ordner bleibt', async () => { + const s = setup({ zeitenPublicBase: 'http://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({ zeiten: Z() }))); + assert.equal(r.state, 'failed'); + assert.equal(get(t, 'index.html'), 'ALT'); +}); + +test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => { + const s = setup(); + const { workDir } = await s.jobs.prepareWorkspace(job({ zeiten: Z() }), { workDir: join(s.config.workRoot, 'dev', 'x') }); + assert.equal(get(workDir, 'index.html'), 'NEU'); +});