diff --git a/runner/src/analytics-inject.js b/runner/src/analytics-inject.js index bc92d3d..43fb3b2 100644 --- a/runner/src/analytics-inject.js +++ b/runner/src/analytics-inject.js @@ -17,20 +17,47 @@ export function scriptTag(analytics, baseUrl) { 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 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; - } + 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'); } diff --git a/runner/src/jobs.js b/runner/src/jobs.js index d63db5c..c062f2e 100644 --- a/runner/src/jobs.js +++ b/runner/src/jobs.js @@ -256,7 +256,7 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem 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 }); + if (job.analytics) await injectAnalytics(fresh, job.analytics, { baseUrl: config.analyticsPublicBase, 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 a64a08e..78d4810 100644 --- a/runner/src/validate.js +++ b/runner/src/validate.js @@ -1,11 +1,10 @@ // Strikte Eingabeprüfung für Runner-Jobs. +import { RE_SITE_KEY, RE_COLOR } from './analytics-inject.js'; const RE_SUB = /^[a-z0-9-]{1,63}$/; 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); @@ -58,10 +57,10 @@ export function validateJob(body) { const privacy = {}; for (const [k, max] of [['company', 200], ['address', 300], ['email', 200]]) { const v = pr[k]; + if (k === 'address' && (v === undefined || (typeof v === 'string' && !v.trim()))) { privacy[k] = ''; continue; } // eslint-disable-next-line no-control-regex - if (k === 'address' && (v === undefined || v === '')) { privacy[k] = ''; continue; } 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; + privacy[k] = v.trim(); } analytics = { siteKey: a.siteKey, color: a.color, privacy }; } diff --git a/runner/test/analytics-inject.test.js b/runner/test/analytics-inject.test.js index 284eb88..944c700 100644 --- a/runner/test/analytics-inject.test.js +++ b/runner/test/analytics-inject.test.js @@ -185,3 +185,40 @@ test('leere Adresse: keine leere Zeile, kein Platzhalterrest', async () => { assert.ok(p.includes('Muster GmbH
E-Mail:')); assert.ok(!p.includes('{{') && !p.includes('

')); }); + +test('Marker in script-String, Kommentar, template: Tag landet an der echten Stelle', async () => { + const d = dir(); + put(d, 'a.html', ''); + put(d, 'b.html', ''); + put(d, 'c.html', '

x

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

x

`); + assert.equal(get(d, 'd.html'), `${TAG}`); +}); + +test('alter Tag im Kommentar wird nicht ersetzt; echter wird genau einmal eingesetzt', async () => { + const d = dir(); + const old = ''; + put(d, 'a.html', ``); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.equal(get(d, 'a.html'), `${TAG}`); +}); + +test('5-MB-Grenze: genau 5 MB wird bearbeitet', async () => { + const d = dir(); + const b = Buffer.alloc(5 * 1024 * 1024, 0x20); b.write('', 0); + writeFileSync(join(d, 'g.html'), b); + await injectAnalytics(d, A(), { baseUrl: BASE }); + assert.ok(get(d, 'g.html').includes('data-wk-analytics')); +}); + +test('validateJob: Adresse nur Leerraum ⇒ leer, sonst getrimmt', () => { + const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' }; + const r = validateJob({ ...base, analytics: A({ privacy: { company: 'F', email: 'e@x.de', address: ' ' } }) }); + assert.equal(r.job.analytics.privacy.address, ''); + const r2 = validateJob({ ...base, analytics: A({ privacy: { company: 'F', email: 'e@x.de', address: ' Weg 1 ' } }) }); + assert.equal(r2.job.analytics.privacy.address, 'Weg 1'); +});