Runner: Analyse-Einsetzen ignoriert Kommentare/Skripte (Fix Runde 1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 10:05:08 +00:00
1 parent 18d37d1e01
commit 9fa05e23d1
4 files changed
+79 -16

No files matched your search

+38 -11
View File
@@ -17,20 +17,47 @@ export function scriptTag(analytics, baseUrl) {
return `<script data-wk-analytics src="${baseUrl}w.js" data-site="${analytics.siteKey}" data-color="${analytics.color}" 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;
}
// 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;
}
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 = /<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');
}
+1 -1
View File
@@ -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}`));
+3 -4
View File
@@ -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 };
}
+37
View File
@@ -185,3 +185,40 @@ test('leere Adresse: keine leere Zeile, kein Platzhalterrest', async () => {
assert.ok(p.includes('Muster GmbH<br>E-Mail:'));
assert.ok(!p.includes('{{') && !p.includes('<br><br>'));
});
test('Marker in script-String, Kommentar, template: Tag landet an der echten Stelle', async () => {
const d = dir();
put(d, 'a.html', '<head><script>var t="</head>";</script></head><body></body>');
put(d, 'b.html', '<!-- </head> --><head></head>');
put(d, 'c.html', '<template><body></template><p>x</p>');
put(d, 'd.html', '<style>/* </head> */</style><head></head>');
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'a.html'), `<head><script>var t="</head>";</script>${TAG}</head><body></body>`);
assert.equal(get(d, 'b.html'), `<!-- </head> --><head>${TAG}</head>`);
assert.equal(get(d, 'c.html'), `${TAG}<template><body></template><p>x</p>`);
assert.equal(get(d, 'd.html'), `<style>/* </head> */</style><head>${TAG}</head>`);
});
test('alter Tag im Kommentar wird nicht ersetzt; echter wird genau einmal eingesetzt', async () => {
const d = dir();
const old = '<script data-wk-analytics src="https://alt/w.js"></script>';
put(d, 'a.html', `<head><!-- ${old} --></head>`);
await injectAnalytics(d, A(), { baseUrl: BASE });
assert.equal(get(d, 'a.html'), `<head><!-- ${old} -->${TAG}</head>`);
});
test('5-MB-Grenze: genau 5 MB wird bearbeitet', async () => {
const d = dir();
const b = Buffer.alloc(5 * 1024 * 1024, 0x20); b.write('<head></head>', 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');
});