Files
Webklar-Kundenbereich/runner/src/analytics-inject.js
T
ClaudeandClaude Opus 5.5 a2d11ad6c4 Runner: Zeiten-Skript beim Veröffentlichen einsetzen
injectIntoHtml nimmt eine Marke je Tag (geprüft gegen data-wk-[a-z]+),
injectZeiten setzt den data-wk-zeiten-Tag ohne Datenschutzseite ein,
validateJob lässt nur den siteKey durch, ZEITEN_PUBLIC_BASE in der Konfiguration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:46:06 +00:00

123 lines
5.9 KiB
JavaScript

// 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;');
// Interne Kommentare dürfen nie veröffentlicht werden.
export const stripComments = (t) => t.replace(/<!--[\s\S]*?-->\s*?\n?/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 `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
}
// Ö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 `<script data-wk-zeiten src="${baseUrl}zeiten.js" data-site="${zeiten.siteKey}" defer></script>`;
}
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
function findSpans(s) {
const spans = [];
const re = /<!--|<(script|style|textarea|template)\b/gi;
let m;
while ((m = re.exec(s))) {
let end;
let kind = 'raw';
if (m[0] === '<!--') {
const e = s.indexOf('-->', m.index + 4);
end = e < 0 ? s.length : e + 3;
kind = 'comment';
} else {
const close = new RegExp(`</${m[1]}\\s*>`, 'ig');
close.lastIndex = m.index + m[0].length;
const c = close.exec(s);
end = c ? c.index + c[0].length : s.length;
if (m[1].toLowerCase() === 'script') kind = 'script';
}
spans.push({ start: m.index, end, kind });
re.lastIndex = end;
}
return spans;
}
const RE_MARKER = /^data-wk-[a-z]+$/;
// Arbeitet auf latin1-Zeichenketten (1 Byte = 1 Zeichen), damit beliebige Kodierungen byte-genau erhalten bleiben.
// 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' && 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');
}
let masked = s;
for (const x of spans) masked = masked.slice(0, x.start) + ' '.repeat(x.end - x.start) + masked.slice(x.end);
const head = masked.search(/<\/head\s*>/i);
if (head >= 0) s = s.slice(0, head) + tag + s.slice(head);
else {
const m = /<body\b[^>]*>/i.exec(masked);
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;
}
}
// 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');
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 = stripComments(await fs.readFile(TEMPLATE_URL, 'utf8'));
const withAddr = p.address ? template.replace(/\{\{#address\}\}|\{\{\/address\}\}/g, '') : template.replace(/\{\{#address\}\}[\s\S]*?\{\{\/address\}\}/g, '');
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);
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`);
}