// 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, '''); // Interne Kommentare dürfen nie veröffentlicht werden. export const stripComments = (t) => t.replace(/\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 ``; } // Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik). function findSpans(s) { const spans = []; const re = /', m.index + 4); end = e < 0 ? s.length : e + 3; kind = 'comment'; } else { const close = new RegExp(``, '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; } // 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 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))); 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 = /]*>/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; } } 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); 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`); }