Compare commits
36
Commits
No files matched your search
@@ -1,17 +0,0 @@
|
||||
# Task 2 Report: Projekterkennung
|
||||
Implemented runner/src/detect.js (detectProject, detectFromDir) + runner/test/detect.test.js (19 tests).
|
||||
TDD: RED = ERR_ASSERTION / module not found (detect.js missing); first GREEN attempt had 1 failure (devCmd null for build kind) -> fixed; final: runner npm test 27/27 pass.
|
||||
|
||||
## Return shape
|
||||
{ kind, pm, install, buildCmd, devCmd, outputDirs, devPort, [index], [hint], [error] }
|
||||
- buildCmd/devCmd are set whenever the script exists (also for kind 'build' -> devCmd = dev fallback after failed build, per spec).
|
||||
- static: install [], buildCmd/devCmd null, outputDirs [], `index` = start file (opts.index > previewJson.index > 'index.html'); `hint` (German) when neither index nor scripts found.
|
||||
- Forced mode without script: `{ kind:'build', ..., buildCmd:null, error:'Kein build-Skript in package.json' }` / `{ kind:'dev', ..., devCmd:null, error:'Kein dev-Skript in package.json' }`.
|
||||
- auto: index && !build => static; build => build; dev => dev; else static + hint.
|
||||
- Invalid package.json JSON => no scripts, never throws. devPort int 1024-65535 else 5173; output/index must be relative without '..' else ignored.
|
||||
## Commands
|
||||
npm: ci --include=dev (package-lock/npm-shrinkwrap) or install --include=dev --no-audit --no-fund; pnpm/yarn/bun: `<pm> install --frozen-lockfile`; build `<pm> run build`.
|
||||
dev: npm/pnpm `run dev -- --host 0.0.0.0 --port N`; yarn/bun `run dev --host 0.0.0.0 --port N` (no `--`, they pass args through).
|
||||
Note: pnpm/yarn in node:20 need corepack (`corepack enable`) — not handled here (later task).
|
||||
## detectFromDir(dir, opts)
|
||||
fs adapter (has = isFile); reads .webklar-preview.json as previewJson unless opts.previewJson given; tested with temp dir.
|
||||
@@ -0,0 +1,26 @@
|
||||
// Erzeugt dist/w.js und dist/h.js: core.js + Einstieg als IIFE, ohne import/export (kein Build-Tool).
|
||||
// Aufruf: node client/build.js
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const read = (f) => readFileSync(join(DIR, f), 'utf8');
|
||||
|
||||
function wrap(name, entry) {
|
||||
const core = read('core.js').replace(/^export\s+/gm, '');
|
||||
const body = read(entry).replace(/^import\s[^\n]*\n/gm, '');
|
||||
return `/* WEBklar Analyse ${name} - generiert aus client/core.js + client/${entry} (node client/build.js), nicht von Hand aendern */\n(function () {\n'use strict';\n${core}\n${body}\n})();\n`;
|
||||
}
|
||||
|
||||
export function buildAll() {
|
||||
return { w: wrap('w.js', 'w.js'), h: wrap('h.js', 'h.js') };
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const b = buildAll();
|
||||
mkdirSync(join(DIR, 'dist'), { recursive: true });
|
||||
writeFileSync(join(DIR, 'dist', 'w.js'), b.w);
|
||||
writeFileSync(join(DIR, 'dist', 'h.js'), b.h);
|
||||
console.log('dist/w.js', b.w.length, 'Bytes; dist/h.js', b.h.length, 'Bytes');
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
|
||||
// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
|
||||
|
||||
export const CONSENT_KEY = 'wk-consent';
|
||||
export const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
|
||||
export function vpClass(width) {
|
||||
const w = Number(width);
|
||||
if (w < 768) return 'm';
|
||||
if (w < 1024) return 't';
|
||||
return 'd';
|
||||
}
|
||||
|
||||
export function cleanPath(href) {
|
||||
let p = '/';
|
||||
const h = String(href == null ? '' : href);
|
||||
if (h.charAt(0) === '/') {
|
||||
p = h.replace(/[?#].*$/, '') || '/'; // wie location.pathname, auch '//foo'
|
||||
} else {
|
||||
try { p = new URL(h, 'http://x.invalid/').pathname || '/'; } catch (e) { /* bleibt '/' */ }
|
||||
}
|
||||
try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
|
||||
if (p.charAt(0) !== '/') p = '/' + p;
|
||||
return p.slice(0, 300);
|
||||
}
|
||||
|
||||
const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
|
||||
|
||||
export function refHost(referrer, ownHost) {
|
||||
if (!referrer) return '';
|
||||
try {
|
||||
const u = new URL(String(referrer));
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
|
||||
return h;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// Nur Tag-Namen mit :nth-of-type, keine IDs/Klassen (koennten personenbezogene Texte enthalten).
|
||||
export function cssPath(el) {
|
||||
const parts = [];
|
||||
let n = el;
|
||||
while (n && n.tagName && parts.length < 6) {
|
||||
const tag = String(n.tagName).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
if (!tag) break;
|
||||
let idx = 1;
|
||||
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
||||
if (String(s.tagName).toLowerCase() === String(n.tagName).toLowerCase()) idx++;
|
||||
}
|
||||
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
||||
if (tag === 'body' || tag === 'html') break;
|
||||
n = n.parentNode;
|
||||
}
|
||||
let out = parts.join(' > ');
|
||||
while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
function parseHref(a) {
|
||||
const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
|
||||
return String(href).trim();
|
||||
}
|
||||
|
||||
export function linkHost(a) {
|
||||
try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
export function contactKind(a, ownHost) {
|
||||
const href = parseHref(a);
|
||||
if (!href) return null;
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
|
||||
if (!scheme) return null; // relativ, Anker
|
||||
const s = scheme[1].toLowerCase();
|
||||
if (s === 'tel') return 'tel';
|
||||
if (s === 'mailto') return 'mail';
|
||||
if (s === 'whatsapp') return 'wa';
|
||||
if (s !== 'http' && s !== 'https') return null;
|
||||
const host = linkHost(a);
|
||||
if (!host) return null;
|
||||
if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
|
||||
const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
|
||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
||||
}
|
||||
|
||||
export function readConsent(storage, now) {
|
||||
try {
|
||||
const raw = storage.getItem(CONSENT_KEY);
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
export function contrastOk(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
||||
const lin = (i) => {
|
||||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
|
||||
return 1.05 / (L + 0.05) >= 4.5;
|
||||
}
|
||||
|
||||
export function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
Vendored
+214
@@ -0,0 +1,214 @@
|
||||
/* WEBklar Analyse h.js - generiert aus client/core.js + client/h.js (node client/build.js), nicht von Hand aendern */
|
||||
(function () {
|
||||
'use strict';
|
||||
// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
|
||||
// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
|
||||
|
||||
const CONSENT_KEY = 'wk-consent';
|
||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
|
||||
function vpClass(width) {
|
||||
const w = Number(width);
|
||||
if (w < 768) return 'm';
|
||||
if (w < 1024) return 't';
|
||||
return 'd';
|
||||
}
|
||||
|
||||
function cleanPath(href) {
|
||||
let p = '/';
|
||||
const h = String(href == null ? '' : href);
|
||||
if (h.charAt(0) === '/') {
|
||||
p = h.replace(/[?#].*$/, '') || '/'; // wie location.pathname, auch '//foo'
|
||||
} else {
|
||||
try { p = new URL(h, 'http://x.invalid/').pathname || '/'; } catch (e) { /* bleibt '/' */ }
|
||||
}
|
||||
try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
|
||||
if (p.charAt(0) !== '/') p = '/' + p;
|
||||
return p.slice(0, 300);
|
||||
}
|
||||
|
||||
const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
|
||||
|
||||
function refHost(referrer, ownHost) {
|
||||
if (!referrer) return '';
|
||||
try {
|
||||
const u = new URL(String(referrer));
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
|
||||
return h;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// Nur Tag-Namen mit :nth-of-type, keine IDs/Klassen (koennten personenbezogene Texte enthalten).
|
||||
function cssPath(el) {
|
||||
const parts = [];
|
||||
let n = el;
|
||||
while (n && n.tagName && parts.length < 6) {
|
||||
const tag = String(n.tagName).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
if (!tag) break;
|
||||
let idx = 1;
|
||||
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
||||
if (String(s.tagName).toLowerCase() === String(n.tagName).toLowerCase()) idx++;
|
||||
}
|
||||
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
||||
if (tag === 'body' || tag === 'html') break;
|
||||
n = n.parentNode;
|
||||
}
|
||||
let out = parts.join(' > ');
|
||||
while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
function parseHref(a) {
|
||||
const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
|
||||
return String(href).trim();
|
||||
}
|
||||
|
||||
function linkHost(a) {
|
||||
try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function contactKind(a, ownHost) {
|
||||
const href = parseHref(a);
|
||||
if (!href) return null;
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
|
||||
if (!scheme) return null; // relativ, Anker
|
||||
const s = scheme[1].toLowerCase();
|
||||
if (s === 'tel') return 'tel';
|
||||
if (s === 'mailto') return 'mail';
|
||||
if (s === 'whatsapp') return 'wa';
|
||||
if (s !== 'http' && s !== 'https') return null;
|
||||
const host = linkHost(a);
|
||||
if (!host) return null;
|
||||
if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
|
||||
const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
|
||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
||||
}
|
||||
|
||||
function readConsent(storage, now) {
|
||||
try {
|
||||
const raw = storage.getItem(CONSENT_KEY);
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function contrastOk(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
||||
const lin = (i) => {
|
||||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
|
||||
return 1.05 / (L + 0.05) >= 4.5;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
|
||||
|
||||
function main() {
|
||||
const me = document.currentScript;
|
||||
const token = me && me.getAttribute('data-token');
|
||||
const src = String((me && (me.src || me.getAttribute('src'))) || '').replace(/[?#].*$/, '');
|
||||
const base = src.replace(/[^/]*$/, '');
|
||||
if (!token || !base) return;
|
||||
|
||||
const de = document.documentElement;
|
||||
const host = document.createElement('div');
|
||||
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = 'Heatmap wird geladen ...';
|
||||
const close = document.createElement('button');
|
||||
close.setAttribute('type', 'button');
|
||||
close.textContent = 'Schließen';
|
||||
bar.appendChild(msg); bar.appendChild(close);
|
||||
root.appendChild(style); root.appendChild(bar);
|
||||
document.body.appendChild(host);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const cs = canvas.style;
|
||||
cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.pointerEvents = 'none'; cs.zIndex = '2147483646';
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
let points = [];
|
||||
let lastW = 0, lastH = 0, timer = 0;
|
||||
|
||||
// Farbverlauf: transparent -> blau -> gruen -> gelb -> rot
|
||||
const ramp = (function () {
|
||||
const c = document.createElement('canvas'); c.width = 256; c.height = 1;
|
||||
const g = c.getContext('2d');
|
||||
const lg = g.createLinearGradient(0, 0, 256, 0);
|
||||
lg.addColorStop(0.25, 'rgb(0,0,255)'); lg.addColorStop(0.55, 'rgb(0,255,0)'); lg.addColorStop(0.85, 'rgb(255,255,0)'); lg.addColorStop(1, 'rgb(255,0,0)');
|
||||
g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
|
||||
return g.getImageData(0, 0, 256, 1).data;
|
||||
})();
|
||||
|
||||
function draw() {
|
||||
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
|
||||
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
|
||||
lastW = w; lastH = h;
|
||||
// Zeichenflaeche begrenzen (Speicher): hoechstens ca. 4 Mio. Pixel
|
||||
const scale = Math.min(1, Math.sqrt(4e6 / (w * h)));
|
||||
const cw = Math.max(1, Math.floor(w * scale)), ch = Math.max(1, Math.floor(h * scale));
|
||||
canvas.width = cw; canvas.height = ch;
|
||||
cs.width = w + 'px'; cs.height = h + 'px';
|
||||
const g = canvas.getContext('2d');
|
||||
g.clearRect(0, 0, cw, ch);
|
||||
const r = Math.max(6, 30 * scale);
|
||||
for (const p of points) {
|
||||
const x = p.x * cw, y = p.y * ch;
|
||||
const rg = g.createRadialGradient(x, y, 0, x, y, r);
|
||||
rg.addColorStop(0, 'rgba(0,0,0,0.25)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = rg; g.fillRect(x - r, y - r, r * 2, r * 2);
|
||||
}
|
||||
// Deckkraft-Stufen in Farben umsetzen
|
||||
const img = g.getImageData(0, 0, cw, ch);
|
||||
const d = img.data;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
const a = d[i];
|
||||
if (!a) continue;
|
||||
const k = Math.min(255, a * 3) * 4;
|
||||
d[i - 3] = ramp[k]; d[i - 2] = ramp[k + 1]; d[i - 1] = ramp[k + 2];
|
||||
d[i] = Math.min(200, 60 + a);
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function redrawIfChanged() {
|
||||
if (Math.max(de.scrollWidth, de.clientWidth) !== lastW || Math.max(de.scrollHeight, de.clientHeight) !== lastH) draw();
|
||||
}
|
||||
function stop() {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('resize', draw);
|
||||
host.remove(); canvas.remove();
|
||||
}
|
||||
close.addEventListener('click', stop);
|
||||
window.addEventListener('resize', draw);
|
||||
|
||||
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
|
||||
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
|
||||
.then(function (j) {
|
||||
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
|
||||
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
|
||||
draw();
|
||||
timer = setInterval(redrawIfChanged, 1000);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
|
||||
})();
|
||||
Vendored
+353
@@ -0,0 +1,353 @@
|
||||
/* WEBklar Analyse w.js - generiert aus client/core.js + client/w.js (node client/build.js), nicht von Hand aendern */
|
||||
(function () {
|
||||
'use strict';
|
||||
// Reine Funktionen des Analyse-Skripts (ohne DOM-Zugriff ausser an uebergebenen Objekten).
|
||||
// Wird von w.js/h.js importiert; build.js fuegt alles zu je einer Datei ohne Import zusammen.
|
||||
|
||||
const CONSENT_KEY = 'wk-consent';
|
||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
|
||||
function vpClass(width) {
|
||||
const w = Number(width);
|
||||
if (w < 768) return 'm';
|
||||
if (w < 1024) return 't';
|
||||
return 'd';
|
||||
}
|
||||
|
||||
function cleanPath(href) {
|
||||
let p = '/';
|
||||
const h = String(href == null ? '' : href);
|
||||
if (h.charAt(0) === '/') {
|
||||
p = h.replace(/[?#].*$/, '') || '/'; // wie location.pathname, auch '//foo'
|
||||
} else {
|
||||
try { p = new URL(h, 'http://x.invalid/').pathname || '/'; } catch (e) { /* bleibt '/' */ }
|
||||
}
|
||||
try { p = decodeURIComponent(p); } catch (e) { /* kaputtes Encoding: roh lassen */ }
|
||||
if (p.charAt(0) !== '/') p = '/' + p;
|
||||
return p.slice(0, 300);
|
||||
}
|
||||
|
||||
const stripWww = (h) => String(h).toLowerCase().replace(/^www\./, '');
|
||||
|
||||
function refHost(referrer, ownHost) {
|
||||
if (!referrer) return '';
|
||||
try {
|
||||
const u = new URL(String(referrer));
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') return '';
|
||||
const h = u.hostname.toLowerCase();
|
||||
if (!h || stripWww(h) === stripWww(ownHost || '')) return '';
|
||||
return h;
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// Nur Tag-Namen mit :nth-of-type, keine IDs/Klassen (koennten personenbezogene Texte enthalten).
|
||||
function cssPath(el) {
|
||||
const parts = [];
|
||||
let n = el;
|
||||
while (n && n.tagName && parts.length < 6) {
|
||||
const tag = String(n.tagName).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
||||
if (!tag) break;
|
||||
let idx = 1;
|
||||
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) {
|
||||
if (String(s.tagName).toLowerCase() === String(n.tagName).toLowerCase()) idx++;
|
||||
}
|
||||
parts.unshift(tag + ':nth-of-type(' + idx + ')');
|
||||
if (tag === 'body' || tag === 'html') break;
|
||||
n = n.parentNode;
|
||||
}
|
||||
let out = parts.join(' > ');
|
||||
while (out.length > 200 && parts.length > 1) { parts.shift(); out = parts.join(' > '); }
|
||||
return out.slice(0, 200);
|
||||
}
|
||||
|
||||
function parseHref(a) {
|
||||
const href = a && (typeof a.href === 'string' && a.href ? a.href : (a.getAttribute && a.getAttribute('href'))) || '';
|
||||
return String(href).trim();
|
||||
}
|
||||
|
||||
function linkHost(a) {
|
||||
try { return new URL(parseHref(a)).hostname.toLowerCase(); } catch (e) { return ''; }
|
||||
}
|
||||
|
||||
function contactKind(a, ownHost) {
|
||||
const href = parseHref(a);
|
||||
if (!href) return null;
|
||||
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(href);
|
||||
if (!scheme) return null; // relativ, Anker
|
||||
const s = scheme[1].toLowerCase();
|
||||
if (s === 'tel') return 'tel';
|
||||
if (s === 'mailto') return 'mail';
|
||||
if (s === 'whatsapp') return 'wa';
|
||||
if (s !== 'http' && s !== 'https') return null;
|
||||
const host = linkHost(a);
|
||||
if (!host) return null;
|
||||
if (host === 'wa.me' || host === 'api.whatsapp.com') return 'wa';
|
||||
const own = ownHost == null ? (typeof location !== 'undefined' ? location.hostname : '') : ownHost;
|
||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
||||
}
|
||||
|
||||
function readConsent(storage, now) {
|
||||
try {
|
||||
const raw = storage.getItem(CONSENT_KEY);
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
function contrastOk(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false;
|
||||
const lin = (i) => {
|
||||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
|
||||
};
|
||||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2);
|
||||
return 1.05 / (L + 0.05) >= 4.5;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
||||
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
|
||||
|
||||
function main() {
|
||||
const DOC = document;
|
||||
const WIN = window;
|
||||
if (WIN.__wkAnalytics) return; // doppelt eingebunden
|
||||
WIN.__wkAnalytics = true;
|
||||
const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site')
|
||||
? DOC.currentScript : DOC.querySelector('script[src*="/a/w.js"]');
|
||||
if (!SELF) return;
|
||||
const site = String(SELF.getAttribute('data-site') || '');
|
||||
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
||||
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
||||
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
|
||||
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
|
||||
const base = src.replace(/[^/]*$/, '');
|
||||
const endpoint = base + 'e';
|
||||
|
||||
// ---- Heatmap-Modus: nichts senden, kein Banner ----
|
||||
const qs = new URLSearchParams(WIN.location.search);
|
||||
const heatToken = qs.get('__wk_heat');
|
||||
if (heatToken) {
|
||||
qs.delete('__wk_heat');
|
||||
const rest = qs.toString();
|
||||
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
|
||||
const s = DOC.createElement('script');
|
||||
s.setAttribute('src', base + 'h.js');
|
||||
s.setAttribute('data-token', heatToken);
|
||||
(DOC.head || DOC.documentElement).appendChild(s);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Senden: Fehler werden verschluckt ----
|
||||
function send(obj) {
|
||||
try {
|
||||
const body = JSON.stringify(obj);
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
|
||||
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } };
|
||||
const vp = () => vpClass(WIN.innerWidth);
|
||||
const ownHost = () => WIN.location.hostname;
|
||||
|
||||
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
|
||||
let lastPath = null;
|
||||
let pvStart = Date.now();
|
||||
let maxScroll = 0;
|
||||
let enSent = false;
|
||||
function sendPv(first) {
|
||||
lastPath = cleanPath(WIN.location.pathname);
|
||||
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
||||
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
|
||||
}
|
||||
let navTimer = null;
|
||||
function scheduleNav() {
|
||||
if (navTimer) return;
|
||||
navTimer = setTimeout(safe(function () {
|
||||
navTimer = null;
|
||||
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
}), 300);
|
||||
}
|
||||
sendPv(true);
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
|
||||
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
const hosts = [];
|
||||
const scrollPct = () => {
|
||||
const h = DOC.documentElement.scrollHeight || 1;
|
||||
return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100)));
|
||||
};
|
||||
const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000));
|
||||
|
||||
const onClick = safe(function (e) {
|
||||
if (!allowed()) return;
|
||||
const t = e.target;
|
||||
if (!t || hosts.indexOf(t) !== -1) return;
|
||||
const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight;
|
||||
const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0);
|
||||
const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0);
|
||||
const path = cleanPath(WIN.location.pathname);
|
||||
send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) });
|
||||
let a = t;
|
||||
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
|
||||
const kind = a ? contactKind(a, ownHost()) : null;
|
||||
if (kind) {
|
||||
const ev = { t: 'ct', site, path, kind };
|
||||
if (kind === 'out') ev.host = linkHost(a);
|
||||
send(ev);
|
||||
}
|
||||
});
|
||||
const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
|
||||
const onLeave = safe(function () {
|
||||
if (enSent || !allowed()) return;
|
||||
enSent = true;
|
||||
send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
|
||||
sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
|
||||
});
|
||||
const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
|
||||
|
||||
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
|
||||
function allowed() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
|
||||
if (!ok && tracking) stopTracking();
|
||||
return ok;
|
||||
}
|
||||
|
||||
function startTracking() {
|
||||
if (tracking) return;
|
||||
tracking = true;
|
||||
DOC.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
DOC.addEventListener('visibilitychange', onVis);
|
||||
WIN.addEventListener('pagehide', onLeave);
|
||||
WIN.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
function stopTracking() {
|
||||
tracking = false;
|
||||
DOC.removeEventListener('click', onClick, { capture: true });
|
||||
DOC.removeEventListener('visibilitychange', onVis);
|
||||
WIN.removeEventListener('pagehide', onLeave);
|
||||
WIN.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
// ---- Einwilligung ----
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
|
||||
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let bannerHost = null;
|
||||
let linkHostEl = null;
|
||||
|
||||
function choose(a) {
|
||||
memChoice = null;
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
send({ t: 'bn', site, a });
|
||||
if (a === 'accept') startTracking(); else stopTracking();
|
||||
closeBanner();
|
||||
}
|
||||
|
||||
function el(tag, attrs, text) {
|
||||
const n = DOC.createElement(tag);
|
||||
for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CSS = ':host{all:initial}' +
|
||||
'*{box-sizing:border-box}' +
|
||||
'.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' +
|
||||
'.t{margin:0;max-width:720px}' +
|
||||
'.t a{color:#111;text-decoration:underline}' +
|
||||
'.r{display:flex;gap:10px;flex:none}' +
|
||||
'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;width:150px;height:44px;padding:0 20px;border:2px solid ' + accent + ';border-radius:8px;cursor:pointer;background:' + accent + ';color:#fff}' +
|
||||
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
|
||||
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
|
||||
|
||||
function openBanner(focus) {
|
||||
if (bannerHost) return;
|
||||
const prev = DOC.activeElement;
|
||||
bannerHost = DOC.createElement('div');
|
||||
const st = bannerHost.style;
|
||||
st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647';
|
||||
hosts.push(bannerHost);
|
||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, CSS));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
|
||||
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
|
||||
const row = el('div', { class: 'r' });
|
||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||
const yes = el('button', { type: 'button' }, 'Annehmen');
|
||||
no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); }));
|
||||
yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); }));
|
||||
row.appendChild(no); row.appendChild(yes);
|
||||
bar.appendChild(p); bar.appendChild(row);
|
||||
root.appendChild(bar);
|
||||
DOC.body.appendChild(bannerHost);
|
||||
if (linkHostEl) linkHostEl.style.display = 'none';
|
||||
if (focus === true) no.focus();
|
||||
}
|
||||
function closeBanner() {
|
||||
if (!bannerHost) return;
|
||||
const i = hosts.indexOf(bannerHost);
|
||||
if (i !== -1) hosts.splice(i, 1);
|
||||
bannerHost.remove();
|
||||
bannerHost = null;
|
||||
if (linkHostEl) linkHostEl.style.display = '';
|
||||
}
|
||||
|
||||
function addSettingsLink() {
|
||||
linkHostEl = DOC.createElement('div');
|
||||
const st = linkHostEl.style;
|
||||
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
|
||||
hosts.push(linkHostEl);
|
||||
const root = linkHostEl.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
|
||||
b.addEventListener('click', safe(function () { openBanner(true); }));
|
||||
root.appendChild(b);
|
||||
DOC.body.appendChild(linkHostEl);
|
||||
}
|
||||
|
||||
DOC.addEventListener('click', safe(function (e) {
|
||||
const t = e.target;
|
||||
if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(true); }
|
||||
}));
|
||||
if (!DOC.querySelector('[data-webklar-consent]')) addSettingsLink();
|
||||
|
||||
// Einwilligung in anderem Tab geaendert
|
||||
WIN.addEventListener('storage', safe(function (e) {
|
||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
||||
const c = readConsent(storage, Date.now());
|
||||
if (c && c.a === 'accept') startTracking(); else stopTracking();
|
||||
if (c) closeBanner();
|
||||
}));
|
||||
|
||||
if (stored) {
|
||||
if (stored.a === 'accept') startTracking();
|
||||
} else {
|
||||
openBanner();
|
||||
send({ t: 'bn', site, a: 'shown' });
|
||||
}
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,98 @@
|
||||
// WEBklar Analyse: Heatmap-Overlay (wird von w.js nur mit gueltigem ?__wk_heat=Token geladen).
|
||||
|
||||
function main() {
|
||||
const me = document.currentScript;
|
||||
const token = me && me.getAttribute('data-token');
|
||||
const src = String((me && (me.src || me.getAttribute('src'))) || '').replace(/[?#].*$/, '');
|
||||
const base = src.replace(/[^/]*$/, '');
|
||||
if (!token || !base) return;
|
||||
|
||||
const de = document.documentElement;
|
||||
const host = document.createElement('div');
|
||||
host.style.position = 'fixed'; host.style.left = '0'; host.style.right = '0'; host.style.top = '0'; host.style.zIndex = '2147483647';
|
||||
const root = host.attachShadow({ mode: 'open' });
|
||||
const style = document.createElement('style');
|
||||
style.textContent = ':host{all:initial}.bar{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:#111;color:#fff;font:14px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}button{font:600 14px/1 system-ui,sans-serif;padding:8px 14px;border:2px solid #fff;border-radius:6px;background:#fff;color:#111;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}';
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'bar';
|
||||
const msg = document.createElement('span');
|
||||
msg.textContent = 'Heatmap wird geladen ...';
|
||||
const close = document.createElement('button');
|
||||
close.setAttribute('type', 'button');
|
||||
close.textContent = 'Schließen';
|
||||
bar.appendChild(msg); bar.appendChild(close);
|
||||
root.appendChild(style); root.appendChild(bar);
|
||||
document.body.appendChild(host);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
const cs = canvas.style;
|
||||
cs.position = 'absolute'; cs.left = '0'; cs.top = '0'; cs.pointerEvents = 'none'; cs.zIndex = '2147483646';
|
||||
document.body.appendChild(canvas);
|
||||
|
||||
let points = [];
|
||||
let lastW = 0, lastH = 0, timer = 0;
|
||||
|
||||
// Farbverlauf: transparent -> blau -> gruen -> gelb -> rot
|
||||
const ramp = (function () {
|
||||
const c = document.createElement('canvas'); c.width = 256; c.height = 1;
|
||||
const g = c.getContext('2d');
|
||||
const lg = g.createLinearGradient(0, 0, 256, 0);
|
||||
lg.addColorStop(0.25, 'rgb(0,0,255)'); lg.addColorStop(0.55, 'rgb(0,255,0)'); lg.addColorStop(0.85, 'rgb(255,255,0)'); lg.addColorStop(1, 'rgb(255,0,0)');
|
||||
g.fillStyle = lg; g.fillRect(0, 0, 256, 1);
|
||||
return g.getImageData(0, 0, 256, 1).data;
|
||||
})();
|
||||
|
||||
function draw() {
|
||||
const w = Math.max(de.scrollWidth, de.clientWidth, 1);
|
||||
const h = Math.max(de.scrollHeight, de.clientHeight, 1);
|
||||
lastW = w; lastH = h;
|
||||
// Zeichenflaeche begrenzen (Speicher): hoechstens ca. 4 Mio. Pixel
|
||||
const scale = Math.min(1, Math.sqrt(4e6 / (w * h)));
|
||||
const cw = Math.max(1, Math.floor(w * scale)), ch = Math.max(1, Math.floor(h * scale));
|
||||
canvas.width = cw; canvas.height = ch;
|
||||
cs.width = w + 'px'; cs.height = h + 'px';
|
||||
const g = canvas.getContext('2d');
|
||||
g.clearRect(0, 0, cw, ch);
|
||||
const r = Math.max(6, 30 * scale);
|
||||
for (const p of points) {
|
||||
const x = p.x * cw, y = p.y * ch;
|
||||
const rg = g.createRadialGradient(x, y, 0, x, y, r);
|
||||
rg.addColorStop(0, 'rgba(0,0,0,0.25)'); rg.addColorStop(1, 'rgba(0,0,0,0)');
|
||||
g.fillStyle = rg; g.fillRect(x - r, y - r, r * 2, r * 2);
|
||||
}
|
||||
// Deckkraft-Stufen in Farben umsetzen
|
||||
const img = g.getImageData(0, 0, cw, ch);
|
||||
const d = img.data;
|
||||
for (let i = 3; i < d.length; i += 4) {
|
||||
const a = d[i];
|
||||
if (!a) continue;
|
||||
const k = Math.min(255, a * 3) * 4;
|
||||
d[i - 3] = ramp[k]; d[i - 2] = ramp[k + 1]; d[i - 1] = ramp[k + 2];
|
||||
d[i] = Math.min(200, 60 + a);
|
||||
}
|
||||
g.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
function redrawIfChanged() {
|
||||
if (Math.max(de.scrollWidth, de.clientWidth) !== lastW || Math.max(de.scrollHeight, de.clientHeight) !== lastH) draw();
|
||||
}
|
||||
function stop() {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('resize', draw);
|
||||
host.remove(); canvas.remove();
|
||||
}
|
||||
close.addEventListener('click', stop);
|
||||
window.addEventListener('resize', draw);
|
||||
|
||||
fetch(base + 'heat?token=' + encodeURIComponent(token), { credentials: 'omit' })
|
||||
.then(function (r) { if (!r.ok) throw new Error('heat'); return r.json(); })
|
||||
.then(function (j) {
|
||||
points = (j && Array.isArray(j.points) ? j.points : []).filter(function (p) { return p && p.x >= 0 && p.x <= 1 && p.y >= 0 && p.y <= 1; });
|
||||
msg.textContent = 'Heatmap – ' + points.length + ' Klicks (letzte 30 Tage)';
|
||||
draw();
|
||||
timer = setInterval(redrawIfChanged, 1000);
|
||||
})
|
||||
.catch(function () { msg.textContent = 'Heatmap-Link abgelaufen oder ungültig'; });
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
@@ -0,0 +1,238 @@
|
||||
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
||||
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
|
||||
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js';
|
||||
|
||||
function main() {
|
||||
const DOC = document;
|
||||
const WIN = window;
|
||||
if (WIN.__wkAnalytics) return; // doppelt eingebunden
|
||||
WIN.__wkAnalytics = true;
|
||||
const SELF = DOC.currentScript && DOC.currentScript.getAttribute && DOC.currentScript.getAttribute('data-site')
|
||||
? DOC.currentScript : DOC.querySelector('script[src*="/a/w.js"]');
|
||||
if (!SELF) return;
|
||||
const site = String(SELF.getAttribute('data-site') || '');
|
||||
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
||||
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
||||
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
|
||||
const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
|
||||
const base = src.replace(/[^/]*$/, '');
|
||||
const endpoint = base + 'e';
|
||||
|
||||
// ---- Heatmap-Modus: nichts senden, kein Banner ----
|
||||
const qs = new URLSearchParams(WIN.location.search);
|
||||
const heatToken = qs.get('__wk_heat');
|
||||
if (heatToken) {
|
||||
qs.delete('__wk_heat');
|
||||
const rest = qs.toString();
|
||||
try { WIN.history.replaceState(null, '', WIN.location.pathname + (rest ? '?' + rest : '') + (WIN.location.hash || '')); } catch (e) { /* egal */ }
|
||||
const s = DOC.createElement('script');
|
||||
s.setAttribute('src', base + 'h.js');
|
||||
s.setAttribute('data-token', heatToken);
|
||||
(DOC.head || DOC.documentElement).appendChild(s);
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Senden: Fehler werden verschluckt ----
|
||||
function send(obj) {
|
||||
try {
|
||||
const body = JSON.stringify(obj);
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
|
||||
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
const safe = (fn) => function () { try { return fn.apply(this, arguments); } catch (e) { /* nie die Seite stoeren */ } };
|
||||
const vp = () => vpClass(WIN.innerWidth);
|
||||
const ownHost = () => WIN.location.hostname;
|
||||
|
||||
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
|
||||
let lastPath = null;
|
||||
let pvStart = Date.now();
|
||||
let maxScroll = 0;
|
||||
let enSent = false;
|
||||
function sendPv(first) {
|
||||
lastPath = cleanPath(WIN.location.pathname);
|
||||
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
||||
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
|
||||
}
|
||||
let navTimer = null;
|
||||
function scheduleNav() {
|
||||
if (navTimer) return;
|
||||
navTimer = setTimeout(safe(function () {
|
||||
navTimer = null;
|
||||
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
}), 300);
|
||||
}
|
||||
sendPv(true);
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
|
||||
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
const hosts = [];
|
||||
const scrollPct = () => {
|
||||
const h = DOC.documentElement.scrollHeight || 1;
|
||||
return Math.max(0, Math.min(100, Math.round(((WIN.scrollY || 0) + WIN.innerHeight) / h * 100)));
|
||||
};
|
||||
const rel = (v, total) => Math.max(0, Math.min(1, Math.round(v / (total || 1) * 10000) / 10000));
|
||||
|
||||
const onClick = safe(function (e) {
|
||||
if (!allowed()) return;
|
||||
const t = e.target;
|
||||
if (!t || hosts.indexOf(t) !== -1) return;
|
||||
const dw = DOC.documentElement.scrollWidth, dh = DOC.documentElement.scrollHeight;
|
||||
const px = typeof e.pageX === 'number' ? e.pageX : (e.clientX || 0) + (WIN.scrollX || 0);
|
||||
const py = typeof e.pageY === 'number' ? e.pageY : (e.clientY || 0) + (WIN.scrollY || 0);
|
||||
const path = cleanPath(WIN.location.pathname);
|
||||
send({ t: 'ck', site, path, vp: vp(), x: rel(px, dw), y: rel(py, dh), sel: cssPath(t) });
|
||||
let a = t;
|
||||
while (a && String(a.tagName).toUpperCase() !== 'A') a = a.parentNode;
|
||||
const kind = a ? contactKind(a, ownHost()) : null;
|
||||
if (kind) {
|
||||
const ev = { t: 'ct', site, path, kind };
|
||||
if (kind === 'out') ev.host = linkHost(a);
|
||||
send(ev);
|
||||
}
|
||||
});
|
||||
const onScroll = safe(function () { maxScroll = Math.max(maxScroll, scrollPct()); });
|
||||
const onLeave = safe(function () {
|
||||
if (enSent || !allowed()) return;
|
||||
enSent = true;
|
||||
send({ t: 'en', site, path: lastPath || cleanPath(WIN.location.pathname), vp: vp(),
|
||||
sec: Math.max(0, Math.min(3600, Math.round((Date.now() - pvStart) / 1000))), scroll: Math.max(maxScroll, scrollPct()) });
|
||||
});
|
||||
const onVis = safe(function () { if (DOC.visibilityState === 'hidden') onLeave(); });
|
||||
|
||||
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
|
||||
function allowed() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
|
||||
if (!ok && tracking) stopTracking();
|
||||
return ok;
|
||||
}
|
||||
|
||||
function startTracking() {
|
||||
if (tracking) return;
|
||||
tracking = true;
|
||||
DOC.addEventListener('click', onClick, { capture: true, passive: true });
|
||||
DOC.addEventListener('visibilitychange', onVis);
|
||||
WIN.addEventListener('pagehide', onLeave);
|
||||
WIN.addEventListener('scroll', onScroll, { passive: true });
|
||||
}
|
||||
function stopTracking() {
|
||||
tracking = false;
|
||||
DOC.removeEventListener('click', onClick, { capture: true });
|
||||
DOC.removeEventListener('visibilitychange', onVis);
|
||||
WIN.removeEventListener('pagehide', onLeave);
|
||||
WIN.removeEventListener('scroll', onScroll);
|
||||
}
|
||||
|
||||
// ---- Einwilligung ----
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
|
||||
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let bannerHost = null;
|
||||
let linkHostEl = null;
|
||||
|
||||
function choose(a) {
|
||||
memChoice = null;
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
send({ t: 'bn', site, a });
|
||||
if (a === 'accept') startTracking(); else stopTracking();
|
||||
closeBanner();
|
||||
}
|
||||
|
||||
function el(tag, attrs, text) {
|
||||
const n = DOC.createElement(tag);
|
||||
for (const k in attrs || {}) n.setAttribute(k, attrs[k]);
|
||||
if (text != null) n.textContent = text;
|
||||
return n;
|
||||
}
|
||||
|
||||
const CSS = ':host{all:initial}' +
|
||||
'*{box-sizing:border-box}' +
|
||||
'.bar{display:flex;align-items:center;justify-content:center;gap:16px;padding:14px 20px;background:#fff;color:#111;border-top:1px solid #d0d0d0;box-shadow:0 -2px 12px rgba(0,0,0,.12);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,sans-serif}' +
|
||||
'.t{margin:0;max-width:720px}' +
|
||||
'.t a{color:#111;text-decoration:underline}' +
|
||||
'.r{display:flex;gap:10px;flex:none}' +
|
||||
'button{font:600 15px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;width:150px;height:44px;padding:0 20px;border:2px solid ' + accent + ';border-radius:8px;cursor:pointer;background:' + accent + ';color:#fff}' +
|
||||
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
|
||||
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
|
||||
|
||||
function openBanner(focus) {
|
||||
if (bannerHost) return;
|
||||
const prev = DOC.activeElement;
|
||||
bannerHost = DOC.createElement('div');
|
||||
const st = bannerHost.style;
|
||||
st.position = 'fixed'; st.left = '0'; st.right = '0'; st.bottom = '0'; st.zIndex = '2147483647';
|
||||
hosts.push(bannerHost);
|
||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, CSS));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
|
||||
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
|
||||
const row = el('div', { class: 'r' });
|
||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||
const yes = el('button', { type: 'button' }, 'Annehmen');
|
||||
no.addEventListener('click', safe(function () { choose('reject'); if (prev && prev.focus) prev.focus(); }));
|
||||
yes.addEventListener('click', safe(function () { choose('accept'); if (prev && prev.focus) prev.focus(); }));
|
||||
row.appendChild(no); row.appendChild(yes);
|
||||
bar.appendChild(p); bar.appendChild(row);
|
||||
root.appendChild(bar);
|
||||
DOC.body.appendChild(bannerHost);
|
||||
if (linkHostEl) linkHostEl.style.display = 'none';
|
||||
if (focus === true) no.focus();
|
||||
}
|
||||
function closeBanner() {
|
||||
if (!bannerHost) return;
|
||||
const i = hosts.indexOf(bannerHost);
|
||||
if (i !== -1) hosts.splice(i, 1);
|
||||
bannerHost.remove();
|
||||
bannerHost = null;
|
||||
if (linkHostEl) linkHostEl.style.display = '';
|
||||
}
|
||||
|
||||
function addSettingsLink() {
|
||||
linkHostEl = DOC.createElement('div');
|
||||
const st = linkHostEl.style;
|
||||
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
|
||||
hosts.push(linkHostEl);
|
||||
const root = linkHostEl.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
|
||||
b.addEventListener('click', safe(function () { openBanner(true); }));
|
||||
root.appendChild(b);
|
||||
DOC.body.appendChild(linkHostEl);
|
||||
}
|
||||
|
||||
DOC.addEventListener('click', safe(function (e) {
|
||||
const t = e.target;
|
||||
if (t && t.closest && t.closest('[data-webklar-consent]')) { if (e.preventDefault) e.preventDefault(); openBanner(true); }
|
||||
}));
|
||||
if (!DOC.querySelector('[data-webklar-consent]')) addSettingsLink();
|
||||
|
||||
// Einwilligung in anderem Tab geaendert
|
||||
WIN.addEventListener('storage', safe(function (e) {
|
||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
||||
const c = readConsent(storage, Date.now());
|
||||
if (c && c.a === 'accept') startTracking(); else stopTracking();
|
||||
if (c) closeBanner();
|
||||
}));
|
||||
|
||||
if (stored) {
|
||||
if (stored.a === 'accept') startTracking();
|
||||
} else {
|
||||
openBanner();
|
||||
send({ t: 'bn', site, a: 'shown' });
|
||||
}
|
||||
}
|
||||
|
||||
try { main(); } catch (e) { /* nie die Seite stoeren */ }
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "webklar-analytics",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"test": "node --test test/*.test.js"
|
||||
}
|
||||
}
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
# Lädt die aktuelle DB-IP-Lite-Länderdatei (monatlich) nach $ANALYTICS_DATA_DIR/geo.csv und startet
|
||||
# den Dienst nur neu, wenn sich die Datei geändert hat. Läuft auf dem Host (z. B. per Cron, 1x pro Woche).
|
||||
# Die alte geo.csv wird nie gelöscht, bevor die neue geprüft und umbenannt ist.
|
||||
set -eu
|
||||
|
||||
DIR="${ANALYTICS_DATA_DIR:-/srv/webklar-analytics}"
|
||||
CONTAINER="${ANALYTICS_CONTAINER:-webklar-analytics}"
|
||||
NEU="$DIR/geo.csv.new"
|
||||
ZIEL="$DIR/geo.csv"
|
||||
MARKER="$DIR/geo.restart-pending"
|
||||
|
||||
fehler() { echo "Fehler (geo-update): $*" >&2; rm -f "$NEU" "$NEU.gz"; exit 1; }
|
||||
|
||||
command -v curl >/dev/null 2>&1 || fehler "curl fehlt."
|
||||
command -v gunzip >/dev/null 2>&1 || fehler "gunzip fehlt."
|
||||
[ -d "$DIR" ] || fehler "Datenverzeichnis $DIR existiert nicht."
|
||||
|
||||
JETZT="$(date -u +%Y-%m)"
|
||||
JAHR="${JETZT%-*}"
|
||||
MONAT="${JETZT#*-}"
|
||||
m="$(printf '%s' "$MONAT" | sed 's/^0//')"
|
||||
if [ "$m" -eq 1 ]; then VORMONAT="$((JAHR - 1))-12"; else VORMONAT="$JAHR-$(printf '%02d' "$((m - 1))")"; fi
|
||||
|
||||
geladen=""
|
||||
for monat in "$JETZT" "$VORMONAT"; do
|
||||
url="https://download.db-ip.com/free/dbip-country-lite-$monat.csv.gz"
|
||||
if curl -fsSL --retry 2 --max-time 300 -o "$NEU.gz" "$url" 2>/dev/null && gunzip -c "$NEU.gz" > "$NEU" 2>/dev/null; then
|
||||
geladen="$monat"
|
||||
break
|
||||
fi
|
||||
done
|
||||
rm -f "$NEU.gz"
|
||||
[ -n "$geladen" ] || fehler "Download von DB-IP fehlgeschlagen (weder $JETZT noch $VORMONAT). Bestehende geo.csv bleibt unverändert."
|
||||
|
||||
zeilen="$(wc -l < "$NEU" | tr -d ' ')"
|
||||
[ "$zeilen" -gt 100000 ] || fehler "Neue Datei hat nur $zeilen Zeilen (erwartet > 100000). Bestehende geo.csv bleibt unverändert."
|
||||
grep -Eq ',"?DE"?\r?$' "$NEU" || fehler "Neue Datei enthält kein 'DE'. Bestehende geo.csv bleibt unverändert."
|
||||
|
||||
neustart() {
|
||||
docker restart "$CONTAINER" >/dev/null || fehler "Neustart von $CONTAINER fehlgeschlagen (geo.csv ist aber aktualisiert, nächster Lauf versucht den Neustart erneut)."
|
||||
rm -f "$MARKER"
|
||||
echo "$CONTAINER neu gestartet."
|
||||
}
|
||||
|
||||
if [ -f "$ZIEL" ] && cmp -s "$NEU" "$ZIEL"; then
|
||||
rm -f "$NEU"
|
||||
if [ -f "$MARKER" ]; then
|
||||
echo "geo.csv ($geladen) unverändert, aber ein früherer Neustart steht aus."
|
||||
neustart
|
||||
else
|
||||
echo "geo.csv ($geladen) unverändert, kein Neustart nötig."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mv -f "$NEU" "$ZIEL"
|
||||
: > "$MARKER" # bleibt liegen, bis der Neustart gelungen ist
|
||||
echo "geo.csv aktualisiert ($geladen, $zeilen Zeilen)."
|
||||
neustart
|
||||
@@ -0,0 +1,60 @@
|
||||
// GET/HEAD /a/w.js und /a/h.js (öffentlicher Listener): generierte Client-Skripte aus client/dist/.
|
||||
// Einmal beim Start in den Speicher geladen (roh + gzip); fehlt eine Datei, antwortet der Pfad mit 503.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { gzipSync } from 'node:zlib';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
|
||||
const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' };
|
||||
|
||||
export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
|
||||
const assets = new Map();
|
||||
for (const [path, file] of Object.entries(FILES)) {
|
||||
try {
|
||||
const raw = readFileSync(join(distDir, file));
|
||||
assets.set(path, { raw, gz: gzipSync(raw, { level: 9 }), etag: `"${crypto.createHash('sha256').update(raw).digest('hex')}"` });
|
||||
} catch (e) {
|
||||
log.error(`Analytics: Skript ${file} fehlt in ${distDir} (${e.code || e.message}); ${path} antwortet mit 503`);
|
||||
}
|
||||
}
|
||||
|
||||
// true, wenn die Anfrage ein Skript betraf.
|
||||
return function handle(req, res, pathname) {
|
||||
if (!(pathname in FILES)) return false;
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
res.writeHead(405, { Allow: 'GET, HEAD' });
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
const a = assets.get(pathname);
|
||||
if (!a) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
res.end(req.method === 'HEAD' ? '' : JSON.stringify({ error: 'Skript nicht verfügbar' }));
|
||||
return true;
|
||||
}
|
||||
const headers = {
|
||||
'Content-Type': 'text/javascript; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
ETag: a.etag,
|
||||
Vary: 'Accept-Encoding',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
const inm = req.headers['if-none-match'];
|
||||
if (inm && inm.split(',').some((t) => t.trim().replace(/^W\//, '') === a.etag)) {
|
||||
res.writeHead(304, headers);
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
const gzip = /\bgzip\b/i.test(req.headers['accept-encoding'] || '');
|
||||
const body = gzip ? a.gz : a.raw;
|
||||
if (gzip) headers['Content-Encoding'] = 'gzip';
|
||||
headers['Content-Length'] = body.length;
|
||||
res.writeHead(200, headers);
|
||||
res.end(req.method === 'HEAD' ? undefined : body);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Konfiguration aus der Umgebung; leerer adminToken sperrt alle Admin-Routen.
|
||||
export function loadConfig(env = process.env) {
|
||||
return {
|
||||
port: Number(env.ANALYTICS_PORT) || 8090,
|
||||
adminPort: Number(env.ANALYTICS_ADMIN_PORT) || 8091,
|
||||
dataDir: env.ANALYTICS_DATA_DIR || '/srv/webklar-analytics',
|
||||
adminToken: env.ANALYTICS_ADMIN_TOKEN || '',
|
||||
heatSecret: env.ANALYTICS_HEAT_SECRET || '',
|
||||
geoFile: env.ANALYTICS_GEO_FILE || '',
|
||||
baseHost: env.ANALYTICS_BASE_HOST || 'project.webklar.com',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
|
||||
const SCHEMA = `
|
||||
CREATE TABLE IF NOT EXISTS sites (
|
||||
siteKey TEXT PRIMARY KEY,
|
||||
projectId TEXT NOT NULL,
|
||||
domains TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
offSince TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS events_raw (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
type TEXT NOT NULL,
|
||||
path TEXT,
|
||||
vp TEXT,
|
||||
ref TEXT,
|
||||
country TEXT,
|
||||
device TEXT,
|
||||
visitor TEXT,
|
||||
sec INTEGER,
|
||||
scroll INTEGER,
|
||||
kind TEXT,
|
||||
host TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_events_site_ts ON events_raw(site, ts);
|
||||
CREATE TABLE IF NOT EXISTS clicks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site TEXT NOT NULL,
|
||||
ts INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
vp TEXT NOT NULL,
|
||||
x REAL NOT NULL,
|
||||
y REAL NOT NULL,
|
||||
sel TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_clicks_site_path_vp_ts ON clicks(site, path, vp, ts);
|
||||
CREATE TABLE IF NOT EXISTS daily (
|
||||
site TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
dim TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
count INTEGER NOT NULL DEFAULT 0,
|
||||
PRIMARY KEY (site, day, dim, key)
|
||||
);
|
||||
-- Merker für die Nachtverdichtung: Tage, die schon in daily stehen (macht runNightly idempotent).
|
||||
CREATE TABLE IF NOT EXISTS agg_state (
|
||||
site TEXT NOT NULL,
|
||||
day TEXT NOT NULL,
|
||||
PRIMARY KEY (site, day)
|
||||
);
|
||||
`;
|
||||
|
||||
export function openDb(path) {
|
||||
const db = new DatabaseSync(path);
|
||||
if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
// Länderzuordnung per DB-IP Lite (country, CSV: start_ip,end_ip,land). Die Datei wird einmal beim
|
||||
// Start geladen (IPv4 als Uint32, IPv6 als 2×64 Bit in BigUint64Array) und per Binärsuche abgefragt.
|
||||
import { readFileSync } from 'node:fs';
|
||||
import net from 'node:net';
|
||||
|
||||
const MASK64 = (1n << 64n) - 1n;
|
||||
|
||||
function parseV4(s) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(s);
|
||||
if (!m) return -1;
|
||||
let n = 0;
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
const o = Number(m[i]);
|
||||
if (o > 255) return -1;
|
||||
n = n * 256 + o;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// IPv6 ⇒ 128-Bit-BigInt oder null (auch mit angehängter IPv4-Schreibweise).
|
||||
function parseV6(s) {
|
||||
if (net.isIPv6(s) === false) return null;
|
||||
s = s.split('%')[0];
|
||||
const dot = s.lastIndexOf('.');
|
||||
if (dot !== -1) {
|
||||
const v4 = parseV4(s.slice(s.lastIndexOf(':') + 1));
|
||||
if (v4 < 0) return null;
|
||||
s = s.slice(0, s.lastIndexOf(':') + 1) + Math.floor(v4 / 65536).toString(16) + ':' + (v4 % 65536).toString(16);
|
||||
}
|
||||
const [head, tail] = s.split('::');
|
||||
const h = head ? head.split(':') : [];
|
||||
const t = tail === undefined ? [] : (tail ? tail.split(':') : []);
|
||||
const fill = tail === undefined ? 0 : 8 - h.length - t.length;
|
||||
const groups = [...h, ...Array(Math.max(fill, 0)).fill('0'), ...t];
|
||||
if (groups.length !== 8) return null;
|
||||
let n = 0n;
|
||||
for (const g of groups) n = (n << 16n) | BigInt(parseInt(g, 16));
|
||||
return n;
|
||||
}
|
||||
|
||||
const pack = (c) => (c.length === 2 && c !== 'ZZ' && /^[A-Za-z]{2}$/.test(c) ? (c.toUpperCase().charCodeAt(0) << 8) | c.toUpperCase().charCodeAt(1) : 0);
|
||||
const unpack = (n) => (n ? String.fromCharCode(n >> 8, n & 255) : '');
|
||||
|
||||
function sortedBy(n, cmp) {
|
||||
for (let i = 1; i < n; i++) if (cmp(i - 1, i) > 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function loadGeo(file) {
|
||||
const none = { lookup: () => '', size: 0 };
|
||||
let text;
|
||||
try { text = readFileSync(file, 'utf8'); } catch (e) {
|
||||
console.warn(`Analytics: Länderdatei ${file} nicht lesbar (${e.code || e.message}); Länder bleiben leer.`);
|
||||
return none;
|
||||
}
|
||||
// Obergrenze der Zeilen für die Vorab-Reservierung
|
||||
let cap = 1;
|
||||
for (let i = text.indexOf('\n'); i !== -1; i = text.indexOf('\n', i + 1)) cap++;
|
||||
let n4 = 0;
|
||||
let n6 = 0;
|
||||
const s4 = new Uint32Array(cap); const e4 = new Uint32Array(cap); const c4 = new Uint16Array(cap);
|
||||
const sh = new BigUint64Array(cap); const sl = new BigUint64Array(cap);
|
||||
const eh = new BigUint64Array(cap); const el = new BigUint64Array(cap); const c6 = new Uint16Array(cap);
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
let nl = text.indexOf('\n', pos);
|
||||
if (nl === -1) nl = text.length;
|
||||
const line = text.slice(pos, nl).replace(/["\r]/g, '').trim();
|
||||
pos = nl + 1;
|
||||
if (!line) continue;
|
||||
const p = line.split(',');
|
||||
if (p.length < 3) continue;
|
||||
const a = p[0].trim(); const b = p[1].trim(); const c = pack(p[2].trim());
|
||||
if (a.includes(':')) {
|
||||
const x = parseV6(a); const y = parseV6(b);
|
||||
if (x === null || y === null || y < x) continue;
|
||||
sh[n6] = x >> 64n; sl[n6] = x & MASK64; eh[n6] = y >> 64n; el[n6] = y & MASK64; c6[n6++] = c;
|
||||
} else {
|
||||
const x = parseV4(a); const y = parseV4(b);
|
||||
if (x < 0 || y < x) continue;
|
||||
s4[n4] = x; e4[n4] = y; c4[n4++] = c;
|
||||
}
|
||||
}
|
||||
text = null;
|
||||
|
||||
// Falls die Datei nicht sortiert ist: Indexsortierung
|
||||
let S4 = s4.slice(0, n4); let E4 = e4.slice(0, n4); let C4 = c4.slice(0, n4);
|
||||
if (!sortedBy(n4, (i, j) => S4[i] - S4[j])) {
|
||||
const idx = Array.from({ length: n4 }, (_, i) => i).sort((i, j) => S4[i] - S4[j]);
|
||||
S4 = Uint32Array.from(idx, (i) => s4[i]); E4 = Uint32Array.from(idx, (i) => e4[i]); C4 = Uint16Array.from(idx, (i) => c4[i]);
|
||||
}
|
||||
let SH = sh.slice(0, n6); let SL = sl.slice(0, n6); let EH = eh.slice(0, n6); let EL = el.slice(0, n6); let C6 = c6.slice(0, n6);
|
||||
const cmp6 = (i, j) => (SH[i] !== SH[j] ? (SH[i] < SH[j] ? -1 : 1) : (SL[i] === SL[j] ? 0 : (SL[i] < SL[j] ? -1 : 1)));
|
||||
if (!sortedBy(n6, cmp6)) {
|
||||
const idx = Array.from({ length: n6 }, (_, i) => i).sort(cmp6);
|
||||
const pick = (A, T) => T.from(idx, (i) => A[i]);
|
||||
const [a, b, c, d, e] = [pick(SH, BigUint64Array), pick(SL, BigUint64Array), pick(EH, BigUint64Array), pick(EL, BigUint64Array), pick(C6, Uint16Array)];
|
||||
SH = a; SL = b; EH = c; EL = d; C6 = e;
|
||||
}
|
||||
|
||||
const look4 = (v) => {
|
||||
let lo = 0; let hi = n4 - 1; let best = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (S4[mid] <= v) { best = mid; lo = mid + 1; } else hi = mid - 1;
|
||||
}
|
||||
return best >= 0 && v <= E4[best] ? unpack(C4[best]) : '';
|
||||
};
|
||||
const look6 = (h, l) => {
|
||||
let lo = 0; let hi = n6 - 1; let best = -1;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >>> 1;
|
||||
if (SH[mid] < h || (SH[mid] === h && SL[mid] <= l)) { best = mid; lo = mid + 1; } else hi = mid - 1;
|
||||
}
|
||||
if (best < 0) return '';
|
||||
const inside = h < EH[best] || (h === EH[best] && l <= EL[best]);
|
||||
return inside ? unpack(C6[best]) : '';
|
||||
};
|
||||
|
||||
return {
|
||||
size: n4 + n6,
|
||||
lookup(ip) {
|
||||
if (typeof ip !== 'string') return '';
|
||||
ip = ip.trim();
|
||||
if (!ip.includes(':')) {
|
||||
const v = parseV4(ip);
|
||||
return v < 0 ? '' : look4(v);
|
||||
}
|
||||
const v = parseV6(ip);
|
||||
if (v === null) return '';
|
||||
const h = v >> 64n; const l = v & MASK64;
|
||||
if (h === 0n && (l >> 32n) === 0xffffn) return look4(Number(l & 0xffffffffn)); // ::ffff:a.b.c.d
|
||||
return look6(h, l);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay.
|
||||
// Token (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, e = Ablauf in Unix-Sekunden.
|
||||
import crypto from 'node:crypto';
|
||||
import { hostOf } from './ingest.js';
|
||||
|
||||
const SITE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const MAX_AHEAD = 30 * 60; // Sekunden
|
||||
const POINTS = 5000;
|
||||
const CLICK_DAYS = 30;
|
||||
|
||||
export function signHeatToken({ siteKey, path, vp, exp }, secret) {
|
||||
const part = Buffer.from(JSON.stringify({ s: siteKey, p: path, v: vp, e: exp })).toString('base64url');
|
||||
return `${part}.${crypto.createHmac('sha256', secret).update(part).digest('hex')}`;
|
||||
}
|
||||
|
||||
// Liefert { siteKey, path, vp, exp } oder null (Signatur, Format, abgelaufen, > 30 Min voraus).
|
||||
// ignoreExpiry: nur Signatur und Format prüfen (um die Website eines abgelaufenen Links zu erkennen).
|
||||
export function verifyHeatToken(token, secret, nowMs, { ignoreExpiry = false } = {}) {
|
||||
if (!secret || typeof token !== 'string') return null;
|
||||
const m = /^([A-Za-z0-9_-]+)\.([0-9a-f]{64})$/.exec(token);
|
||||
if (!m) return null;
|
||||
const expect = crypto.createHmac('sha256', secret).update(m[1]).digest();
|
||||
const got = Buffer.from(m[2], 'hex');
|
||||
if (got.length !== expect.length || !crypto.timingSafeEqual(got, expect)) return null;
|
||||
let o;
|
||||
try { o = JSON.parse(Buffer.from(m[1], 'base64url').toString('utf8')); } catch { return null; }
|
||||
if (!o || typeof o !== 'object' || typeof o.s !== 'string' || !SITE_KEY.test(o.s)
|
||||
|| typeof o.p !== 'string' || !o.p.startsWith('/') || !['m', 't', 'd'].includes(o.v)
|
||||
|| !Number.isInteger(o.e)) return null;
|
||||
const nowS = nowMs / 1000;
|
||||
if (!ignoreExpiry && (o.e <= nowS || o.e > nowS + MAX_AHEAD)) return null;
|
||||
return { siteKey: o.s, path: o.p, vp: o.v, exp: o.e };
|
||||
}
|
||||
|
||||
export function createHeat({ db, config = {}, now }) {
|
||||
const clock = now || Date.now;
|
||||
const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?');
|
||||
const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC LIMIT ?');
|
||||
|
||||
// CORS: nur die eigenen https-Domains der Website (das Overlay läuft auf der Kundenseite).
|
||||
function cors(res, req, domains) {
|
||||
const origin = req.headers.origin;
|
||||
const host = hostOf(origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
if (host && domains.includes(host)) res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
}
|
||||
const domainsOf = (site) => { try { return JSON.parse(site.domains); } catch { return []; } };
|
||||
|
||||
// true, wenn die Anfrage /a/heat betraf.
|
||||
return function handle(req, res, url) {
|
||||
if (url.pathname !== '/a/heat') return false;
|
||||
const end = (status, body, extra = {}) => {
|
||||
const b = body ? JSON.stringify(body) : '';
|
||||
res.writeHead(status, { 'Cache-Control': 'no-store', ...(body ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(b) } : {}), ...extra });
|
||||
res.end(b);
|
||||
return true;
|
||||
};
|
||||
const token = url.searchParams.get('token');
|
||||
// Website zum Token (auch bei abgelaufenem, aber echt signiertem Link), nur für die CORS-Köpfe der Fehlerantworten.
|
||||
const knownSite = () => {
|
||||
const t = verifyHeatToken(token, config.heatSecret, clock(), { ignoreExpiry: true });
|
||||
return t && getSite.get(t.siteKey);
|
||||
};
|
||||
if (req.method === 'OPTIONS') {
|
||||
// Preflight: CORS-Köpfe nur, wenn das Token zu einer Website gehört und der Origin deren https-Domain ist.
|
||||
const site = knownSite();
|
||||
if (site) cors(res, req, domainsOf(site));
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
return end(204);
|
||||
}
|
||||
if (req.method !== 'GET') return end(405, null, { Allow: 'GET, OPTIONS' });
|
||||
const t = verifyHeatToken(token, config.heatSecret, clock());
|
||||
const site = t && getSite.get(t.siteKey);
|
||||
if (!t || !site || !site.active) {
|
||||
// Bekannte Website: CORS-Kopf auch bei 403, damit das Overlay "Link abgelaufen oder ungültig" zeigen kann.
|
||||
const known = site || knownSite();
|
||||
if (known) cors(res, req, domainsOf(known));
|
||||
return end(403, { error: 'Ungültiger oder abgelaufener Link' });
|
||||
}
|
||||
cors(res, req, domainsOf(site));
|
||||
const since = clock() - CLICK_DAYS * 86400 * 1000;
|
||||
const points = getClicks.all(t.siteKey, t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
|
||||
return end(200, { points });
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from './config.js';
|
||||
import { openDb } from './db.js';
|
||||
import { createSalt } from './salt.js';
|
||||
import { loadGeo } from './geo.js';
|
||||
import { scheduleNightly } from './jobs.js';
|
||||
import { createPublicServer, createAdminServer } from './server.js';
|
||||
|
||||
const config = loadConfig(process.env);
|
||||
mkdirSync(config.dataDir, { recursive: true });
|
||||
const db = openDb(join(config.dataDir, 'analytics.db'));
|
||||
const geo = loadGeo(config.geoFile || join(config.dataDir, 'geo.csv'));
|
||||
if (geo.size) console.log(`Analytics: ${geo.size} Länder-Bereiche geladen`);
|
||||
|
||||
const publicServer = createPublicServer({ config, db, geo, salt: createSalt() });
|
||||
const servers = [
|
||||
['öffentlich', publicServer, config.port],
|
||||
['intern (Admin)', createAdminServer({ config, db, onSitesChanged: () => publicServer.invalidateSites() }), config.adminPort],
|
||||
];
|
||||
for (const [name, server, port] of servers) {
|
||||
server.on('error', (e) => { console.error(`Analytics-Listener ${name} (Port ${port}):`, e.message); process.exit(1); });
|
||||
server.listen(port, () => console.log(`Analytics-Dienst ${name} auf Port ${port}`));
|
||||
}
|
||||
|
||||
// Nachtjob: täglich 02:30 Europe/Berlin und einmal 60 s nach dem Start
|
||||
const nightly = scheduleNightly(db);
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
nightly.stop();
|
||||
let open = servers.length;
|
||||
const done = () => { if (--open === 0) { db.close(); process.exit(0); } };
|
||||
for (const [, server] of servers) { server.close(done); server.closeIdleConnections?.(); }
|
||||
setTimeout(() => process.exit(0), 5000).unref();
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
// Annahme von POST /a/e. Datenschutz-Kern: IP und User-Agent leben nur in lokalen
|
||||
// Variablen (Geo, Limit-Schlüssel, Besucher-Hash) und werden nie gespeichert oder geloggt.
|
||||
import { classifyUa } from './ua.js';
|
||||
|
||||
const MAX_BODY = 4096;
|
||||
const SITE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const HOST = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/;
|
||||
const LIMIT = 120;
|
||||
const HOUR = 3600 * 1000;
|
||||
const MINUTE = 60 * 1000;
|
||||
const FIELDS = Object.assign(Object.create(null), {
|
||||
pv: ['t', 'site', 'path', 'ref', 'vp'],
|
||||
bn: ['t', 'site', 'a'],
|
||||
en: ['t', 'site', 'path', 'vp', 'sec', 'scroll'],
|
||||
ck: ['t', 'site', 'path', 'vp', 'x', 'y', 'sel'],
|
||||
ct: ['t', 'site', 'path', 'kind', 'host'],
|
||||
});
|
||||
const SITES_TTL = 30 * 1000;
|
||||
|
||||
function tooBig() { return Object.assign(new Error('zu groß'), { status: 413 }); }
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (Number(req.headers['content-length']) > MAX_BODY) return reject(tooBig());
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
req.on('data', (c) => {
|
||||
size += c.length;
|
||||
if (size > MAX_BODY) { chunks.length = 0; reject(tooBig()); return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
// Rechtester X-Forwarded-For-Eintrag (von Traefik angehängt), sonst Socket-Adresse.
|
||||
function clientIp(req) {
|
||||
const xff = String(req.headers['x-forwarded-for'] || '').split(',').map((s) => s.trim()).filter(Boolean);
|
||||
const ip = xff.length ? xff[xff.length - 1] : (req.socket.remoteAddress || '');
|
||||
return ip.replace(/^::ffff:/i, '');
|
||||
}
|
||||
|
||||
// Nur https ohne expliziten Port zählt; alles andere ergibt '' (kein Treffer).
|
||||
export function hostOf(value) {
|
||||
if (typeof value !== 'string' || !value) return '';
|
||||
try {
|
||||
const u = new URL(value);
|
||||
return u.protocol === 'https:' && !u.port ? u.hostname.toLowerCase() : '';
|
||||
} catch { return ''; }
|
||||
}
|
||||
|
||||
// Für cleanRef: Host einer beliebigen Referrer-URL (auch http, mit Port).
|
||||
function anyHostOf(value) {
|
||||
try { return new URL(value).hostname.toLowerCase(); } catch { return ''; }
|
||||
}
|
||||
|
||||
function cleanPath(p) {
|
||||
if (typeof p !== 'string') return null;
|
||||
const s = p.split(/[?#]/)[0];
|
||||
if (!s.startsWith('/') || s.length > 300 || /[\u0000-\u001f\u007f]/.test(s)) return null;
|
||||
return s;
|
||||
}
|
||||
|
||||
function cleanHost(h) {
|
||||
if (typeof h !== 'string') return null;
|
||||
const s = h.toLowerCase();
|
||||
return s.length <= 253 && HOST.test(s) ? s : null;
|
||||
}
|
||||
|
||||
function cleanRef(ref, domains) {
|
||||
if (typeof ref !== 'string' || !ref) return null;
|
||||
const host = cleanHost(ref.includes('://') ? anyHostOf(ref) : ref);
|
||||
return host && !domains.includes(host) ? host : null;
|
||||
}
|
||||
|
||||
const cleanVp = (v) => (v === 'm' || v === 't' || v === 'd' ? v : null);
|
||||
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
||||
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
||||
|
||||
export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.now }) {
|
||||
const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?');
|
||||
const activeSites = db.prepare('SELECT domains FROM sites WHERE active = 1');
|
||||
let hostCache = null; // { at, hosts: Set } für Preflight-Prüfungen
|
||||
let hostCacheStale = true;
|
||||
const insEvent = db.prepare(`INSERT INTO events_raw (site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
const insClick = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?, ?, ?, ?, ?, ?, ?)');
|
||||
const windows = new Map(); // rateKey -> { w, n }; rateKey ist ein HMAC, keine IP
|
||||
let lastPrune = 0;
|
||||
|
||||
const originHost = (req) => hostOf(req.headers.origin) || (req.headers.origin ? '' : hostOf(req.headers.referer));
|
||||
const parseDomains = (s) => { try { return JSON.parse(s); } catch { return []; } };
|
||||
|
||||
function activeHosts() {
|
||||
const t = now();
|
||||
if (hostCacheStale || !hostCache || t - hostCache.at >= SITES_TTL || t < hostCache.at) {
|
||||
const hosts = new Set();
|
||||
for (const s of activeSites.all()) for (const d of parseDomains(s.domains)) hosts.add(d);
|
||||
hostCache = { at: t, hosts };
|
||||
hostCacheStale = false;
|
||||
}
|
||||
return hostCache.hosts;
|
||||
}
|
||||
|
||||
function limited(key) {
|
||||
const t = now();
|
||||
const w = Math.floor(t / MINUTE);
|
||||
if (w !== lastPrune) { // alte Fenster entfernen
|
||||
for (const [k, v] of windows) if (v.w !== w) windows.delete(k);
|
||||
lastPrune = w;
|
||||
}
|
||||
let e = windows.get(key);
|
||||
if (!e || e.w !== w) { e = { w, n: 0 }; windows.set(key, e); }
|
||||
return ++e.n > LIMIT;
|
||||
}
|
||||
|
||||
function cors(res, origin) {
|
||||
res.setHeader('Access-Control-Allow-Origin', origin);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
}
|
||||
|
||||
function store(ev, site, domains, ip, ua) {
|
||||
if (typeof ev.t !== 'string') return;
|
||||
const spec = FIELDS[ev.t];
|
||||
if (!spec) return;
|
||||
const e = {};
|
||||
for (const k of spec) if (k in ev) e[k] = ev[k]; // unbekannte Felder werden nie übernommen
|
||||
const t = now();
|
||||
if (e.t === 'bn') {
|
||||
if (!['shown', 'accept', 'reject'].includes(e.a)) return;
|
||||
insEvent.run(site, Math.floor(t / HOUR) * HOUR, 'bn', null, null, null, null, null, null, null, null, e.a, null);
|
||||
return;
|
||||
}
|
||||
const path = cleanPath(e.path);
|
||||
if (!path) return;
|
||||
if (e.t === 'pv') {
|
||||
const vp = cleanVp(e.vp);
|
||||
if (!vp) return;
|
||||
const c = String(geo.lookup(ip) || '');
|
||||
const country = /^[A-Za-z]{2}$/.test(c) ? c.toUpperCase() : '';
|
||||
insEvent.run(site, t, 'pv', path, vp, cleanRef(e.ref, domains), country, classifyUa(ua).device, null, null, null, null, null);
|
||||
return;
|
||||
}
|
||||
if (e.t === 'ck') {
|
||||
const vp = cleanVp(e.vp);
|
||||
const x = num(e.x);
|
||||
const y = num(e.y);
|
||||
if (!vp || x === null || y === null) return;
|
||||
const sel = typeof e.sel === 'string' ? e.sel.slice(0, 200) : null;
|
||||
insClick.run(site, t, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
|
||||
return;
|
||||
}
|
||||
const visitor = salt.visitorId(site, ip, ua);
|
||||
if (e.t === 'en') {
|
||||
const vp = cleanVp(e.vp);
|
||||
const sec = num(e.sec);
|
||||
const scroll = num(e.scroll);
|
||||
if (!vp || sec === null || scroll === null) return;
|
||||
insEvent.run(site, t, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
|
||||
return;
|
||||
}
|
||||
if (e.t === 'ct') {
|
||||
if (!['tel', 'mail', 'wa', 'out'].includes(e.kind)) return;
|
||||
let host = null;
|
||||
if (e.kind === 'out') { host = cleanHost(e.host); if (!host) return; }
|
||||
insEvent.run(site, t, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
|
||||
}
|
||||
}
|
||||
|
||||
// Liefert true, wenn die Anfrage /a/e betraf.
|
||||
async function handle(req, res, pathname) {
|
||||
if (pathname !== '/a/e') return false;
|
||||
const done = (status) => {
|
||||
if (status === 413) { // Rest des Bodys nicht abwarten: Antwort senden, danach Verbindung kappen
|
||||
res.writeHead(413, { Connection: 'close' });
|
||||
res.end(() => req.destroy());
|
||||
return true;
|
||||
}
|
||||
res.writeHead(status); res.end(); return true;
|
||||
};
|
||||
if (req.method === 'OPTIONS') {
|
||||
const host = originHost(req);
|
||||
const origin = req.headers.origin;
|
||||
if (host && origin && activeHosts().has(host)) {
|
||||
cors(res, origin);
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
res.setHeader('Access-Control-Max-Age', '86400');
|
||||
}
|
||||
return done(204);
|
||||
}
|
||||
if (req.method !== 'POST') { res.setHeader('Allow', 'POST, OPTIONS'); return done(405); }
|
||||
let raw;
|
||||
try { raw = await readBody(req); } catch (e) { return done(e.status === 413 ? 413 : 204); }
|
||||
let ev;
|
||||
try { ev = JSON.parse(raw); } catch { return done(204); }
|
||||
if (!ev || typeof ev !== 'object' || Array.isArray(ev) || typeof ev.site !== 'string' || !SITE_KEY.test(ev.site)) return done(204);
|
||||
const site = getSite.get(ev.site);
|
||||
if (!site || !site.active) return done(204);
|
||||
const domains = parseDomains(site.domains);
|
||||
const host = originHost(req);
|
||||
if (!host || !domains.includes(host)) return done(204);
|
||||
if (req.headers.origin) cors(res, req.headers.origin);
|
||||
const ua = String(req.headers['user-agent'] || '');
|
||||
if (classifyUa(ua).bot) return done(204);
|
||||
const ip = clientIp(req);
|
||||
if (limited(salt.rateKey(ev.site, ip))) return done(204);
|
||||
try { store(ev, ev.site, domains, ip, ua); } catch (e) { console.error('Analytics-Annahme:', e.message); }
|
||||
return done(204);
|
||||
}
|
||||
|
||||
handle.invalidateSites = () => { hostCacheStale = true; };
|
||||
return handle;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// Nachtjob: Rohdaten tageweise in `daily` verdichten, Fristen durchsetzen.
|
||||
// Idempotenz: agg_state(site, day) merkt sich verdichtete Tage; ein Tag wird nie zweimal gezählt.
|
||||
// Rohzeilen bleiben bis zur 30-Tage-Löschung bestehen (Statistik für heute/gestern liest sie live).
|
||||
const DAY_FMT = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
const WALL_FMT = new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: 'Europe/Berlin', hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
||||
});
|
||||
const DAY_MS = 86400 * 1000;
|
||||
export const RAW_DAYS = 30;
|
||||
export const OFF_DAYS = 60;
|
||||
|
||||
// Kalendertag (YYYY-MM-DD) in Europe/Berlin.
|
||||
export const dayOf = (ts) => DAY_FMT.format(ts);
|
||||
|
||||
// Verdichtet Rohzeilen EINES Tages zu [dim, key, count]-Tripeln.
|
||||
export function aggregateRows(rows) {
|
||||
const m = new Map();
|
||||
const add = (dim, key, n = 1) => { const k = `${dim}\u0000${key}`; m.set(k, (m.get(k) || 0) + n); };
|
||||
const visitors = new Set();
|
||||
for (const r of rows) {
|
||||
if (r.type === 'pv') {
|
||||
add('pv_total', ''); add('pv_path', r.path || ''); add('pv_ref', r.ref || '');
|
||||
add('pv_country', r.country || ''); add('pv_device', r.device || '');
|
||||
} else if (r.type === 'en') {
|
||||
add('en_count', ''); add('en_sec_sum', '', r.sec || 0); add('scroll_sum', '', r.scroll || 0);
|
||||
if (r.visitor) visitors.add(r.visitor);
|
||||
} else if (r.type === 'ct') {
|
||||
add('ct_kind', r.kind || '');
|
||||
if (r.visitor) visitors.add(r.visitor);
|
||||
} else if (r.type === 'bn' && ['shown', 'accept', 'reject'].includes(r.kind)) {
|
||||
add(`bn_${r.kind}`, '');
|
||||
}
|
||||
}
|
||||
if (visitors.size) add('visitors', '', visitors.size);
|
||||
return [...m].filter(([, n]) => n > 0).map(([k, n]) => { const [dim, key] = k.split('\u0000'); return [dim, key, n]; });
|
||||
}
|
||||
|
||||
export function runNightly(db, { now = Date.now() } = {}) {
|
||||
const today = dayOf(now);
|
||||
const rawCut = now - RAW_DAYS * DAY_MS;
|
||||
const offCut = new Date(now - OFF_DAYS * DAY_MS).toISOString();
|
||||
const siteKeys = db.prepare(`SELECT DISTINCT site FROM events_raw UNION SELECT siteKey FROM sites`).all().map((r) => r.site);
|
||||
const selRaw = db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts < ? ORDER BY ts');
|
||||
const selDone = db.prepare('SELECT day FROM agg_state WHERE site = ?');
|
||||
const insDaily = db.prepare(`INSERT INTO daily (site, day, dim, key, count) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(site, day, dim, key) DO UPDATE SET count = count + excluded.count`);
|
||||
const insDone = db.prepare('INSERT OR IGNORE INTO agg_state (site, day) VALUES (?, ?)');
|
||||
const stats = { aggregated: 0, sitesDeleted: 0 };
|
||||
|
||||
const tx = (fn) => {
|
||||
db.exec('BEGIN');
|
||||
try { fn(); db.exec('COMMIT'); } catch (e) { db.exec('ROLLBACK'); throw e; }
|
||||
};
|
||||
|
||||
for (const site of siteKeys) {
|
||||
tx(() => {
|
||||
const done = new Set(selDone.all(site).map((r) => r.day));
|
||||
const byDay = new Map();
|
||||
for (const r of selRaw.all(site, now)) {
|
||||
const d = dayOf(r.ts);
|
||||
if (d >= today || done.has(d)) continue;
|
||||
if (!byDay.has(d)) byDay.set(d, []);
|
||||
byDay.get(d).push(r);
|
||||
}
|
||||
for (const [day, rows] of byDay) {
|
||||
for (const [dim, key, n] of aggregateRows(rows)) insDaily.run(site, day, dim, key, n);
|
||||
insDone.run(site, day);
|
||||
stats.aggregated++;
|
||||
}
|
||||
// Fristen erst NACH der Verdichtung (Rohdaten und Klicks > 30 Tage)
|
||||
db.prepare('DELETE FROM events_raw WHERE site = ? AND ts < ?').run(site, rawCut);
|
||||
db.prepare('DELETE FROM clicks WHERE site = ? AND ts < ?').run(site, rawCut);
|
||||
});
|
||||
}
|
||||
// Klicks ganz ohne Website-Zeile / Ereignisse
|
||||
db.prepare('DELETE FROM clicks WHERE ts < ?').run(rawCut);
|
||||
|
||||
for (const { siteKey } of db.prepare('SELECT siteKey FROM sites WHERE offSince IS NOT NULL AND offSince < ?').all(offCut)) {
|
||||
tx(() => {
|
||||
for (const t of ['events_raw', 'clicks', 'daily', 'agg_state']) db.prepare(`DELETE FROM ${t} WHERE site = ?`).run(siteKey);
|
||||
db.prepare('DELETE FROM sites WHERE siteKey = ?').run(siteKey);
|
||||
stats.sitesDeleted++;
|
||||
});
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
// Offset (ms) von Europe/Berlin gegenüber UTC zum Zeitpunkt ts.
|
||||
function berlinOffset(ts) {
|
||||
const p = Object.fromEntries(WALL_FMT.formatToParts(ts).map((x) => [x.type, x.value]));
|
||||
return Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second) - Math.floor(ts / 1000) * 1000;
|
||||
}
|
||||
|
||||
// Nächster Lauf um hour:minute Europe/Berlin (strikt nach now), als UTC-ms.
|
||||
export function nextRunAt(now, hour = 2, minute = 30) {
|
||||
const [y, m, d] = dayOf(now).split('-').map(Number);
|
||||
const at = (dd) => {
|
||||
const guess = Date.UTC(y, m - 1, dd, hour, minute);
|
||||
return guess - berlinOffset(guess - berlinOffset(guess));
|
||||
};
|
||||
const t = at(d);
|
||||
return t > now ? t : at(d + 1);
|
||||
}
|
||||
|
||||
// Plant runNightly täglich 02:30 Berlin und einmal kurz nach dem Start; stop() beendet alle Timer.
|
||||
// Test-Hooks: now, timers ({ setTimeout, clearTimeout }) und job (statt runNightly).
|
||||
export function scheduleNightly(db, { startDelayMs = 60000, log = console, now = Date.now, timers = { setTimeout, clearTimeout }, job = runNightly } = {}) {
|
||||
let timer = null;
|
||||
let stopped = false;
|
||||
const run = () => {
|
||||
try {
|
||||
const r = job(db, { now: now() });
|
||||
log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht`);
|
||||
} catch (e) { log.error('Analytics-Nachtjob:', e.message); }
|
||||
};
|
||||
const arm = (delay, fn) => { if (!stopped) timer = timers.setTimeout(fn, delay); };
|
||||
// Ein Timer kann früh feuern (Uhrsprung, Timer-Ungenauigkeit): dann nur neu auf den Zielzeitpunkt setzen,
|
||||
// nie vorzeitig laufen. Nach dem Lauf strikt hinter das Ziel planen, damit 02:30 nie doppelt läuft.
|
||||
const planDaily = (target) => arm(Math.max(0, target - now()), () => {
|
||||
if (now() < target) return planDaily(target);
|
||||
run();
|
||||
planDaily(nextRunAt(Math.max(now(), target) + 1000));
|
||||
});
|
||||
arm(startDelayMs, () => { run(); planDaily(nextRunAt(now())); });
|
||||
return { stop: () => { stopped = true; timers.clearTimeout(timer); } };
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const DAY = new Intl.DateTimeFormat('en-CA', { timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit' });
|
||||
|
||||
// Tagessalz nur im RAM; neu erzeugt, sobald sich der Kalendertag in Europe/Berlin ändert.
|
||||
export function createSalt({ now = Date.now, random = () => crypto.randomBytes(32) } = {}) {
|
||||
let day = null;
|
||||
let salt = null;
|
||||
const current = () => {
|
||||
const d = DAY.format(now());
|
||||
if (d !== day) { day = d; salt = random(); }
|
||||
return salt;
|
||||
};
|
||||
const hmac = (s) => crypto.createHmac('sha256', current()).update(s).digest('hex');
|
||||
return {
|
||||
visitorId: (site, ip, ua) => hmac(`${site}|${ip}|${ua}`).slice(0, 16),
|
||||
rateKey: (site, ip) => hmac(`${site}|${ip}`),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import http from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import { createIngest } from './ingest.js';
|
||||
import { createSalt } from './salt.js';
|
||||
import { createHeat } from './heat.js';
|
||||
import { computeStats } from './stats.js';
|
||||
import { createAssets } from './assets.js';
|
||||
|
||||
const MAX_ADMIN_BODY = 64 * 1024;
|
||||
const SITE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/;
|
||||
|
||||
function tokenOk(header, token) {
|
||||
if (!token) return false;
|
||||
const m = /^Bearer (.+)$/.exec(header || '');
|
||||
if (!m) return false;
|
||||
const a = crypto.createHash('sha256').update(m[1]).digest();
|
||||
const b = crypto.createHash('sha256').update(token).digest();
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function send(res, status, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function readJson(req, max) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
let size = 0;
|
||||
let tooBig = false;
|
||||
req.on('data', (c) => {
|
||||
if (tooBig) return; // Rest verwerfen, damit die Antwort noch ausgeliefert wird
|
||||
size += c.length;
|
||||
if (size > max) { tooBig = true; chunks.length = 0; return; }
|
||||
chunks.push(c);
|
||||
});
|
||||
req.on('end', () => {
|
||||
if (tooBig) return reject(Object.assign(new Error('Anfrage zu groß'), { status: 413 }));
|
||||
const raw = Buffer.concat(chunks).toString('utf8');
|
||||
if (!raw) return resolve({});
|
||||
try { resolve(JSON.parse(raw)); } catch { reject(Object.assign(new Error('Ungültiges JSON'), { status: 400 })); }
|
||||
});
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
function validateSite(b) {
|
||||
if (!b || typeof b !== 'object' || Array.isArray(b)) return { error: 'Ungültige Anfrage' };
|
||||
if (typeof b.projectId !== 'string' || !b.projectId || b.projectId.length > 100) return { error: 'Ungültige Projekt-ID' };
|
||||
if (!Array.isArray(b.domains) || b.domains.length < 1 || b.domains.length > 5
|
||||
|| !b.domains.every((d) => typeof d === 'string' && d.length <= 253 && HOSTNAME.test(d))) {
|
||||
return { error: 'Ungültige Domains (1 bis 5 Hostnamen, klein geschrieben, ohne Port und Pfad)' };
|
||||
}
|
||||
if (new Set(b.domains).size !== b.domains.length) return { error: 'Doppelte Domains' };
|
||||
if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' };
|
||||
return { site: { projectId: b.projectId, domains: b.domains, active: b.active } };
|
||||
}
|
||||
|
||||
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
|
||||
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
|
||||
const heat = role === 'public' ? createHeat({ db, config, now }) : null;
|
||||
const assets = role === 'public' ? createAssets({ distDir: config.distDir }) : null;
|
||||
const clock = now || Date.now;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const method = req.method;
|
||||
|
||||
if (ingest && await ingest(req, res, url.pathname)) return;
|
||||
if (heat && heat(req, res, url)) return;
|
||||
if (assets && assets(req, res, url.pathname)) return;
|
||||
|
||||
if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true });
|
||||
|
||||
if (role === 'admin' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4
|
||||
&& (method === 'PUT' || method === 'DELETE')) {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
||||
|
||||
if (method === 'DELETE') {
|
||||
db.exec('BEGIN');
|
||||
try {
|
||||
db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key);
|
||||
db.prepare('DELETE FROM events_raw WHERE site = ?').run(key);
|
||||
db.prepare('DELETE FROM clicks WHERE site = ?').run(key);
|
||||
db.prepare('DELETE FROM daily WHERE site = ?').run(key);
|
||||
db.prepare('DELETE FROM agg_state WHERE site = ?').run(key);
|
||||
db.exec('COMMIT');
|
||||
} catch (e) {
|
||||
db.exec('ROLLBACK');
|
||||
throw e;
|
||||
}
|
||||
onSitesChanged?.();
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
const v = validateSite(await readJson(req, MAX_ADMIN_BODY));
|
||||
if (v.error) return send(res, 400, { error: v.error });
|
||||
const { projectId, domains, active } = v.site;
|
||||
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
|
||||
const offSince = active ? null : (old && old.offSince) || new Date().toISOString();
|
||||
db.prepare(`INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(siteKey) DO UPDATE SET projectId = excluded.projectId, domains = excluded.domains,
|
||||
active = excluded.active, offSince = excluded.offSince`)
|
||||
.run(key, projectId, JSON.stringify(domains), active ? 1 : 0, offSince);
|
||||
onSitesChanged?.();
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
||||
const r = url.searchParams.get('range') ?? '30';
|
||||
if (!['7', '30', '90'].includes(r)) return send(res, 400, { error: 'Ungültiger Zeitraum (7, 30 oder 90)' });
|
||||
if (!db.prepare('SELECT 1 FROM sites WHERE siteKey = ?').get(key)) return send(res, 404, { error: 'Website nicht gefunden' });
|
||||
return send(res, 200, computeStats(db, key, Number(r), clock()));
|
||||
}
|
||||
return send(res, 404, { error: 'Nicht gefunden' });
|
||||
} catch (e) {
|
||||
const status = e.status || 500;
|
||||
if (status >= 500) console.error('Analytics-Fehler:', e);
|
||||
if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message });
|
||||
}
|
||||
});
|
||||
server.invalidateSites = ingest ? ingest.invalidateSites : () => {};
|
||||
return server;
|
||||
}
|
||||
|
||||
// Öffentlicher Listener: /a/health, /a/e (Erfassung), /a/heat, /a/w.js und /a/h.js; Admin-Routen gibt es hier nicht.
|
||||
export const createPublicServer = ({ config, db, geo, salt, now }) => createHandlerServer({ config, db, role: 'public', geo, salt, now });
|
||||
// Interner Listener für die Website-Verwaltung.
|
||||
// onSitesChanged: wird nach jeder Änderung an Websites gerufen (public.invalidateSites()).
|
||||
export const createAdminServer = ({ config, db, onSitesChanged, now }) => createHandlerServer({ config, db, role: 'admin', onSitesChanged, now });
|
||||
@@ -0,0 +1,69 @@
|
||||
// Statistik für das Portal: Vergangenheit aus `daily`, noch nicht verdichtete Tage live aus den Rohdaten.
|
||||
import { dayOf, aggregateRows } from './jobs.js';
|
||||
|
||||
const DAY_MS = 86400 * 1000;
|
||||
const TOP = 20;
|
||||
|
||||
// Liste der Kalendertage (YYYY-MM-DD), oldest first, endend am Berlin-Tag von `now`.
|
||||
function dayList(now, range) {
|
||||
const [y, m, d] = dayOf(now).split('-').map(Number);
|
||||
const days = [];
|
||||
for (let i = range - 1; i >= 0; i--) days.push(new Date(Date.UTC(y, m - 1, d - i)).toISOString().slice(0, 10));
|
||||
return days;
|
||||
}
|
||||
|
||||
const top = (map) => [...map].map(([key, count]) => ({ key, count })).filter((e) => e.count > 0)
|
||||
.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : 1)).slice(0, TOP);
|
||||
|
||||
export function computeStats(db, site, range, now = Date.now()) {
|
||||
const days = dayList(now, range);
|
||||
const first = days[0];
|
||||
const inRange = new Set(days);
|
||||
const pvByDay = new Map(days.map((d) => [d, 0]));
|
||||
const dims = {};
|
||||
const bump = (dim, key, n) => {
|
||||
if (!dims[dim]) dims[dim] = new Map();
|
||||
dims[dim].set(key, (dims[dim].get(key) || 0) + n);
|
||||
};
|
||||
const apply = (day, dim, key, n) => {
|
||||
if (dim === 'pv_total') pvByDay.set(day, pvByDay.get(day) + n);
|
||||
bump(dim, key, n);
|
||||
};
|
||||
|
||||
const done = new Set(db.prepare('SELECT day FROM agg_state WHERE site = ? AND day >= ?').all(site, first).map((r) => r.day));
|
||||
for (const r of db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? AND day >= ?').all(site, first)) {
|
||||
if (inRange.has(r.day)) apply(r.day, r.dim, r.key, r.count);
|
||||
}
|
||||
// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten
|
||||
const live = new Map();
|
||||
const from = now - (range + 1) * DAY_MS;
|
||||
for (const r of db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
|
||||
.all(site, from, now)) {
|
||||
const d = dayOf(r.ts);
|
||||
if (!inRange.has(d) || done.has(d)) continue;
|
||||
if (!live.has(d)) live.set(d, []);
|
||||
live.get(d).push(r);
|
||||
}
|
||||
for (const [day, rows] of live) for (const [dim, key, n] of aggregateRows(rows)) apply(day, dim, key, n);
|
||||
|
||||
const sum = (dim) => [...(dims[dim] || new Map()).values()].reduce((a, b) => a + b, 0);
|
||||
const banner = { shown: sum('bn_shown'), accept: sum('bn_accept'), reject: sum('bn_reject') };
|
||||
const decided = banner.accept + banner.reject;
|
||||
const contacts = top(dims.ct_kind || new Map());
|
||||
return {
|
||||
totals: {
|
||||
pv: sum('pv_total'),
|
||||
// Summe der Tages-Besucher (kein tagesübergreifender Abgleich möglich: Salz wechselt täglich)
|
||||
visitors: sum('visitors'),
|
||||
contacts: sum('ct_kind'),
|
||||
consentRate: decided ? banner.accept / decided : 0,
|
||||
},
|
||||
series: days.map((day) => ({ day, pv: pvByDay.get(day) })),
|
||||
pages: top(dims.pv_path || new Map()),
|
||||
refs: top(dims.pv_ref || new Map()),
|
||||
countries: top(dims.pv_country || new Map()),
|
||||
devices: top(dims.pv_device || new Map()),
|
||||
contacts,
|
||||
banner,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Gröbste Bot- und Gerätetyp-Erkennung; der User-Agent wird danach verworfen.
|
||||
const BOT = /bot|crawl|spider|slurp|headless|lighthouse|pingdom|uptime|curl|wget|python|go-http|java\/|facebookexternalhit|preview/i;
|
||||
|
||||
export function classifyUa(ua) {
|
||||
const s = typeof ua === 'string' ? ua : '';
|
||||
if (!s || BOT.test(s)) return { bot: true, device: 'd' };
|
||||
if (/Mobi|Android.*Mobile|iPhone/i.test(s)) return { bot: false, device: 'm' };
|
||||
if (/iPad|Tablet|Android(?!.*Mobile)/i.test(s)) return { bot: false, device: 't' };
|
||||
return { bot: false, device: 'd' };
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import { readFileSync, mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { gunzipSync } from 'node:zlib';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||
|
||||
const DIST = new URL('../client/dist/', import.meta.url);
|
||||
|
||||
async function start(config = {}) {
|
||||
const server = createPublicServer({ config, db: openDb(':memory:') });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
|
||||
}
|
||||
|
||||
// fetch dekodiert gzip selbst; für den rohen Body http.request verwenden.
|
||||
function raw(base, path, headers = {}, method = 'GET') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(base + path, { method, headers }, (res) => {
|
||||
const chunks = [];
|
||||
res.on('data', (c) => chunks.push(c));
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
for (const file of ['w.js', 'h.js']) {
|
||||
test(`/a/${file}: Header, identity und gzip liefern den dist-Inhalt`, async () => {
|
||||
const s = await start();
|
||||
const want = readFileSync(new URL(file, DIST));
|
||||
const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`;
|
||||
const id = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'identity' });
|
||||
assert.equal(id.status, 200);
|
||||
assert.deepEqual(id.body, want);
|
||||
assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(id.headers['cache-control'], 'public, max-age=3600');
|
||||
assert.equal(id.headers.etag, etag);
|
||||
assert.equal(id.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(id.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin');
|
||||
assert.equal(id.headers['access-control-allow-origin'], '*');
|
||||
assert.equal(id.headers['content-encoding'], undefined);
|
||||
const gz = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'gzip, deflate' });
|
||||
assert.equal(gz.status, 200);
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(gz.headers.etag, etag);
|
||||
assert.ok(gz.body.length < want.length);
|
||||
assert.deepEqual(gunzipSync(gz.body), want);
|
||||
s.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => {
|
||||
const s = await start();
|
||||
const first = await raw(s.base, '/a/w.js');
|
||||
const hit = await raw(s.base, '/a/w.js', { 'If-None-Match': first.headers.etag });
|
||||
assert.equal(hit.status, 304);
|
||||
assert.equal(hit.body.length, 0);
|
||||
assert.equal(hit.headers.etag, first.headers.etag);
|
||||
assert.equal(hit.headers['cache-control'], 'public, max-age=3600');
|
||||
assert.equal((await raw(s.base, '/a/w.js', { 'If-None-Match': '"anders"' })).status, 200);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('HEAD: Header wie GET, kein Body', async () => {
|
||||
const s = await start();
|
||||
const get = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' });
|
||||
const head = await raw(s.base, '/a/h.js', { 'Accept-Encoding': 'identity' }, 'HEAD');
|
||||
assert.equal(head.status, 200);
|
||||
assert.equal(head.body.length, 0);
|
||||
assert.equal(head.headers['content-length'], String(get.body.length));
|
||||
assert.equal(head.headers.etag, get.headers.etag);
|
||||
assert.equal((await raw(s.base, '/a/h.js', {}, 'POST')).status, 405);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('Admin-Listener liefert die Skripte nicht', async () => {
|
||||
const server = createAdminServer({ config: { adminToken: 't' }, db: openDb(':memory:') });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
assert.equal((await fetch(`${base}/a/w.js`)).status, 404);
|
||||
assert.equal((await fetch(`${base}/a/h.js`)).status, 404);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'dist-'));
|
||||
writeFileSync(join(dir, 'w.js'), 'console.log(1)');
|
||||
const orig = console.error;
|
||||
const logged = [];
|
||||
console.error = (...a) => logged.push(a.join(' '));
|
||||
let s;
|
||||
try { s = await start({ distDir: dir }); } finally { console.error = orig; }
|
||||
assert.ok(logged.some((l) => l.includes('h.js') && l.includes('fehlt')));
|
||||
assert.equal((await fetch(`${s.base}/a/w.js`)).status, 200);
|
||||
assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503);
|
||||
s.close();
|
||||
});
|
||||
@@ -0,0 +1,433 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import vm from 'node:vm';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
import {
|
||||
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
|
||||
} from '../client/core.js';
|
||||
import { buildAll } from '../client/build.js';
|
||||
|
||||
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
|
||||
|
||||
// ---------- core ----------
|
||||
test('vpClass', () => {
|
||||
assert.equal(vpClass(0), 'm');
|
||||
assert.equal(vpClass(767), 'm');
|
||||
assert.equal(vpClass(768), 't');
|
||||
assert.equal(vpClass(1023), 't');
|
||||
assert.equal(vpClass(1024), 'd');
|
||||
assert.equal(vpClass(NaN), 'd');
|
||||
});
|
||||
|
||||
test('cleanPath', () => {
|
||||
assert.equal(cleanPath('https://x.de/a/b?q=1#h'), '/a/b');
|
||||
assert.equal(cleanPath('/über%20uns?x=1'), '/über uns');
|
||||
assert.equal(cleanPath('/%E0%A4%A'), '/%E0%A4%A'); // kaputtes Encoding bleibt roh
|
||||
assert.equal(cleanPath('https://x.de'), '/');
|
||||
assert.equal(cleanPath(''), '/');
|
||||
assert.equal(cleanPath('/' + 'a'.repeat(400)).length, 300);
|
||||
assert.equal(cleanPath(null), '/');
|
||||
assert.equal(cleanPath('//foo/bar?x=1'), '//foo/bar');
|
||||
assert.equal(cleanPath('//foo'), '//foo');
|
||||
});
|
||||
|
||||
test('refHost', () => {
|
||||
assert.equal(refHost('https://www.google.com/search?q=x', 'mein.de'), 'www.google.com');
|
||||
assert.equal(refHost('https://mein.de/a', 'mein.de'), '');
|
||||
assert.equal(refHost('https://www.mein.de/a', 'mein.de'), '');
|
||||
assert.equal(refHost('', 'mein.de'), '');
|
||||
assert.equal(refHost('kein url', 'mein.de'), '');
|
||||
assert.equal(refHost('android-app://com.x', 'mein.de'), '');
|
||||
assert.equal(refHost('HTTPS://Foo.DE/x', 'mein.de'), 'foo.de');
|
||||
});
|
||||
|
||||
function el(tag, o = {}) {
|
||||
const e = { tagName: tag.toUpperCase(), id: o.id || '', parentNode: o.parent || null, previousElementSibling: o.prev || null,
|
||||
attrs: { class: o.cls || '' }, getAttribute(n) { return this.attrs[n] ?? null; } };
|
||||
return e;
|
||||
}
|
||||
|
||||
test('cssPath: nur Tags + nth-of-type, keine IDs/Klassen', () => {
|
||||
const body = el('body');
|
||||
const div = el('div', { parent: body, cls: 'hero main' });
|
||||
const p1 = el('p', { parent: div });
|
||||
const btn0 = el('button', { parent: div, prev: p1 });
|
||||
const btn = el('button', { parent: div, prev: btn0, cls: 'cta' });
|
||||
assert.equal(cssPath(btn), 'body:nth-of-type(1) > div:nth-of-type(1) > button:nth-of-type(2)');
|
||||
const pii = el('a', { parent: body, id: 'max@firma.de', cls: 'team-max-mustermann' });
|
||||
const sel = cssPath(pii);
|
||||
assert.equal(sel, 'body:nth-of-type(1) > a:nth-of-type(1)');
|
||||
assert.ok(!/max|firma|mustermann|#|\./.test(sel));
|
||||
// max. 6 Ebenen
|
||||
let n = el('div');
|
||||
for (let i = 0; i < 9; i++) n = el('span', { parent: n });
|
||||
assert.equal(cssPath(n).split(' > ').length, 6);
|
||||
// stoppt bei body
|
||||
const outer = el('html'); body.parentNode = outer;
|
||||
assert.equal(cssPath(div).split(' > ')[0], 'body:nth-of-type(1)');
|
||||
// <= 200
|
||||
let deep = el('div');
|
||||
for (let i = 0; i < 5; i++) deep = el('x'.repeat(40), { parent: deep });
|
||||
assert.ok(cssPath(deep).length <= 200);
|
||||
assert.equal(cssPath(null), '');
|
||||
});
|
||||
|
||||
test('contactKind', () => {
|
||||
const k = (href) => contactKind({ href }, 'mein.de');
|
||||
assert.equal(k('tel:+49123'), 'tel');
|
||||
assert.equal(k('TEL:+49123'), 'tel');
|
||||
assert.equal(k('mailto:a@b.de'), 'mail');
|
||||
assert.equal(k('https://wa.me/49123'), 'wa');
|
||||
assert.equal(k('https://api.whatsapp.com/send?phone=1'), 'wa');
|
||||
assert.equal(k('whatsapp://send?text=x'), 'wa');
|
||||
assert.equal(k('https://anders.de/x'), 'out');
|
||||
assert.equal(k('https://mein.de/x'), null);
|
||||
assert.equal(k('https://www.mein.de/x'), null);
|
||||
assert.equal(k('/kontakt'), null);
|
||||
assert.equal(k('#top'), null);
|
||||
assert.equal(k('javascript:void(0)'), null);
|
||||
assert.equal(k(''), null);
|
||||
assert.equal(contactKind(null, 'mein.de'), null);
|
||||
});
|
||||
|
||||
test('readConsent', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const day = 86400000;
|
||||
const st = (v) => ({ getItem: () => v });
|
||||
assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day });
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject');
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null);
|
||||
assert.equal(readConsent(st('kaputt{'), now), null);
|
||||
assert.equal(readConsent(st(null), now), null);
|
||||
assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
|
||||
assert.equal(readConsent(null, now), null);
|
||||
});
|
||||
|
||||
test('contrastOk', () => {
|
||||
assert.equal(contrastOk('#111111'), true);
|
||||
assert.equal(contrastOk('#ffffff'), false);
|
||||
assert.equal(contrastOk('#ffff00'), false);
|
||||
assert.equal(contrastOk('#767676'), true); // 4.54
|
||||
assert.equal(contrastOk('#777777'), false); // 4.48
|
||||
assert.equal(contrastOk('#00F'), false); // ungueltig
|
||||
assert.equal(contrastOk(undefined), false);
|
||||
});
|
||||
|
||||
test('escapeHtml', () => {
|
||||
assert.equal(escapeHtml(`<a href="x">&'</a>`), '<a href="x">&'</a>');
|
||||
assert.equal(escapeHtml(null), '');
|
||||
});
|
||||
|
||||
// ---------- dist ----------
|
||||
test('dist ist aktuell', () => {
|
||||
const b = buildAll();
|
||||
assert.equal(readFileSync(join(DIST, 'w.js'), 'utf8'), b.w);
|
||||
assert.equal(readFileSync(join(DIST, 'h.js'), 'utf8'), b.h);
|
||||
assert.ok(!/^\s*(import|export)\s/m.test(b.w + b.h));
|
||||
});
|
||||
|
||||
// ---------- Verhalten von w.js (Fake-DOM) ----------
|
||||
function makeEl(tag) {
|
||||
const e = {
|
||||
tagName: String(tag).toUpperCase(), attrs: {}, children: [], listeners: {}, style: {}, parentNode: null, id: '',
|
||||
textContent: '', focused: false, removed: false,
|
||||
setAttribute(n, v) { this.attrs[n] = String(v); if (n === 'id') this.id = String(v); },
|
||||
getAttribute(n) { return n in this.attrs ? this.attrs[n] : null; },
|
||||
appendChild(c) { c.parentNode = this; this.children.push(c); return c; },
|
||||
append(...cs) { cs.forEach((c) => this.appendChild(c)); },
|
||||
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
|
||||
focus() { this.focused = true; },
|
||||
remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
|
||||
closest(sel) {
|
||||
const attr = /^\[([\w-]+)\]$/.exec(sel);
|
||||
for (let n = this; n; n = n.parentNode) if (attr && n.attrs && attr[1] in n.attrs) return n;
|
||||
return null;
|
||||
},
|
||||
attachShadow() { const r = makeEl('shadow'); r.isShadow = true; this.shadow = r; return r; },
|
||||
click(extra = {}) { fire(this, 'click', { target: this, ...extra }); },
|
||||
};
|
||||
Object.defineProperty(e, 'href', { get() { return this.attrs.href || ''; } });
|
||||
Object.defineProperty(e, 'className', { set(v) { this.attrs.class = v; }, get() { return this.attrs.class || ''; } });
|
||||
return e;
|
||||
}
|
||||
function fire(node, type, ev) { for (const f of [...(node.listeners[type] || [])]) f({ type, preventDefault() {}, ...ev }); }
|
||||
function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if (n.shadow) walk(n.shadow, fn); }
|
||||
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
|
||||
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
|
||||
|
||||
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) {
|
||||
const sent = []; const store = {}; let now = 1_700_000_000_000;
|
||||
if (stored) store['wk-consent'] = JSON.stringify(stored);
|
||||
const body = makeEl('body'); const html = makeEl('html');
|
||||
const timers = [];
|
||||
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
|
||||
const docL = {}; const winL = {};
|
||||
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
|
||||
const injected = [];
|
||||
if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
|
||||
const document = {
|
||||
currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible',
|
||||
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
|
||||
createElement: makeEl, activeElement: null,
|
||||
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
|
||||
addEventListener(t, f) { (docL[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
|
||||
};
|
||||
body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
|
||||
document.head.appendChild = body.appendChild;
|
||||
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
|
||||
const win = {
|
||||
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
|
||||
location: { hostname: 'mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
|
||||
addEventListener(t, f) { (winL[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
|
||||
};
|
||||
const sandbox = {
|
||||
window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
|
||||
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } },
|
||||
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
|
||||
setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
|
||||
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(),
|
||||
};
|
||||
win.localStorage = sandbox.localStorage;
|
||||
sandbox.globalThis = sandbox;
|
||||
vm.createContext(sandbox);
|
||||
vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
|
||||
const types = () => sent.map((s) => s.d.t + (s.d.a ? ':' + s.d.a : ''));
|
||||
const ck = (target, ev = {}) => { for (const f of [...(docL.click || [])]) f({ type: 'click', target, pageX: 500, pageY: 1000, clientX: 500, clientY: 700, preventDefault() {}, ...ev }); };
|
||||
const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
|
||||
const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
|
||||
const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' }));
|
||||
return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
|
||||
}
|
||||
const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
|
||||
const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
|
||||
|
||||
test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
|
||||
assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' });
|
||||
assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' });
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
});
|
||||
|
||||
test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
|
||||
const h = harness();
|
||||
const host = hostEl(h);
|
||||
assert.ok(host, 'Banner im Shadow Root');
|
||||
const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
|
||||
assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen');
|
||||
const all = findAll(host.shadow, () => true);
|
||||
const text = all.map((n) => n.textContent).join(' ');
|
||||
assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/);
|
||||
const a = all.find((n) => n.tagName === 'A');
|
||||
assert.equal(a.attrs.href, '/datenschutz-analyse.html');
|
||||
assert.equal(a.textContent, 'Datenschutz');
|
||||
const css = all.filter((n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
|
||||
assert.match(css, /#0a5a8a/);
|
||||
assert.match(css, /max-width:\s*767px/);
|
||||
const buttons = all.filter((n) => n.tagName === 'BUTTON');
|
||||
assert.deepEqual(buttons.map((b) => b.textContent), ['Ablehnen', 'Annehmen']);
|
||||
assert.ok(!buttons[0].focused, 'kein Fokusraub beim Laden');
|
||||
assert.equal(buttons[0].attrs.class, buttons[1].attrs.class);
|
||||
assert.ok(!/button\.|\.p\b/.test(css), 'eine Regel fuer beide Buttons');
|
||||
assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/);
|
||||
assert.match(css, /button\{[^}]*[; ]width:150px/);
|
||||
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
|
||||
assert.ok(!/min-width/.test(css));
|
||||
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8');
|
||||
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
|
||||
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
|
||||
assert.equal(host.style.zIndex, '2147483647');
|
||||
// Escape schliesst nicht
|
||||
fire(dlg, 'keydown', { key: 'Escape' }); fire(host, 'keydown', { key: 'Escape' });
|
||||
assert.ok(!host.removed);
|
||||
});
|
||||
|
||||
test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort', () => {
|
||||
const h = harness();
|
||||
const bh = hostEl(h);
|
||||
btn(bh.shadow, 'Annehmen').click();
|
||||
assert.equal(h.types().at(-1), 'bn:accept');
|
||||
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 });
|
||||
assert.ok(bh.removed && !hostEl(h));
|
||||
const t = target();
|
||||
h.ck(t);
|
||||
const ck = h.sent.at(-1).d;
|
||||
assert.deepEqual(ck, { t: 'ck', site: 'abcdef0123456789abcd', path: '/start', vp: 'd', x: 0.5, y: 0.5, sel: 'body:nth-of-type(1) > button:nth-of-type(1)' });
|
||||
// Kontakt-Link: ck + ct (Kind-Element in <a>)
|
||||
const a = makeEl('a'); a.setAttribute('href', 'tel:+4912'); a.parentNode = makeEl('body');
|
||||
const span = makeEl('span'); span.parentNode = a;
|
||||
const n = h.sent.length; h.ck(span);
|
||||
assert.deepEqual(h.sent.slice(n).map((s) => s.d.t), ['ck', 'ct']);
|
||||
assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'tel' });
|
||||
const out = makeEl('a'); out.setAttribute('href', 'https://anders.de/x'); out.parentNode = makeEl('body');
|
||||
h.ck(out);
|
||||
assert.deepEqual(h.sent.at(-1).d, { t: 'ct', site: 'abcdef0123456789abcd', path: '/start', kind: 'out', host: 'anders.de' });
|
||||
// en einmal
|
||||
h.leave(); h.leave();
|
||||
const en = h.sent.filter((s) => s.d.t === 'en');
|
||||
assert.equal(en.length, 1);
|
||||
assert.equal(en[0].d.scroll, 55); // (300+800)/2000
|
||||
assert.equal(typeof en[0].d.sec, 'number');
|
||||
// Widerruf ueber Einstellungs-Link
|
||||
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0];
|
||||
assert.ok(settings, 'fester Link');
|
||||
settings.click();
|
||||
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
|
||||
btn(hostEl(h).shadow, 'Ablehnen').click();
|
||||
assert.equal(h.types().at(-1), 'bn:reject');
|
||||
const before = h.sent.length;
|
||||
h.ck(target()); h.leave();
|
||||
assert.equal(h.sent.length, before);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
assert.equal((h.docL.visibilitychange || []).length, 0);
|
||||
assert.equal((h.winL.pagehide || []).length, 0);
|
||||
});
|
||||
|
||||
test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.at(-1).d.t, 'ck');
|
||||
});
|
||||
|
||||
test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } });
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } });
|
||||
assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
|
||||
});
|
||||
|
||||
test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => {
|
||||
for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) {
|
||||
const h = harness(o);
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
}
|
||||
});
|
||||
|
||||
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true });
|
||||
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
h.link.click();
|
||||
assert.ok(hostEl(h));
|
||||
});
|
||||
|
||||
test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () => {
|
||||
const h = harness({ search: '?a=1&__wk_heat=TOKEN.sig' });
|
||||
assert.deepEqual(h.sent, []);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
assert.equal(h.history.replaced, '/start?a=1');
|
||||
assert.equal(h.injected.length, 1);
|
||||
assert.equal(h.injected[0].attrs.src, 'https://project.webklar.com/a/h.js');
|
||||
assert.equal(h.injected[0].attrs['data-token'], 'TOKEN.sig');
|
||||
assert.equal((h.docL.click || []).length, 0);
|
||||
});
|
||||
|
||||
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
|
||||
const h = harness({ gpc: true });
|
||||
h.win.location.pathname = '/start';
|
||||
h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f());
|
||||
assert.equal(h.sent.length, 1);
|
||||
h.win.location.pathname = '/neu';
|
||||
h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
|
||||
h.timers.splice(0).forEach((f) => f());
|
||||
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']);
|
||||
});
|
||||
|
||||
test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
|
||||
assert.doesNotThrow(() => {
|
||||
const sandbox = { window: {}, document: { currentScript: null, querySelector() { throw new Error('x'); } }, navigator: {}, location: {} };
|
||||
sandbox.window = sandbox; vm.createContext(sandbox);
|
||||
vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
|
||||
});
|
||||
});
|
||||
|
||||
test('w.js: DNT yes / msDoNotTrack => nur pv', () => {
|
||||
for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); }
|
||||
});
|
||||
|
||||
test('w.js: doppelt eingebunden => nur ein pv', () => {
|
||||
const h = harness({ gpc: true });
|
||||
h.rerun();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
});
|
||||
|
||||
test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.at(-1).d.t, 'ck');
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
const n = h.sent.length;
|
||||
h.ck(target()); h.leave();
|
||||
assert.equal(h.sent.length, n);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
// Annehmen in anderem Tab startet Tracking
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.length, n + 1);
|
||||
});
|
||||
|
||||
test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
const n = h.sent.length;
|
||||
h.ck(target()); h.leave();
|
||||
assert.equal(h.sent.length, n);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
});
|
||||
|
||||
test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
h.win.location.pathname = '/zwei';
|
||||
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
|
||||
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.at(-1).d.path, '/zwei');
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
const n = h.sent.length;
|
||||
h.leave();
|
||||
assert.equal(h.sent.length, n);
|
||||
});
|
||||
|
||||
test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
h.leave();
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1);
|
||||
for (const f of h.winL.pageshow || []) f({ persisted: false });
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1);
|
||||
for (const f of h.winL.pageshow || []) f({ persisted: true });
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 2);
|
||||
h.leave();
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 2);
|
||||
});
|
||||
|
||||
test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } });
|
||||
// kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls
|
||||
const late = makeEl('a'); late.setAttribute('data-webklar-consent', '');
|
||||
for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} });
|
||||
assert.ok(hostEl(h));
|
||||
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused);
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, chmodSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const SCRIPT = new URL('../scripts/geo-update.sh', import.meta.url).pathname;
|
||||
|
||||
// Fremde Programme (curl, docker) sind Attrappen im PATH; docker schlaegt fehl, solange DOCKER_FAIL=1 gesetzt ist.
|
||||
function setup() {
|
||||
const root = mkdtempSync(join(tmpdir(), 'geoupd-'));
|
||||
const bin = join(root, 'bin');
|
||||
const data = join(root, 'data');
|
||||
mkdirSync(bin);
|
||||
mkdirSync(data);
|
||||
const csv = join(root, 'src.csv');
|
||||
const rows = [];
|
||||
for (let i = 0; i < 100100; i++) rows.push(`1.${i % 250}.${Math.floor(i / 250) % 250}.0,1.${i % 250}.${Math.floor(i / 250) % 250}.255,AU`);
|
||||
rows.push('5.9.0.0,5.9.255.255,DE');
|
||||
writeFileSync(csv, rows.join('\n') + '\n');
|
||||
writeFileSync(join(bin, 'curl'), `#!/bin/sh\nwhile [ "$#" -gt 0 ]; do [ "$1" = "-o" ] && out="$2"; shift; done\ngzip -c "${csv}" > "$out"\n`);
|
||||
writeFileSync(join(bin, 'docker'), `#!/bin/sh\necho "$@" >> "${root}/docker.log"\n[ "\${DOCKER_FAIL:-0}" = "1" ] && exit 1\nexit 0\n`);
|
||||
chmodSync(join(bin, 'curl'), 0o755);
|
||||
chmodSync(join(bin, 'docker'), 0o755);
|
||||
const run = (fail) => spawnSync('sh', [SCRIPT], { env: { PATH: `${bin}:${process.env.PATH}`, ANALYTICS_DATA_DIR: data, DOCKER_FAIL: fail ? '1' : '0' }, encoding: 'utf8' });
|
||||
const restarts = () => (existsSync(join(root, 'docker.log')) ? readFileSync(join(root, 'docker.log'), 'utf8').trim().split('\n').length : 0);
|
||||
return { data, run, restarts, marker: join(data, 'geo.restart-pending') };
|
||||
}
|
||||
|
||||
test('geo-update: Neustart schlägt fehl -> Marker; nächster Lauf startet trotz unveränderter Datei neu und entfernt ihn', () => {
|
||||
const s = setup();
|
||||
const first = s.run(true);
|
||||
assert.notEqual(first.status, 0, first.stderr);
|
||||
assert.ok(existsSync(join(s.data, 'geo.csv')), 'neue Datei ist schon an Ort und Stelle');
|
||||
assert.ok(existsSync(s.marker), 'Marker liegt');
|
||||
assert.equal(s.restarts(), 1);
|
||||
const second = s.run(false);
|
||||
assert.equal(second.status, 0, second.stderr);
|
||||
assert.equal(s.restarts(), 2, 'Neustart nachgeholt');
|
||||
assert.equal(existsSync(s.marker), false);
|
||||
const third = s.run(false);
|
||||
assert.equal(third.status, 0);
|
||||
assert.equal(s.restarts(), 2, 'unverändert und ohne Marker: kein Neustart');
|
||||
});
|
||||
|
||||
test('geo-update: erfolgreicher Neustart hinterlässt keinen Marker', () => {
|
||||
const s = setup();
|
||||
assert.equal(s.run(false).status, 0);
|
||||
assert.equal(s.restarts(), 1);
|
||||
assert.equal(existsSync(s.marker), false);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadGeo } from '../src/geo.js';
|
||||
|
||||
const CSV = [
|
||||
'1.0.0.0,1.0.0.255,AU',
|
||||
'5.9.0.0,5.9.255.255,DE',
|
||||
'5.10.0.0,5.10.0.9,ZZ',
|
||||
'8.8.8.0,8.8.8.255,US',
|
||||
'255.255.255.0,255.255.255.255,FR',
|
||||
'2001:db8::,2001:db8:ffff:ffff:ffff:ffff:ffff:ffff,AT',
|
||||
'2a01:4f8::,2a01:4f8:ffff:ffff:ffff:ffff:ffff:ffff,DE',
|
||||
'2a02:1::1,2a02:1::ff,CH',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function fixture(content) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'geo-'));
|
||||
const f = join(dir, 'geo.csv');
|
||||
writeFileSync(f, content);
|
||||
return f;
|
||||
}
|
||||
|
||||
test('IPv4: Treffer und Grenzen', () => {
|
||||
const g = loadGeo(fixture(CSV));
|
||||
assert.equal(g.size, 8);
|
||||
assert.equal(g.lookup('5.9.0.0'), 'DE');
|
||||
assert.equal(g.lookup('5.9.123.4'), 'DE');
|
||||
assert.equal(g.lookup('5.9.255.255'), 'DE');
|
||||
assert.equal(g.lookup('5.8.255.255'), '');
|
||||
assert.equal(g.lookup('5.10.0.0'), '');
|
||||
assert.equal(g.lookup('1.0.0.255'), 'AU');
|
||||
assert.equal(g.lookup('1.0.1.0'), '');
|
||||
assert.equal(g.lookup('0.0.0.1'), '');
|
||||
assert.equal(g.lookup('255.255.255.255'), 'FR');
|
||||
assert.equal(g.lookup('8.8.8.8'), 'US');
|
||||
});
|
||||
|
||||
test('IPv6: Treffer, Grenzen, Kurzschreibweise', () => {
|
||||
const g = loadGeo(fixture(CSV));
|
||||
assert.equal(g.lookup('2a01:4f8::'), 'DE');
|
||||
assert.equal(g.lookup('2a01:4f8:1:2:3:4:5:6'), 'DE');
|
||||
assert.equal(g.lookup('2A01:04F8:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF'), 'DE');
|
||||
assert.equal(g.lookup('2a01:4f9::'), '');
|
||||
assert.equal(g.lookup('2a01:4f7:ffff:ffff:ffff:ffff:ffff:ffff'), '');
|
||||
assert.equal(g.lookup('2001:db8::1'), 'AT');
|
||||
assert.equal(g.lookup('2a02:1::1'), 'CH');
|
||||
assert.equal(g.lookup('2a02:1::ff'), 'CH');
|
||||
assert.equal(g.lookup('2a02:1::100'), '');
|
||||
assert.equal(g.lookup('2a02:1::'), '');
|
||||
assert.equal(g.lookup('::1'), '');
|
||||
});
|
||||
|
||||
test('IPv4-gemappte IPv6-Adressen zählen als IPv4', () => {
|
||||
const g = loadGeo(fixture(CSV));
|
||||
assert.equal(g.lookup('::ffff:5.9.1.1'), 'DE');
|
||||
assert.equal(g.lookup('::FFFF:8.8.8.8'), 'US');
|
||||
assert.equal(g.lookup('::ffff:808:808'), 'US');
|
||||
assert.equal(g.lookup('::ffff:9.9.9.9'), '');
|
||||
});
|
||||
|
||||
test('ZZ, ungültige Eingaben, unsortierte Datei', () => {
|
||||
const g = loadGeo(fixture(CSV));
|
||||
assert.equal(g.lookup('5.10.0.5'), '');
|
||||
for (const bad of ['', 'abc', '1.2.3', '256.1.1.1', '1.2.3.4.5', '2001:db8::zz', ':::', '1::2::3', null, undefined, 42]) {
|
||||
assert.equal(g.lookup(bad), '', String(bad));
|
||||
}
|
||||
const u = loadGeo(fixture('8.8.8.0,8.8.8.255,US\n1.0.0.0,1.0.0.255,AU\n2a01:4f8::,2a01:4f8::ff,DE\n2001:db8::,2001:db8::ff,AT\n'));
|
||||
assert.equal(u.lookup('1.0.0.7'), 'AU');
|
||||
assert.equal(u.lookup('8.8.8.8'), 'US');
|
||||
assert.equal(u.lookup('2001:db8::7'), 'AT');
|
||||
assert.equal(u.lookup('2a01:4f8::7'), 'DE');
|
||||
});
|
||||
|
||||
test('fehlende Datei: alles leer, eine Warnung, kein Absturz', () => {
|
||||
const warns = [];
|
||||
const orig = console.warn;
|
||||
console.warn = (...a) => warns.push(a.join(' '));
|
||||
try {
|
||||
const g = loadGeo('/nicht/vorhanden/geo.csv');
|
||||
assert.equal(g.lookup('5.9.1.1'), '');
|
||||
assert.equal(g.lookup('2a01:4f8::1'), '');
|
||||
assert.equal(g.size, 0);
|
||||
} finally { console.warn = orig; }
|
||||
assert.equal(warns.length, 1);
|
||||
assert.match(warns[0], /nicht lesbar/);
|
||||
});
|
||||
|
||||
test('600k Zeilen: lädt zügig, Heap bleibt klein', () => {
|
||||
const lines = [];
|
||||
for (let i = 0; i < 400000; i++) lines.push(`${(i * 4096) >>> 24}.${((i * 4096) >>> 16) & 255}.${((i * 4096) >>> 8) & 255}.0,${(i * 4096) >>> 24}.${((i * 4096) >>> 16) & 255}.${((i * 4096 + 255) >>> 8) & 255}.255,${i % 7 ? 'DE' : 'AT'}`);
|
||||
for (let i = 0; i < 200000; i++) lines.push(`2a00:${(i >> 8).toString(16)}:${(i & 255).toString(16)}::,2a00:${(i >> 8).toString(16)}:${(i & 255).toString(16)}:ffff:ffff:ffff:ffff:ffff,${i % 5 ? 'DE' : 'FR'}`);
|
||||
const f = fixture(lines.join('\n'));
|
||||
lines.length = 0;
|
||||
globalThis.gc?.();
|
||||
const before = process.memoryUsage().heapUsed; const rss0 = process.memoryUsage().rss;
|
||||
const t0 = Date.now();
|
||||
const g = loadGeo(f);
|
||||
const ms = Date.now() - t0;
|
||||
globalThis.gc?.();
|
||||
const grow = (process.memoryUsage().heapUsed - before) / 1048576;
|
||||
console.log(`# geo 600k: ${ms} ms, Heap +${grow.toFixed(1)} MB, RSS +${((process.memoryUsage().rss - rss0) / 1048576).toFixed(0)} MB, Zeilen ${g.size}`);
|
||||
assert.equal(g.size, 600000);
|
||||
assert.equal(g.lookup('0.0.16.5'), 'DE');
|
||||
assert.equal(g.lookup('2a00:0:1::5'), 'DE');
|
||||
assert.ok(grow < 100, `Heap +${grow} MB`);
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||
import { signHeatToken, verifyHeatToken } from '../src/heat.js';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const OTHER = 'zzzzzzzz0123456789zz';
|
||||
const SECRET = 'geheim';
|
||||
const NOW = Date.UTC(2026, 9, 10, 10, 0, 0);
|
||||
const exp = (s = 600) => Math.floor(NOW / 1000) + s;
|
||||
|
||||
async function setup() {
|
||||
const db = openDb(':memory:');
|
||||
const site = db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)');
|
||||
site.run(KEY, 'p', JSON.stringify(['kunde.de', 'www.kunde.de']));
|
||||
site.run(OTHER, 'q', JSON.stringify(['andere.de']));
|
||||
const click = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)');
|
||||
click.run(KEY, NOW - 1000, '/', 'd', 0.1, 0.2);
|
||||
click.run(KEY, NOW - 2000, '/', 'd', 0.3, 0.4);
|
||||
click.run(KEY, NOW - 1000, '/', 'm', 0.9, 0.9);
|
||||
click.run(KEY, NOW - 1000, '/x', 'd', 0.8, 0.8);
|
||||
click.run(OTHER, NOW - 1000, '/', 'd', 0.7, 0.7);
|
||||
click.run(KEY, NOW - 31 * 86400000, '/', 'd', 0.5, 0.5);
|
||||
const server = createPublicServer({ config: { heatSecret: SECRET }, db, now: () => NOW });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const get = (token, headers = {}) => fetch(`${base}/a/heat?token=${encodeURIComponent(token)}`, { headers });
|
||||
return { db, get, base, close: () => server.close() };
|
||||
}
|
||||
const tok = (o = {}, secret = SECRET) => signHeatToken({ siteKey: KEY, path: '/', vp: 'd', exp: exp(), ...o }, secret);
|
||||
|
||||
test('gültiges Token: neueste Punkte der Seite/Viewport, 30 Tage, no-store, CORS nur eigene Domain', async () => {
|
||||
const s = await setup();
|
||||
const r = await s.get(tok(), { Origin: 'https://www.kunde.de' });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(r.headers.get('access-control-allow-origin'), 'https://www.kunde.de');
|
||||
assert.deepEqual(await r.json(), { points: [{ x: 0.1, y: 0.2 }, { x: 0.3, y: 0.4 }] });
|
||||
const fremd = await s.get(tok(), { Origin: 'https://evil.example' });
|
||||
assert.equal(fremd.status, 200);
|
||||
assert.equal(fremd.headers.get('access-control-allow-origin'), null);
|
||||
const http = await s.get(tok(), { Origin: 'http://kunde.de' });
|
||||
assert.equal(http.headers.get('access-control-allow-origin'), null);
|
||||
assert.equal(r.headers.get('access-control-allow-origin') === '*', false);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('maximal 5000 neueste Punkte', async () => {
|
||||
const s = await setup();
|
||||
s.db.exec('BEGIN');
|
||||
const ins = s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)');
|
||||
for (let i = 0; i < 5100; i++) ins.run(KEY, NOW - 10000 + i, '/many', 'd', 0.5, i < 100 ? 0.01 : 0.99);
|
||||
s.db.exec('COMMIT');
|
||||
const j = await (await s.get(tok({ path: '/many' }))).json();
|
||||
assert.equal(j.points.length, 5000);
|
||||
assert.ok(j.points.every((p) => p.y === 0.99)); // die 100 ältesten fehlen
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('403: abgelaufen, > 30 Min voraus, falsches Secret, manipuliert, Müll, fremde/unbekannte Website', async () => {
|
||||
const s = await setup();
|
||||
const bad = [
|
||||
tok({ exp: exp(-1) }),
|
||||
tok({ exp: exp(30 * 60 + 5) }),
|
||||
tok({}, 'anderes-secret'),
|
||||
'abc', '', 'a.b', 'x'.repeat(10) + '.' + 'f'.repeat(64),
|
||||
tok({ siteKey: 'nichtvorhanden0123456' }),
|
||||
];
|
||||
// Manipulation: Payload tauschen, Signatur behalten
|
||||
const [part, sig] = tok().split('.');
|
||||
for (const patch of [{ p: '/x' }, { v: 'm' }, { s: OTHER }]) {
|
||||
const o = { ...JSON.parse(Buffer.from(part, 'base64url')), ...patch };
|
||||
bad.push(`${Buffer.from(JSON.stringify(o)).toString('base64url')}.${sig}`);
|
||||
}
|
||||
for (const t of bad) assert.equal((await s.get(t)).status, 403, t.slice(0, 30));
|
||||
assert.equal((await fetch(`${s.base}/a/heat`)).status, 403);
|
||||
// Token der anderen Website liefert nur deren Punkte (nie die von KEY)
|
||||
const j = await (await s.get(tok({ siteKey: OTHER }))).json();
|
||||
assert.deepEqual(j.points, [{ x: 0.7, y: 0.7 }]);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('Token mit exp genau 30 Min voraus ist gültig; Helfer verify', () => {
|
||||
assert.ok(verifyHeatToken(tok({ exp: exp(1800) }), SECRET, NOW));
|
||||
assert.equal(verifyHeatToken(tok({ exp: exp(1801) }), SECRET, NOW), null);
|
||||
assert.equal(verifyHeatToken(tok(), '', NOW), null); // leeres Secret sperrt
|
||||
assert.equal(verifyHeatToken(tok({ vp: 'x' }), SECRET, NOW), null);
|
||||
assert.equal(verifyHeatToken(tok({ path: 'kein-slash' }), SECRET, NOW), null);
|
||||
const t = tok();
|
||||
assert.match(t, /^[A-Za-z0-9_-]+\.[0-9a-f]{64}$/);
|
||||
assert.deepEqual(JSON.parse(Buffer.from(t.split('.')[0], 'base64url')), { s: KEY, p: '/', v: 'd', e: exp() });
|
||||
});
|
||||
|
||||
test('/a/heat nur auf dem öffentlichen Listener', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const server = createAdminServer({ config: { heatSecret: SECRET, adminToken: 't' }, db });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const r = await fetch(`http://127.0.0.1:${server.address().port}/a/heat?token=${tok()}`);
|
||||
assert.equal(r.status, 404);
|
||||
server.close();
|
||||
});
|
||||
|
||||
test('403 bei ausgeschalteter Website (active = 0), mit CORS für die eigene Domain', async () => {
|
||||
const s = await setup();
|
||||
s.db.prepare('UPDATE sites SET active = 0 WHERE siteKey = ?').run(KEY);
|
||||
const r = await s.get(tok(), { Origin: 'https://kunde.de' });
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.headers.get('access-control-allow-origin'), 'https://kunde.de');
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('403 mit CORS-Kopf, wenn die Website bekannt ist (abgelaufen, aus); sonst ohne', async () => {
|
||||
const s = await setup();
|
||||
const origin = { Origin: 'https://www.kunde.de' };
|
||||
const abgelaufen = await s.get(tok({ exp: exp(-1) }), origin);
|
||||
assert.equal(abgelaufen.status, 403);
|
||||
assert.equal(abgelaufen.headers.get('access-control-allow-origin'), 'https://www.kunde.de');
|
||||
// fremder Origin, falsche Signatur, unbekannte Website: kein CORS-Kopf
|
||||
assert.equal((await s.get(tok({ exp: exp(-1) }), { Origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null);
|
||||
assert.equal((await s.get(tok({}, 'anderes-secret'), origin)).headers.get('access-control-allow-origin'), null);
|
||||
assert.equal((await s.get(tok({ siteKey: 'nichtvorhanden0123456' }), origin)).headers.get('access-control-allow-origin'), null);
|
||||
assert.equal((await s.get('abc', origin)).headers.get('access-control-allow-origin'), null);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('OPTIONS-Preflight: CORS-Köpfe nur für erlaubte Origins bekannter Websites', async () => {
|
||||
const s = await setup();
|
||||
const pre = (token, origin) => fetch(`${s.base}/a/heat?token=${encodeURIComponent(token)}`, { method: 'OPTIONS', headers: { Origin: origin, 'Access-Control-Request-Method': 'GET' } });
|
||||
const ok = await pre(tok(), 'https://kunde.de');
|
||||
assert.equal(ok.status, 204);
|
||||
assert.equal(ok.headers.get('access-control-allow-origin'), 'https://kunde.de');
|
||||
assert.match(ok.headers.get('access-control-allow-methods'), /GET/);
|
||||
assert.equal((await pre(tok({ exp: exp(-1) }), 'https://kunde.de')).headers.get('access-control-allow-origin'), 'https://kunde.de');
|
||||
assert.equal((await pre(tok(), 'https://evil.example')).headers.get('access-control-allow-origin'), null);
|
||||
assert.equal((await pre('abc', 'https://kunde.de')).headers.get('access-control-allow-origin'), null);
|
||||
s.close();
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.js';
|
||||
import net from 'node:net';
|
||||
import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||
import { classifyUa } from '../src/ua.js';
|
||||
import { createSalt } from '../src/salt.js';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const HOST = 'abc.project.webklar.com';
|
||||
const ORIGIN = `https://${HOST}`;
|
||||
const IP = '203.0.113.77';
|
||||
const UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148 Safari/604.1 GEHEIMERUA';
|
||||
|
||||
async function setup({ now = () => Date.UTC(2026, 9, 1, 10, 30, 45), geo = { lookup: () => 'DE' } } = {}) {
|
||||
const db = openDb(':memory:');
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)')
|
||||
.run(KEY, 'p1', JSON.stringify([HOST, 'www.kunde.de']));
|
||||
const server = createPublicServer({ config: {}, db, geo, now });
|
||||
const invalidate = () => server.invalidateSites();
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const post = (ev, { origin = ORIGIN, ua = UA, ip = IP, left = '1.2.3.4', referer, raw, type = 'text/plain' } = {}) => {
|
||||
const headers = { 'Content-Type': type, 'User-Agent': ua, 'X-Forwarded-For': `${left}, ${ip}` };
|
||||
if (origin) headers.Origin = origin;
|
||||
if (referer) headers.Referer = referer;
|
||||
return fetch(base + '/a/e', { method: 'POST', headers, body: raw ?? JSON.stringify(ev) });
|
||||
};
|
||||
const rows = () => db.prepare('SELECT * FROM events_raw ORDER BY id').all();
|
||||
return { db, post, rows, base, server, invalidate, close: () => server.close() };
|
||||
}
|
||||
const pv = (o = {}) => ({ t: 'pv', site: KEY, path: '/', vp: 'd', ...o });
|
||||
|
||||
test('classifyUa: Bots, Geräte, leer', () => {
|
||||
assert.equal(classifyUa('').bot, true);
|
||||
assert.equal(classifyUa('Googlebot/2.1').bot, true);
|
||||
assert.equal(classifyUa('curl/8.0').bot, true);
|
||||
assert.equal(classifyUa('Mozilla/5.0 (Windows NT 10.0) Chrome/120').device, 'd');
|
||||
assert.equal(classifyUa('Mozilla/5.0 (iPhone; CPU) Mobile/15E').device, 'm');
|
||||
assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; Pixel) Mobile Safari').device, 'm');
|
||||
assert.equal(classifyUa('Mozilla/5.0 (iPad; CPU OS 17)').device, 't');
|
||||
assert.equal(classifyUa('Mozilla/5.0 (Linux; Android 13; SM-T500) Safari').device, 't');
|
||||
assert.equal(classifyUa('Mozilla/5.0 (X11; Linux) Firefox/120').bot, false);
|
||||
});
|
||||
|
||||
test('Salz: stabil am Tag, wechselt um Mitternacht Berlin', () => {
|
||||
let t = Date.UTC(2026, 9, 1, 10, 0, 0);
|
||||
let n = 0;
|
||||
const s = createSalt({ now: () => t, random: () => Buffer.alloc(32, ++n) });
|
||||
const a = s.visitorId('s', '1.1.1.1', 'ua');
|
||||
assert.match(a, /^[0-9a-f]{16}$/);
|
||||
t = Date.UTC(2026, 9, 1, 21, 59, 0); // 23:59 Berlin (MESZ)
|
||||
assert.equal(s.visitorId('s', '1.1.1.1', 'ua'), a);
|
||||
assert.notEqual(s.visitorId('s', '1.1.1.2', 'ua'), a);
|
||||
t = Date.UTC(2026, 9, 1, 22, 0, 1); // 00:00 Berlin
|
||||
assert.notEqual(s.visitorId('s', '1.1.1.1', 'ua'), a);
|
||||
assert.match(s.rateKey('s', '1.1.1.1'), /^[0-9a-f]{64}$/);
|
||||
assert.ok(!s.rateKey('s', '1.1.1.1').includes('1.1.1.1'));
|
||||
});
|
||||
|
||||
test('pv: gespeichert ohne visitor, mit Land und Gerät, Cookie-frei', async () => {
|
||||
const t = await setup();
|
||||
const res = await t.post(pv({ path: '/preise?x=1#a', ref: 'https://Google.com/search?q=1' }));
|
||||
assert.equal(res.status, 204);
|
||||
assert.equal(res.headers.get('set-cookie'), null);
|
||||
const [r] = t.rows();
|
||||
assert.equal(r.type, 'pv');
|
||||
assert.equal(r.site, KEY);
|
||||
assert.equal(r.path, '/preise');
|
||||
assert.equal(r.ref, 'google.com');
|
||||
assert.equal(r.vp, 'd');
|
||||
assert.equal(r.country, 'DE');
|
||||
assert.equal(r.device, 'm');
|
||||
assert.equal(r.visitor, null);
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), ORIGIN);
|
||||
assert.equal(res.headers.get('access-control-allow-credentials'), null);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('pv: ref eigene Domain wird verworfen, ungültige Felder', async () => {
|
||||
const t = await setup();
|
||||
await t.post(pv({ ref: 'https://www.kunde.de/x' }));
|
||||
await t.post(pv({ ref: 'kein host!' }));
|
||||
assert.equal(t.rows()[0].ref, null);
|
||||
assert.equal(t.rows()[1].ref, null);
|
||||
await t.post(pv({ path: 'kein-slash' }));
|
||||
await t.post(pv({ path: '/' + 'a'.repeat(300) }));
|
||||
await t.post(pv({ path: '/a\u0001b' }));
|
||||
await t.post(pv({ vp: 'x' }));
|
||||
await t.post({ t: 'zz', site: KEY, path: '/', vp: 'd' });
|
||||
await t.post({ site: KEY, path: '/', vp: 'd' });
|
||||
await t.post(null, { raw: 'kein json' });
|
||||
await t.post(null, { raw: '[1]' });
|
||||
assert.equal(t.rows().length, 2);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Antwort immer 204; 413 bei großem Body; 405 bei falscher Methode', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.post(null, { raw: 'kein json' })).status, 204);
|
||||
assert.equal((await t.post(pv({ pad: 'x'.repeat(5000) }))).status, 413);
|
||||
assert.equal(t.rows().length, 0);
|
||||
assert.equal((await fetch(t.base + '/a/e')).status, 405);
|
||||
assert.equal((await fetch(t.base + '/a/e', { method: 'PUT', body: '{}' })).status, 405);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('application/json wird ebenfalls angenommen; unbekannte Felder werden nicht gespeichert', async () => {
|
||||
const t = await setup();
|
||||
const res = await t.post(pv({ evil: 'GEHEIM', ip: '9.9.9.9' }), { type: 'application/json' });
|
||||
assert.equal(res.status, 204);
|
||||
assert.equal(t.rows().length, 1);
|
||||
assert.ok(!JSON.stringify(t.db.prepare('SELECT * FROM events_raw').all()).includes('GEHEIM'));
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Origin: fremd, fehlend, Referer-Fallback, inaktiv, unbekannte Site', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.post(pv(), { origin: 'https://evil.example' })).headers.get('access-control-allow-origin'), null);
|
||||
await t.post(pv(), { origin: null });
|
||||
assert.equal(t.rows().length, 0);
|
||||
await t.post(pv(), { origin: null, referer: `${ORIGIN}/seite` });
|
||||
assert.equal(t.rows().length, 1);
|
||||
await t.post(pv(), { origin: 'https://www.kunde.de' });
|
||||
assert.equal(t.rows().length, 2);
|
||||
await t.post(pv({ site: 'zzzzzzzzzzzzzzzzzzzz' }));
|
||||
assert.equal(t.rows().length, 2);
|
||||
t.db.prepare('UPDATE sites SET active = 0').run();
|
||||
await t.post(pv());
|
||||
assert.equal(t.rows().length, 2);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Bot und leerer User-Agent: nichts gespeichert', async () => {
|
||||
const t = await setup();
|
||||
await t.post(pv(), { ua: 'Googlebot/2.1' });
|
||||
await t.post(pv(), { ua: '' });
|
||||
assert.equal(t.rows().length, 0);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('bn: nur site, ts (Stunde), type, kind', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'bn', site: KEY, a: 'shown', path: '/x', vp: 'm', visitor: 'v' });
|
||||
await t.post({ t: 'bn', site: KEY, a: 'accept' });
|
||||
await t.post({ t: 'bn', site: KEY, a: 'foo' });
|
||||
const rows = t.rows();
|
||||
assert.equal(rows.length, 2);
|
||||
for (const r of rows) {
|
||||
assert.equal(r.type, 'bn');
|
||||
for (const f of ['path', 'vp', 'country', 'device', 'visitor', 'ref', 'sec', 'scroll', 'host']) assert.equal(r[f], null, f);
|
||||
assert.equal(r.ts, Date.UTC(2026, 9, 1, 10, 0, 0));
|
||||
}
|
||||
assert.deepEqual(rows.map((r) => r.kind), ['shown', 'accept']);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('en: sec/scroll begrenzt, mit visitor', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'en', site: KEY, path: '/a?b', vp: 't', sec: 99999, scroll: 250 });
|
||||
await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 12.7, scroll: -5 });
|
||||
await t.post({ t: 'en', site: KEY, path: '/a', vp: 't', sec: 'x', scroll: 1 });
|
||||
const rows = t.rows();
|
||||
assert.equal(rows.length, 2);
|
||||
assert.match(rows[0].visitor, /^[0-9a-f]{16}$/);
|
||||
assert.equal(rows[0].path, '/a');
|
||||
assert.equal(rows[0].sec, 3600);
|
||||
assert.equal(rows[0].scroll, 100);
|
||||
assert.equal(rows[1].sec, 12);
|
||||
assert.equal(rows[1].scroll, 0);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('ck: in clicks, geklemmt, ohne visitor', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'ck', site: KEY, path: '/a#h', vp: 'd', x: 0.123456, y: 1.7, sel: 'div>a'.repeat(100) });
|
||||
await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 'n', y: 0.5 });
|
||||
assert.equal(t.rows().length, 0);
|
||||
const c = t.db.prepare('SELECT * FROM clicks').all();
|
||||
assert.equal(c.length, 1);
|
||||
assert.equal(c[0].path, '/a');
|
||||
assert.equal(c[0].x, 0.123);
|
||||
assert.equal(c[0].y, 1);
|
||||
assert.equal(c[0].sel.length, 200);
|
||||
assert.deepEqual(Object.keys(c[0]).sort(), ['id', 'path', 'sel', 'site', 'ts', 'vp', 'x', 'y']);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('ct: kind und host', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'tel', host: 'ignored.de' });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out', host: 'Instagram.COM' });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'out' });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/k', kind: 'sms' });
|
||||
const rows = t.rows();
|
||||
assert.equal(rows.length, 2);
|
||||
assert.equal(rows[0].kind, 'tel');
|
||||
assert.equal(rows[0].host, null);
|
||||
assert.equal(rows[1].host, 'instagram.com');
|
||||
assert.match(rows[1].visitor, /^[0-9a-f]{16}$/);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Mengenlimit: 121. Ereignis verworfen, neue Minute frei', async () => {
|
||||
let now = Date.UTC(2026, 9, 1, 10, 30, 0);
|
||||
const t = await setup({ now: () => now });
|
||||
for (let i = 0; i < 125; i++) await t.post(pv());
|
||||
assert.equal(t.rows().length, 120);
|
||||
await t.post(pv(), { ip: '198.51.100.9' });
|
||||
assert.equal(t.rows().length, 121);
|
||||
now += 61000;
|
||||
await t.post(pv());
|
||||
assert.equal(t.rows().length, 122);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Land: nur 2 Großbuchstaben, sonst leer', async () => {
|
||||
const t = await setup({ geo: { lookup: () => 'xyz' } });
|
||||
await t.post(pv());
|
||||
assert.equal(t.rows()[0].country, '');
|
||||
t.close();
|
||||
const u = await setup({ geo: { lookup: () => 'at' } });
|
||||
await u.post(pv());
|
||||
assert.equal(u.rows()[0].country, 'AT');
|
||||
u.close();
|
||||
});
|
||||
|
||||
test('CORS-Preflight nur für erlaubte Origin', async () => {
|
||||
const t = await setup();
|
||||
const ok = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN, 'Access-Control-Request-Method': 'POST' } });
|
||||
assert.equal(ok.status, 204);
|
||||
assert.equal(ok.headers.get('access-control-allow-origin'), ORIGIN);
|
||||
assert.equal(ok.headers.get('access-control-allow-credentials'), null);
|
||||
const bad = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: 'https://evil.example' } });
|
||||
assert.equal(bad.status, 204);
|
||||
assert.equal(bad.headers.get('access-control-allow-origin'), null);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Datenschutz: weder IP noch User-Agent irgendwo in der Datenbank', async () => {
|
||||
const t = await setup();
|
||||
await t.post(pv({ ref: 'https://google.com' }));
|
||||
await t.post({ t: 'bn', site: KEY, a: 'accept' });
|
||||
await t.post({ t: 'en', site: KEY, path: '/a', vp: 'd', sec: 5, scroll: 50 });
|
||||
await t.post({ t: 'ck', site: KEY, path: '/a', vp: 'd', x: 0.5, y: 0.5, sel: 'a' });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/a', kind: 'mail' });
|
||||
let dump = '';
|
||||
for (const { name } of t.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all()) {
|
||||
dump += JSON.stringify(t.db.prepare(`SELECT * FROM ${name}`).all());
|
||||
}
|
||||
for (const needle of [IP, '1.2.3.4', 'GEHEIMERUA', 'Mozilla', 'iPhone']) assert.ok(!dump.includes(needle), needle);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('t: nur eigene Feldlisten, kein Prototyp-Zugriff, keine Logzeile', async () => {
|
||||
const t = await setup();
|
||||
const errs = [];
|
||||
const orig = console.error;
|
||||
console.error = (...a) => errs.push(a.join(' '));
|
||||
try {
|
||||
for (const tt of ['__proto__', 'constructor', 'toString', 'hasOwnProperty', 'valueOf']) {
|
||||
assert.equal((await t.post(null, { raw: JSON.stringify({ t: tt, site: KEY, path: '/', vp: 'd' }) })).status, 204);
|
||||
}
|
||||
assert.equal((await t.post(null, { raw: `{"t":"__proto__","site":"${KEY}","path":"/"}` })).status, 204);
|
||||
assert.equal((await t.post({ t: ['pv'], site: KEY, path: '/', vp: 'd' })).status, 204);
|
||||
assert.equal((await t.post({ t: { toString: 1 }, site: KEY, path: '/', vp: 'd' })).status, 204);
|
||||
} finally { console.error = orig; }
|
||||
assert.equal(t.rows().length, 0);
|
||||
assert.deepEqual(errs, []);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Origin/Referer: nur https ohne Port zählt', async () => {
|
||||
const t = await setup();
|
||||
for (const origin of [`http://${HOST}`, `https://${HOST}:8080`, `https://evil-${HOST}`, `https://${HOST}.evil.com`, `ftp://${HOST}`]) {
|
||||
const res = await t.post(pv(), { origin });
|
||||
assert.equal(res.headers.get('access-control-allow-origin'), null, origin);
|
||||
const pre = await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: origin } });
|
||||
assert.equal(pre.headers.get('access-control-allow-origin'), null, origin);
|
||||
}
|
||||
await t.post(pv(), { origin: null, referer: `http://${HOST}/x` });
|
||||
await t.post(pv(), { origin: null, referer: `https://${HOST}:8080/x` });
|
||||
assert.equal(t.rows().length, 0);
|
||||
await t.post(pv(), { origin: null, referer: `https://${HOST}/x` });
|
||||
assert.equal(t.rows().length, 1);
|
||||
t.close();
|
||||
});
|
||||
|
||||
function rawRequest(port, head, body = '') {
|
||||
return new Promise((resolve) => {
|
||||
const s = net.connect(port, '127.0.0.1');
|
||||
let buf = '';
|
||||
s.on('connect', () => s.write(head + '\r\n\r\n' + body));
|
||||
s.on('data', (d) => { buf += d; });
|
||||
s.on('close', () => resolve(buf));
|
||||
s.on('error', () => resolve(buf));
|
||||
setTimeout(() => { s.destroy(); }, 1500);
|
||||
});
|
||||
}
|
||||
|
||||
test('Body: früh 413 bei Content-Length > 4096, Abbruch bei gestreamtem Überlauf', async () => {
|
||||
const t = await setup();
|
||||
const port = t.server.address().port;
|
||||
const head = `POST /a/e HTTP/1.1\r\nHost: x\r\nOrigin: ${ORIGIN}\r\nContent-Type: text/plain`;
|
||||
// Content-Length 1 MB angekündigt, aber nichts gesendet: Antwort kommt trotzdem sofort
|
||||
const early = await rawRequest(port, `${head}\r\nContent-Length: 1048576`);
|
||||
assert.match(early, /^HTTP\/1\.1 413/);
|
||||
// chunked ohne Content-Length: über 4 KB ⇒ 413 und Verbindung wird beendet
|
||||
const chunk = 'x'.repeat(3000);
|
||||
const body = `${chunk.length.toString(16)}\r\n${chunk}\r\n`.repeat(3);
|
||||
const streamed = await rawRequest(port, `${head}\r\nTransfer-Encoding: chunked`, body);
|
||||
assert.match(streamed, /^HTTP\/1\.1 413/);
|
||||
assert.equal(t.rows().length, 0);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Preflight: Hostliste gecacht (30 s), invalidateSites() und Admin-Änderung wirken sofort', async () => {
|
||||
let now = Date.UTC(2026, 9, 1, 10, 0, 0);
|
||||
const t = await setup({ now: () => now });
|
||||
const pre = async () => (await fetch(t.base + '/a/e', { method: 'OPTIONS', headers: { Origin: ORIGIN } })).headers.get('access-control-allow-origin');
|
||||
assert.equal(await pre(), ORIGIN);
|
||||
t.db.prepare('UPDATE sites SET active = 0').run();
|
||||
assert.equal(await pre(), ORIGIN); // noch aus dem Cache
|
||||
now += 31000;
|
||||
assert.equal(await pre(), null); // Cache abgelaufen
|
||||
t.db.prepare('UPDATE sites SET active = 1').run();
|
||||
assert.equal(await pre(), null);
|
||||
t.invalidate();
|
||||
assert.equal(await pre(), ORIGIN);
|
||||
// Admin-Listener meldet Änderungen an den öffentlichen Listener
|
||||
const admin = createAdminServer({ config: { adminToken: 'geheim' }, db: t.db, onSitesChanged: () => t.invalidate() });
|
||||
await new Promise((r) => admin.listen(0, '127.0.0.1', r));
|
||||
const res = await fetch(`http://127.0.0.1:${admin.address().port}/a/admin/sites/${KEY}`, {
|
||||
method: 'PUT', headers: { Authorization: 'Bearer geheim', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'p1', domains: [HOST], active: false }),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(await pre(), null);
|
||||
admin.close();
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('X-Forwarded-For: rechtester Eintrag zählt für das Mengenlimit', async () => {
|
||||
const t = await setup();
|
||||
for (let i = 0; i < 120; i++) await t.post(pv(), { left: `10.0.0.${i % 250}` }); // links variiert, rechts gleich
|
||||
assert.equal(t.rows().length, 120);
|
||||
await t.post(pv(), { left: '10.9.9.9' }); // gleicher Bucket ⇒ verworfen
|
||||
assert.equal(t.rows().length, 120);
|
||||
await t.post(pv(), { left: '1.2.3.4', ip: '198.51.100.9' }); // rechts anders ⇒ eigener Bucket
|
||||
assert.equal(t.rows().length, 121);
|
||||
t.close();
|
||||
});
|
||||
@@ -0,0 +1,168 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../src/config.js';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
|
||||
async function setup(adminToken = 'geheim', make = createAdminServer) {
|
||||
const db = openDb(':memory:');
|
||||
const server = make({ config: { adminToken }, db });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const call = async (method, path, body, token = 'geheim', raw) => {
|
||||
const headers = {};
|
||||
if (token !== null) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined || raw !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(base + path, { method, headers, body: raw ?? (body === undefined ? undefined : JSON.stringify(body)) });
|
||||
return { status: res.status, body: await res.json() };
|
||||
};
|
||||
return { db, call, close: () => server.close() };
|
||||
}
|
||||
const row = (db, k = KEY) => db.prepare('SELECT * FROM sites WHERE siteKey = ?').get(k);
|
||||
const valid = { projectId: 'p1', domains: ['abc.project.webklar.com'], active: true };
|
||||
|
||||
test('loadConfig: Vorgaben und Umgebung', () => {
|
||||
const c = loadConfig({});
|
||||
assert.deepEqual(c, { port: 8090, adminPort: 8091, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' });
|
||||
const d = loadConfig({ ANALYTICS_PORT: '9000', ANALYTICS_ADMIN_TOKEN: 't', ANALYTICS_HEAT_SECRET: 'h' });
|
||||
assert.equal(d.port, 9000);
|
||||
assert.equal(d.adminToken, 't');
|
||||
assert.equal(d.heatSecret, 'h');
|
||||
});
|
||||
|
||||
test('openDb ist idempotent und legt Tabellen an', () => {
|
||||
const db = openDb(':memory:');
|
||||
const names = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name);
|
||||
for (const n of ['sites', 'events_raw', 'clicks', 'daily']) assert.ok(names.includes(n), n);
|
||||
});
|
||||
|
||||
test('GET /a/health ohne Token', async () => {
|
||||
const t = await setup();
|
||||
const r = await t.call('GET', '/a/health', undefined, null);
|
||||
assert.equal(r.status, 200);
|
||||
assert.deepEqual(r.body, { ok: true });
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Admin: 401 ohne, mit falschem Token und bei leerem adminToken', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, null)).status, 401);
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, 'falsch')).status, 401);
|
||||
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`, undefined, 'falsch')).status, 401);
|
||||
assert.equal(row(t.db), undefined);
|
||||
t.close();
|
||||
const e = await setup('');
|
||||
assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, '')).status, 401);
|
||||
assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, 'x')).status, 401);
|
||||
e.close();
|
||||
});
|
||||
|
||||
test('PUT legt an und ändert', async () => {
|
||||
const t = await setup();
|
||||
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.ok, true);
|
||||
let s = row(t.db);
|
||||
assert.equal(s.projectId, 'p1');
|
||||
assert.deepEqual(JSON.parse(s.domains), ['abc.project.webklar.com']);
|
||||
assert.equal(s.active, 1);
|
||||
assert.equal(s.offSince, null);
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, { projectId: 'p2', domains: ['a.de', 'b.de'], active: true });
|
||||
s = row(t.db);
|
||||
assert.equal(s.projectId, 'p2');
|
||||
assert.deepEqual(JSON.parse(s.domains), ['a.de', 'b.de']);
|
||||
assert.equal(t.db.prepare('SELECT COUNT(*) c FROM sites').get().c, 1);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('active=false setzt offSince einmalig, active=true löscht es', async () => {
|
||||
const t = await setup();
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false });
|
||||
const s1 = row(t.db);
|
||||
assert.equal(s1.active, 0);
|
||||
assert.ok(!Number.isNaN(Date.parse(s1.offSince)));
|
||||
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
|
||||
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false });
|
||||
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
assert.equal(row(t.db).offSince, null);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('PUT: ungültiger siteKey 400', async () => {
|
||||
const t = await setup();
|
||||
for (const k of ['kurz', 'ABCDEF0123456789ABCD', 'abcdef0123456789abc!', 'a'.repeat(41)]) {
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${k}`, valid)).status, 400, k);
|
||||
}
|
||||
assert.equal((await t.call('DELETE', '/a/admin/sites/kurz')).status, 400);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('PUT: Domains nur Hostnamen, max 5; projectId/active geprüft', async () => {
|
||||
const t = await setup();
|
||||
const bad = [
|
||||
['a.de:8080'], ['a.de/pfad'], ['https://a.de'], ['A.de'], ['localhost'], [''], ['-a.de'], [1], 'a.de', [],
|
||||
['a.de', 'b.de', 'c.de', 'd.de', 'e.de', 'f.de'],
|
||||
];
|
||||
for (const domains of bad) {
|
||||
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains });
|
||||
assert.equal(r.status, 400, JSON.stringify(domains));
|
||||
assert.equal(typeof r.body.error, 'string');
|
||||
}
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: '' })).status, 400);
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: 'ja' })).status, 400);
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, undefined, 'geheim', '{kaputt')).status, 400);
|
||||
assert.equal(row(t.db), undefined);
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'b.de', 'c.de', 'd.de', 'e.de'] })).status, 200);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('PUT: zu großer Body 413', async () => {
|
||||
const t = await setup();
|
||||
const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: 'x'.repeat(70000) });
|
||||
assert.equal(r.status, 413);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('DELETE entfernt Website, unbekannte Route 404', async () => {
|
||||
const t = await setup();
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 200);
|
||||
assert.equal(row(t.db), undefined);
|
||||
assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Admin-Route auf dem öffentlichen Listener: 404 auch mit richtigem Token', async () => {
|
||||
const t = await setup('geheim', createPublicServer);
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid)).status, 404);
|
||||
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 404);
|
||||
assert.equal(row(t.db), undefined);
|
||||
assert.equal((await t.call('GET', '/a/health', undefined, null)).status, 200);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Doppelte Domains: 400', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'a.de'] })).status, 400);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
|
||||
const f = join(mkdtempSync(join(tmpdir(), 'ana-')), 'a.db');
|
||||
const d1 = openDb(f);
|
||||
d1.prepare("INSERT INTO sites VALUES ('k','p','[]',1,NULL)").run();
|
||||
assert.equal(d1.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||
d1.close();
|
||||
const d2 = openDb(f);
|
||||
assert.equal(d2.prepare('SELECT COUNT(*) c FROM sites').get().c, 1);
|
||||
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||
d2.close();
|
||||
});
|
||||
@@ -0,0 +1,206 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { runNightly, nextRunAt, dayOf, scheduleNightly } from '../src/jobs.js';
|
||||
import { createAdminServer } from '../src/server.js';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const OTHER = 'zzzzzzzz0123456789zz';
|
||||
const NOW = Date.UTC(2026, 9, 10, 10, 0, 0); // 10.10.2026 12:00 Berlin
|
||||
const H = 3600 * 1000;
|
||||
const D = 24 * H;
|
||||
|
||||
function mk() {
|
||||
const db = openDb(':memory:');
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '["a.de"]');
|
||||
const ev = (o) => db.prepare(`INSERT INTO events_raw (site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)
|
||||
VALUES (@site,@ts,@type,@path,@vp,@ref,@country,@device,@visitor,@sec,@scroll,@kind,@host)`)
|
||||
.run({ site: KEY, path: null, vp: null, ref: null, country: null, device: null, visitor: null, sec: null, scroll: null, kind: null, host: null, ...o });
|
||||
return { db, ev };
|
||||
}
|
||||
const daily = (db, site = KEY) => db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? ORDER BY day, dim, key').all(site);
|
||||
const get = (db, day, dim, key = '') => db.prepare('SELECT count FROM daily WHERE site=? AND day=? AND dim=? AND key=?').get(KEY, day, dim, key)?.count;
|
||||
|
||||
test('Verdichtung: Dimensionen, nur Tage < heute, idempotent', () => {
|
||||
const { db, ev } = mk();
|
||||
const t = NOW - D; // 9.10.
|
||||
ev({ ts: t, type: 'pv', path: '/', vp: 'd', ref: 'google.com', country: 'DE', device: 'd' });
|
||||
ev({ ts: t + 1, type: 'pv', path: '/', vp: 'm', ref: null, country: 'DE', device: 'm' });
|
||||
ev({ ts: t + 2, type: 'pv', path: '/x', vp: 'd', ref: 'google.com', country: 'AT', device: 'd' });
|
||||
ev({ ts: t + 3, type: 'en', path: '/', vp: 'd', visitor: 'v1', sec: 10, scroll: 50 });
|
||||
ev({ ts: t + 4, type: 'en', path: '/x', vp: 'd', visitor: 'v1', sec: 20, scroll: 70 });
|
||||
ev({ ts: t + 5, type: 'ct', path: '/', visitor: 'v2', kind: 'tel' });
|
||||
ev({ ts: t + 6, type: 'ct', path: '/', visitor: 'v2', kind: 'out', host: 'x.de' });
|
||||
ev({ ts: t + 7, type: 'bn', kind: 'shown' });
|
||||
ev({ ts: t + 8, type: 'bn', kind: 'accept' });
|
||||
ev({ ts: NOW, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // heute: unberührt
|
||||
runNightly(db, { now: NOW });
|
||||
const d = '2026-10-09';
|
||||
assert.equal(get(db, d, 'pv_total'), 3);
|
||||
assert.equal(get(db, d, 'pv_path', '/'), 2);
|
||||
assert.equal(get(db, d, 'pv_path', '/x'), 1);
|
||||
assert.equal(get(db, d, 'pv_ref', 'google.com'), 2);
|
||||
assert.equal(get(db, d, 'pv_ref', ''), 1);
|
||||
assert.equal(get(db, d, 'pv_country', 'DE'), 2);
|
||||
assert.equal(get(db, d, 'pv_device', 'm'), 1);
|
||||
assert.equal(get(db, d, 'visitors'), 2); // v1, v2
|
||||
assert.equal(get(db, d, 'en_count'), 2);
|
||||
assert.equal(get(db, d, 'en_sec_sum'), 30);
|
||||
assert.equal(get(db, d, 'scroll_sum'), 120);
|
||||
assert.equal(get(db, d, 'ct_kind', 'tel'), 1);
|
||||
assert.equal(get(db, d, 'ct_kind', 'out'), 1);
|
||||
assert.equal(get(db, d, 'bn_shown'), 1);
|
||||
assert.equal(get(db, d, 'bn_accept'), 1);
|
||||
assert.equal(get(db, d, 'bn_reject'), undefined);
|
||||
assert.equal(db.prepare("SELECT COUNT(*) c FROM daily WHERE day = '2026-10-10'").get().c, 0);
|
||||
const before = daily(db);
|
||||
runNightly(db, { now: NOW });
|
||||
runNightly(db, { now: NOW + H });
|
||||
assert.deepEqual(daily(db), before);
|
||||
});
|
||||
|
||||
test('DST: 23:30 UTC im Sommer gehört zum nächsten Berlin-Tag', () => {
|
||||
const { db, ev } = mk();
|
||||
ev({ ts: Date.UTC(2026, 6, 14, 23, 30), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 15.7. 01:30 Berlin
|
||||
ev({ ts: Date.UTC(2026, 6, 14, 21, 59), type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' }); // 14.7. 23:59 Berlin
|
||||
assert.equal(dayOf(Date.UTC(2026, 6, 14, 23, 30)), '2026-07-15');
|
||||
runNightly(db, { now: Date.UTC(2026, 6, 16, 10) });
|
||||
assert.equal(get(db, '2026-07-14', 'pv_total'), 1);
|
||||
assert.equal(get(db, '2026-07-15', 'pv_total'), 1);
|
||||
// Winter: 23:30 UTC = 00:30 Berlin am Folgetag
|
||||
assert.equal(dayOf(Date.UTC(2026, 0, 14, 23, 30)), '2026-01-15');
|
||||
});
|
||||
|
||||
test('Aufbewahrung: Rohdaten und Klicks > 30 Tage weg, vorher verdichtet', () => {
|
||||
const { db, ev } = mk();
|
||||
ev({ ts: NOW - 31 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
|
||||
ev({ ts: NOW - 29 * D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
|
||||
const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,0.5,0.5)');
|
||||
ins.run(KEY, NOW - 31 * D, '/', 'd'); ins.run(KEY, NOW - 29 * D, '/', 'd');
|
||||
runNightly(db, { now: NOW });
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM clicks').get().c, 1);
|
||||
assert.equal(get(db, dayOf(NOW - 31 * D), 'pv_total'), 1); // in daily erhalten
|
||||
});
|
||||
|
||||
test('Website mit offSince > 60 Tage wird komplett gelöscht', () => {
|
||||
const { db, ev } = mk();
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?,?,?,0,?)')
|
||||
.run(OTHER, 'q', '["b.de"]', new Date(NOW - 61 * D).toISOString());
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?,?,?,0,?)')
|
||||
.run('young0123456789abcdef', 'r', '["c.de"]', new Date(NOW - 59 * D).toISOString());
|
||||
ev({ site: OTHER, ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
|
||||
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
|
||||
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,0.1,0.1)').run(OTHER, NOW - D, '/', 'd');
|
||||
runNightly(db, { now: NOW });
|
||||
for (const t of ['sites', 'events_raw', 'clicks', 'daily', 'agg_state']) {
|
||||
const col = t === 'sites' ? 'siteKey' : 'site';
|
||||
assert.equal(db.prepare(`SELECT COUNT(*) c FROM ${t} WHERE ${col} = ?`).get(OTHER).c, 0, t);
|
||||
}
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM sites').get().c, 2); // KEY + junge
|
||||
assert.ok(get(db, dayOf(NOW - D), 'pv_total'));
|
||||
});
|
||||
|
||||
test('nextRunAt: 02:30 Berlin (Sommer/Winter)', () => {
|
||||
assert.equal(new Date(nextRunAt(Date.UTC(2026, 9, 1, 10))).toISOString(), '2026-10-02T00:30:00.000Z');
|
||||
assert.equal(new Date(nextRunAt(Date.UTC(2026, 9, 1, 0, 0))).toISOString(), '2026-10-01T00:30:00.000Z');
|
||||
assert.equal(new Date(nextRunAt(Date.UTC(2026, 0, 1, 12))).toISOString(), '2026-01-02T01:30:00.000Z');
|
||||
});
|
||||
|
||||
async function admin(db, now = () => NOW) {
|
||||
const server = createAdminServer({ config: { adminToken: 'tok' }, db, now });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
const base = `http://127.0.0.1:${server.address().port}`;
|
||||
const get = (p, auth = 'Bearer tok') => fetch(base + p, { headers: auth ? { Authorization: auth } : {} });
|
||||
return { get, close: () => server.close() };
|
||||
}
|
||||
|
||||
test('Stats-Endpunkt: Form, Serie lückenlos, heute live + Vergangenheit aus daily, consentRate', async () => {
|
||||
const { db, ev } = mk();
|
||||
const y = NOW - D;
|
||||
for (let i = 0; i < 3; i++) ev({ ts: y + i, type: 'pv', path: '/a', vp: 'd', ref: 'g.de', country: 'DE', device: 'd' });
|
||||
ev({ ts: y + 10, type: 'en', path: '/a', vp: 'd', visitor: 'v1', sec: 5, scroll: 5 });
|
||||
ev({ ts: y + 11, type: 'bn', kind: 'accept' });
|
||||
runNightly(db, { now: NOW }); // gestern -> daily
|
||||
ev({ ts: NOW - H, type: 'pv', path: '/b', vp: 'm', country: 'AT', device: 'm' }); // heute live
|
||||
ev({ ts: NOW - H, type: 'ct', path: '/b', visitor: 'v9', kind: 'mail' });
|
||||
ev({ ts: NOW - H, type: 'bn', kind: 'accept' });
|
||||
ev({ ts: NOW - H, type: 'bn', kind: 'reject' });
|
||||
ev({ ts: NOW - H, type: 'bn', kind: 'reject' });
|
||||
ev({ ts: NOW - H, type: 'bn', kind: 'shown' });
|
||||
const a = await admin(db);
|
||||
const r = await a.get(`/a/admin/stats/${KEY}?range=7`);
|
||||
assert.equal(r.status, 200);
|
||||
const j = await r.json();
|
||||
assert.deepEqual(Object.keys(j).sort(), ['banner', 'contacts', 'countries', 'devices', 'pages', 'refs', 'series', 'totals']);
|
||||
assert.equal(j.series.length, 7);
|
||||
assert.equal(j.series[0].day, '2026-10-04');
|
||||
assert.equal(j.series[6].day, '2026-10-10');
|
||||
assert.deepEqual(j.series.map((s) => s.pv), [0, 0, 0, 0, 0, 3, 1]);
|
||||
assert.deepEqual(j.totals, { pv: 4, visitors: 2, contacts: 1, consentRate: 2 / 4 });
|
||||
assert.deepEqual(j.pages, [{ key: '/a', count: 3 }, { key: '/b', count: 1 }]);
|
||||
assert.deepEqual(j.refs[0], { key: 'g.de', count: 3 });
|
||||
assert.deepEqual(j.countries, [{ key: 'DE', count: 3 }, { key: 'AT', count: 1 }]);
|
||||
assert.deepEqual(j.devices, [{ key: 'd', count: 3 }, { key: 'm', count: 1 }]);
|
||||
assert.deepEqual(j.contacts, [{ key: 'mail', count: 1 }]);
|
||||
assert.deepEqual(j.banner, { shown: 1, accept: 2, reject: 2 });
|
||||
// range 90: 90 Tage
|
||||
const j90 = await (await a.get(`/a/admin/stats/${KEY}?range=90`)).json();
|
||||
assert.equal(j90.series.length, 90);
|
||||
assert.equal(j90.totals.pv, 4);
|
||||
a.close();
|
||||
});
|
||||
|
||||
test('Stats: gestern noch nicht verdichtet wird live gezählt, nie doppelt', async () => {
|
||||
const { db, ev } = mk();
|
||||
ev({ ts: NOW - D, type: 'pv', path: '/', vp: 'd', country: 'DE', device: 'd' });
|
||||
const a = await admin(db);
|
||||
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
|
||||
runNightly(db, { now: NOW });
|
||||
assert.equal((await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json()).totals.pv, 1);
|
||||
a.close();
|
||||
});
|
||||
|
||||
test('Stats: Top 20 absteigend, consentRate 0 ohne Entscheidungen, Fehlerfälle', async () => {
|
||||
const { db, ev } = mk();
|
||||
for (let p = 0; p < 25; p++) for (let i = 0; i <= p; i++) ev({ ts: NOW - 1000, type: 'pv', path: `/p${p}`, vp: 'd', country: 'DE', device: 'd' });
|
||||
const a = await admin(db);
|
||||
const j = await (await a.get(`/a/admin/stats/${KEY}?range=30`)).json();
|
||||
assert.equal(j.pages.length, 20);
|
||||
assert.equal(j.pages[0].key, '/p24');
|
||||
assert.ok(j.pages.every((e, i, arr) => !i || arr[i - 1].count >= e.count));
|
||||
assert.equal(j.totals.consentRate, 0);
|
||||
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=5`)).status, 400);
|
||||
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=abc`)).status, 400);
|
||||
assert.equal((await a.get(`/a/admin/stats/${OTHER}?range=7`)).status, 404);
|
||||
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=7`, null)).status, 401);
|
||||
assert.equal((await a.get(`/a/admin/stats/${KEY}?range=7`, 'Bearer nope')).status, 401);
|
||||
a.close();
|
||||
});
|
||||
|
||||
test('scheduleNightly: früh feuernder Timer läuft nicht vorzeitig und nie doppelt für dasselbe 02:30', () => {
|
||||
let t = Date.UTC(2026, 9, 10, 10, 0, 0);
|
||||
const pending = [];
|
||||
const timers = { setTimeout: (fn, ms) => { const h = { fn, at: t + ms }; pending.push(h); return h; }, clearTimeout: (h) => { const i = pending.indexOf(h); if (i >= 0) pending.splice(i, 1); } };
|
||||
const runs = [];
|
||||
const quiet = { log() {}, error() {} };
|
||||
const s = scheduleNightly({}, { startDelayMs: 1000, log: quiet, now: () => t, timers, job: (db, o) => { runs.push(o.now); return { aggregated: 0, sitesDeleted: 0 }; } });
|
||||
const fire = (clock) => { const h = pending.shift(); t = clock ?? h.at; h.fn(); };
|
||||
fire(); // Startlauf
|
||||
assert.equal(runs.length, 1);
|
||||
const target = nextRunAt(t);
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].at, target);
|
||||
fire(target - 500); // Timer feuert 500 ms zu früh
|
||||
assert.equal(runs.length, 1, 'kein vorzeitiger Lauf');
|
||||
assert.equal(pending.length, 1);
|
||||
assert.equal(pending[0].at, target, 'neu auf das Ziel gesetzt');
|
||||
fire(target); // pünktlich
|
||||
assert.equal(runs.length, 2);
|
||||
assert.equal(pending[0].at, nextRunAt(target + 1000));
|
||||
assert.ok(pending[0].at > target + 20 * 3600000, 'nächster Lauf am Folgetag');
|
||||
fire(pending[0].at - 1); // wieder zu früh
|
||||
assert.equal(runs.length, 2);
|
||||
s.stop();
|
||||
assert.equal(pending.length, 0);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
# Ergänzung der Abo-Bedingungen: Website-Analyse (Vorschlag)
|
||||
|
||||
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** `public/abo-bedingungen.html` wird NICHT geändert; Nutzer lädt die neue Fassung in WOMS „Rechtliches“ hoch.
|
||||
|
||||
Bezug: aktuelle Abo-Bedingungen (Aufbau § 1 bis § 16, Leistungsbeschreibung B = Wartungs-Abo, Anlagen 1 bis 3). Die Paragraphenfolge bleibt unverändert, es entstehen keine neuen Verweise in anderen §§.
|
||||
|
||||
## 1. Wartungs-Abo, Nr. 1 „Umfang“: neuer Spiegelstrich (nach „Servicezeiten und Reaktion“)
|
||||
|
||||
```diff
|
||||
tägliche Datensicherung (§ 3 Abs. 5)
|
||||
1 Stunde Arbeitszeit je bezahltem Monat; nicht genutzte Zeit bleibt 12 Monate verfügbar (§ 4)
|
||||
Servicezeiten und Reaktion (§ 3 Abs. 6)
|
||||
+ Website-Analyse für öffentliche Websites auf Wunsch des Kunden (Leistungsbeschreibung „Website-Analyse“, § 3 Abs. 8)
|
||||
```
|
||||
|
||||
## 2. § 3 Leistungen: neuer Absatz (8) nach Absatz (7)
|
||||
|
||||
```diff
|
||||
+ (8) Website-Analyse
|
||||
+ Im Wartungs-Abo kann der Kunde im Kundenportal für seine öffentliche, bei uns gehostete Website eine
|
||||
+ Reichweitenmessung mit Cookie-Banner einschalten. Umfang, Voraussetzungen, Pflichten des Kunden und Ende
|
||||
+ der Leistung ergeben sich aus der Leistungsbeschreibung „Website-Analyse“. Die Website-Analyse ist im
|
||||
+ Hosting-Abo nicht enthalten. Wir schulden dafür keine bestimmte Verfügbarkeit; ein Ausfall der Analyse
|
||||
+ beeinträchtigt die Website nicht. Sie endet mit dem Ausschalten durch den Kunden oder mit dem Ende des
|
||||
+ Wartungs-Abos; die Daten löschen wir 60 Tage danach.
|
||||
```
|
||||
|
||||
## 3. § 6 Pflichten des Kunden: neuer Absatz nach dem Absatz zu Impressum und Datenschutzerklärung
|
||||
|
||||
```diff
|
||||
Für Impressum, Datenschutzerklärung und Inhalte seiner Website ist der Kunde verantwortlich. Die Angaben zum Hosting, ... stehen in den Anlagen 1 bis 3.
|
||||
+ Schaltet der Kunde die Website-Analyse ein, ist er für die Messung auf seiner Website Verantwortlicher. Er
|
||||
+ entfernt oder umgeht das Cookie-Banner nicht, bindet ohne Abstimmung mit uns keine weiteren Mess- oder
|
||||
+ Tracking-Werkzeuge ein und ergänzt seine Datenschutzerklärung um einen Hinweis auf die von uns erzeugte
|
||||
+ Zusatzseite „/datenschutz-analyse.html“.
|
||||
```
|
||||
|
||||
## 4. § 11 Vertragsende: neuer Satz am Ende
|
||||
|
||||
```diff
|
||||
+ Mit dem Vertragsende endet auch die Website-Analyse; wir speichern dann keine Messdaten mehr, entfernen
|
||||
+ Skript und Banner beim nächsten Veröffentlichen der Website und löschen die Messdaten 60 Tage nach Abschaltung.
|
||||
```
|
||||
|
||||
## 5. § 14 Datenschutz, Absatz (2): Ergänzung
|
||||
|
||||
```diff
|
||||
- Soweit wir im Rahmen eines Abos personenbezogene Daten im Auftrag des Kunden verarbeiten, etwa E-Mails in seinen Postfächern, Eingaben in Kontaktformulare oder Server-Protokolle seiner Website, gilt der Auftragsverarbeitungsvertrag in Anlage 1.
|
||||
+ Soweit wir im Rahmen eines Abos personenbezogene Daten im Auftrag des Kunden verarbeiten, etwa E-Mails in seinen Postfächern, Eingaben in Kontaktformulare oder Server-Protokolle seiner Website, gilt der Auftragsverarbeitungsvertrag in Anlage 1. Für die Website-Analyse gilt zusätzlich der Auftragsverarbeitungsvertrag Website-Analyse, dem der Kunde beim Einschalten im Kundenportal zustimmt.
|
||||
```
|
||||
|
||||
## 6. Anlage 3 Unterauftragsverarbeiter: neue Zeile
|
||||
|
||||
```diff
|
||||
Hetzner Online GmbH ... | E-Mail-Postfächer (Webhosting) | Deutschland (Rechenzentrum Nürnberg)
|
||||
+ Hetzner Online GmbH ... | Server für die Website-Analyse (Messdienst, nur bei eingeschalteter Funktion) | Deutschland (Rechenzentrum Falkenstein)
|
||||
```
|
||||
|
||||
## 7. Fassung und Datum
|
||||
|
||||
Neue Fassung der Abo-Bedingungen mit neuem Stand-Datum hochladen; § 15 (Änderungen dieser Bedingungen) regelt die Mitteilung. Die Website-Analyse ist eine zusätzliche Leistung ohne Aufpreis, erhöht Preise nicht und greift nicht in bestehende Leistungen ein.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P10: Wirksame Einbeziehung der geänderten Fassung bei Bestandskunden (§ 15, § 308 Nr. 5 / § 307 BGB); Wettbewerbs-/Transparenzfragen zur kostenlosen Zusatzleistung. -->
|
||||
<!-- Prüfpunkt Anwalt P14: Verweis Wartungs-Abo (Leistungsbeschreibung B) und Hosting-Abo-Ausschluss konsistent mit § 9 Wechsel. -->
|
||||
@@ -0,0 +1,132 @@
|
||||
# Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)
|
||||
|
||||
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung. Stellen mit „Prüfpunkt Anwalt“ sind offen.
|
||||
|
||||
**Fassung 2026-10-01**
|
||||
|
||||
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
|
||||
|
||||
Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Er wird vereinbart, wenn der Kunde im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei die Zustimmung zu dieser Fassung bestätigt (Haken im Portal; das Portal speichert Fassung, Zeitpunkt und Person). Schaltet ein Mitarbeiter von WEBklar die Funktion auf Wunsch des Kunden ein, bestätigt der Mitarbeiter im System, dass der Kunde diese Fassung des Vertrags bestätigt hat. Gespeichert werden die Fassung, der Zeitpunkt und der Name des Mitarbeiters. Bei Zustimmung im Kundenportal wird die Zustimmung dem Kundenkonto zugeordnet.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P11: Wirksamer Abschluss durch Portal-Haken (Textform, Art. 28 Abs. 9 DSGVO) und mündliche Bestätigung durch Mitarbeiter; Nachweis der Fassung. Reicht die Bestätigung durch den Mitarbeiter (nur Fassung, Zeitpunkt, Name; keine Textform-Bestätigung des Kunden gespeichert) für den Abschluss? -->
|
||||
<!-- Prüfpunkt Anwalt P6: Rollenverteilung. Kunde = Verantwortlicher, WEBklar = Auftragsverarbeiter; keine gemeinsame Verantwortlichkeit (Art. 26), weil WEBklar die Daten nur für den Kunden verwendet. Prüfen, ob die Auswertung in Aggregaten für WEBklar-Produktverbesserung ausgeschlossen bleiben soll (Ja, siehe Nr. 3). -->
|
||||
|
||||
## 1. Gegenstand und Dauer
|
||||
|
||||
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Die Leistung ist in der Leistungsbeschreibung „Website-Analyse“ beschrieben.
|
||||
|
||||
(2) Der Vertrag beginnt mit dem Einschalten der Funktion und gilt, solange die Funktion eingeschaltet ist. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos, und gilt darüber hinaus, bis alle Daten nach Nummer 9 gelöscht sind.
|
||||
|
||||
## 2. Art und Zweck der Verarbeitung
|
||||
|
||||
(1) Art: Erheben über ein Skript, das WEBklar in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
|
||||
|
||||
(2) Zweck: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Andere Zwecke verfolgt WEBklar mit diesen Daten nicht. Es findet kein Profiling, keine Werbung und keine Zusammenführung mit anderen Daten statt.
|
||||
|
||||
## 3. Datenarten und Betroffene
|
||||
|
||||
(1) Datenarten ohne Einwilligung des Besuchers: Seitenaufruf mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (abgeleitet aus der IP-Adresse; die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse (Handy, Tablet, Computer) und Größenklasse des Browserfensters (vp), anonyme Zähler zur Anzeige, Annahme und Ablehnung des Banners.
|
||||
|
||||
(2) Zusätzlich nach Annehmen des Banners durch den Besucher: eine täglich wechselnde pseudonyme Besucher-Kennung (nicht über mehrere Tage verknüpfbar), Verweildauer und Scrolltiefe je Seite, Klickpositionen mit vereinfachtem Elementpfad (Heatmap, keine Formulareingaben), Art des Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit Domainname).
|
||||
|
||||
(3) Nicht gespeichert werden IP-Adressen und Browser-Kennzeichen (User-Agent); sie werden nur kurzzeitig im Arbeitsspeicher verarbeitet (siehe Anlage). Nicht erhoben werden Cookies, Formularinhalte, Seiteninhalte, Mausbewegungen oder Sitzungsaufzeichnungen. Besondere Kategorien nach Art. 9 DSGVO werden nicht gezielt verarbeitet.
|
||||
|
||||
(4) Betroffene: Besucher der Website des Kunden.
|
||||
|
||||
(5) WEBklar verwendet die Daten ausschließlich für den Kunden und gibt sie nicht an Dritte weiter, außer an Unterauftragsverarbeiter nach Nummer 7.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P2: Einordnung der Tages-Kennung (pseudonym oder faktisch anonym, EuGH C-413/23 P) und der Klickpositionen; Konsequenzen für Betroffenenrechte (Nr. 8). -->
|
||||
|
||||
## 4. Weisungen
|
||||
|
||||
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt.
|
||||
|
||||
(2) Die Weisungen ergeben sich aus diesem Vertrag, der Leistungsbeschreibung und den Einstellungen des Kunden im Kundenportal (Einschalten und Ausschalten; die Akzentfarbe des Banners wird beim Einschalten gewählt). Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin und darf die Ausführung aussetzen.
|
||||
|
||||
## 5. Vertraulichkeit
|
||||
|
||||
WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.
|
||||
|
||||
## 6. Technische und organisatorische Maßnahmen
|
||||
|
||||
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
|
||||
|
||||
(2) Die Maßnahmen der Anlage 2 zu den Abo-Bedingungen gelten ergänzend.
|
||||
|
||||
## 7. Unterauftragsverarbeiter
|
||||
|
||||
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für die Datensicherung. Ort der Verarbeitung ist ein Rechenzentrum in Deutschland (Falkenstein). Die Daten werden nicht in Drittländer übermittelt.
|
||||
|
||||
(2) Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll.
|
||||
|
||||
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.
|
||||
|
||||
(4) Die Länderzuordnung nutzt eine lokal gespeicherte Datenbank („DB-IP Lite Country“, Lizenz CC BY 4.0). Dabei werden keine Daten an den Anbieter der Datenbank übermittelt; er ist kein Unterauftragsverarbeiter.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P13: Gültiger Auftragsverarbeitungsvertrag mit Hetzner besteht (laut Datenschutzerklärung ja); Standort Falkenstein bestätigen. P12: Einordnung DB-IP als Datenbank-Lizenz ohne Datenübermittlung. -->
|
||||
|
||||
## 8. Unterstützung des Kunden, Betroffenenrechte
|
||||
|
||||
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter und unterstützt ihn bei der Beantwortung, soweit das mit den eingesetzten Systemen möglich ist.
|
||||
|
||||
(2) Die gespeicherten Daten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die über einen Tag hinaus gilt. WEBklar kann einzelne Besucher daher in der Regel nicht identifizieren und Auskunfts-, Berichtigungs- oder Löschverlangen zu einzelnen Besuchen nicht umsetzen, solange die betroffene Person keine zusätzlichen Angaben bereitstellt (Art. 11 DSGVO). Der Kunde weist Betroffene darauf in seiner Datenschutzerklärung hin.
|
||||
|
||||
(3) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO.
|
||||
|
||||
(4) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
|
||||
|
||||
(5) Der Kunde ist dafür verantwortlich, dass die Besucher seiner Website informiert werden (Art. 13 DSGVO). WEBklar stellt dafür die Zusatzseite „/datenschutz-analyse.html“ bereit. Der Kunde ergänzt seine eigene Datenschutzerklärung um einen Hinweis darauf.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P18: Betroffenenrechte bei nicht identifizierbaren Daten (Art. 11, Art. 12 Abs. 2); Formulierung von Absatz 2. P15: Reicht die Zusatzseite für Art. 13, wenn der Kunde seine Datenschutzerklärung nicht anpasst? -->
|
||||
|
||||
## 9. Löschung und Rückgabe
|
||||
|
||||
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Zusammengefasste Tageswerte ohne Besucher-Kennung bleiben bis zum Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) und weitere 60 Tage gespeichert; danach löscht WEBklar alle Daten der Website. Eine Kopie der Tageswerte stellt WEBklar dem Kunden auf Verlangen in Textform innerhalb dieser Frist zur Verfügung.
|
||||
|
||||
(2) Ob und wie lange Sicherungskopien Daten der Website-Analyse enthalten, ist noch zu klären (Prüfpunkt P8, siehe Anlage); soweit sie enthalten sind, werden sie im Rahmen der regulären Rotation überschrieben.
|
||||
|
||||
(3) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P9: Aufbewahrung der Tageswerte bis Abo-Ende + 60 Tage (Erforderlichkeit, Art. 28 Abs. 3 lit. g DSGVO: Löschung "nach Abschluss der Erbringung"); ggf. kürzer, falls Tageswerte nicht als personenbezogen gelten. P8: Sicherungen (siehe Anlage). -->
|
||||
|
||||
## 10. Nachweise und Kontrollen
|
||||
|
||||
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Prüfpunkt P13). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
|
||||
|
||||
## 11. Pflichten des Kunden
|
||||
|
||||
Der Kunde ist Verantwortlicher für die Verarbeitung. Er sorgt dafür, dass das Banner nicht entfernt oder verdeckt wird, dass keine weiteren Messwerkzeuge ohne Abstimmung eingebunden werden, und dass seine Datenschutzerklärung auf die Zusatzseite hinweist (Leistungsbeschreibung „Website-Analyse“).
|
||||
|
||||
## 12. Haftung und Schlussbestimmungen
|
||||
|
||||
(1) Für die Haftung gilt Art. 82 DSGVO. Im Übrigen gelten die Haftungsregeln der Abo-Bedingungen.
|
||||
|
||||
(2) Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Abo-Bedingungen und der Leistungsbeschreibung vor.
|
||||
|
||||
(3) Änderungen dieses Vertrags richten sich nach § 15 der Abo-Bedingungen; eine neue Fassung wird erst wirksam, wenn der Kunde sie im Portal bestätigt hat.
|
||||
|
||||
(4) Es gilt deutsches Recht.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P10: Haftungsverweis auf Abo-Bedingungen und Änderungsmechanismus (AGB-Kontrolle). -->
|
||||
|
||||
## Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)
|
||||
|
||||
| Bereich | Maßnahmen |
|
||||
|---|---|
|
||||
| Übertragung | Skript und Messdaten werden nur über TLS (HTTPS) übertragen. |
|
||||
| Keine Cookies | Es werden keine Cookies gesetzt. Auf dem Gerät des Besuchers wird nur die Banner-Auswahl im lokalen Speicher (Eintrag „wk-consent“) gespeichert; sie gilt sechs Monate. |
|
||||
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden nicht gespeichert und nicht protokolliert. Die IP-Adresse wird nur im Moment der Abfrage verwendet für: Länderzuordnung, Schlüssel der Mengenbegrenzung (Hash mit Tagessalz, nur im Arbeitsspeicher) und, nur nach Einwilligung, die tägliche Besucher-Kennung. Das Browser-Kennzeichen wird nur im Moment der Abfrage verwendet für: Geräteklasse, Bot-Erkennung und, nur nach Einwilligung, die tägliche Besucher-Kennung. Pfade ohne Suchparameter, Elementpfade ohne Kennungen und Klassennamen. Keine Formulareingaben. |
|
||||
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein gekürzter Hash mit Tagessalz. Das Salz liegt nur im Arbeitsspeicher und wird täglich um 00:00 Uhr (Europe/Berlin) neu erzeugt; ältere Kennungen sind danach nicht mehr verknüpfbar. |
|
||||
| Einwilligung | Ohne „Annehmen“ sendet das Skript nur Seitenaufruf und Banner-Zähler. „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen. |
|
||||
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem getrennten, internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff nur über das Kundenportal und die Verwaltung von WEBklar. Im Portal sieht jeder Kunde nur die Daten seiner eigenen Website. |
|
||||
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert ist und Origin bzw. Referer zu ihren Domains passen; Bots werden verworfen; Größen- und Mengenbegrenzung je Website und Anfrager (nur im Arbeitsspeicher). |
|
||||
| Heatmap-Zugriff | Die Heatmap wird über einen signierten Link abgerufen, der 30 Minuten gültig ist. |
|
||||
| Löschung | Täglicher automatischer Job (02:30 Uhr, Europe/Berlin): Rohdaten und Klicks nach 30 Tagen; Websites, die seit mehr als 60 Tagen abgeschaltet sind, werden vollständig gelöscht. |
|
||||
| Trennung | Daten sind je Website getrennt (Website-Schlüssel); eigener Container und eigenes Datenverzeichnis des Messdienstes; kein Zugriff auf andere Kundendaten. |
|
||||
| Rechenzentrum | Hetzner Online GmbH, Falkenstein (Deutschland). **[Prüfpunkt: Zertifizierung des Rechenzentrums (ISO/IEC 27001) vor Verwendung belegen, bevor sie genannt wird.]** |
|
||||
| Protokolle des Reverse-Proxys | Das Zugriffsprotokoll für die Messdienst-Adresse wird ohne Client-IP geführt. **[Prüfpunkt P7: Einstellung beim Livegang verifizieren.]** |
|
||||
| Datensicherung | Die tägliche Serversicherung (7 Tage) erfasst voraussichtlich auch das Datenverzeichnis des Messdienstes. **[Prüfpunkt P8: ob das Verzeichnis in der Sicherung enthalten ist und wie lange dort gelöschte Daten verbleiben, ist noch zu klären.]** |
|
||||
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nr. 8 Abs. 4), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
|
||||
|
||||
<!-- Prüfpunkt Anwalt P7 und P8: siehe Tabelle (Reverse-Proxy-Protokoll, Sicherung). -->
|
||||
<!-- Prüfpunkt Anwalt P4: Einwilligungsnachweis ist nur technisch (ohne Annehmen sendet das Skript nachweislich nichts Weiteres); es gibt kein Einwilligungsprotokoll je Besucher. Reicht das für Art. 7 Abs. 1 DSGVO? -->
|
||||
@@ -0,0 +1,9 @@
|
||||
# Prüfpunkte zur Vorlage datenschutz-analyse.html (intern, nicht veröffentlichen)
|
||||
|
||||
> Entwurf – vor Verwendung anwaltlich prüfen lassen.
|
||||
|
||||
- **P1** Zählung ohne Einwilligung (Seitenaufruf, Referrer-Domain, Land, Gerätegröße): kein Zugriff auf das Endgerät nach § 25 TDDDG? Das Skript liest Referrer und Fensterbreite aus; Einordnung nach strenger DSK-Lesart offen.
|
||||
- **P2** Tages-Kennung nach Einwilligung: pseudonym oder faktisch anonym?
|
||||
- **P3** Rechtsgrundlage des anonymen Banner-Zählers (angezeigt, angenommen, abgelehnt).
|
||||
- **P5** Speicherung der Banner-Wahl (auch der Ablehnung) im lokalen Speicher nach § 25 Abs. 2 Nr. 2 TDDDG; GPC/DNT wie Ablehnung.
|
||||
- **P18** Betroffenenrechte bei nicht identifizierbaren Daten (Art. 11, Art. 12 Abs. 2 DSGVO), Formulierung unter "Deine Rechte".
|
||||
@@ -0,0 +1,75 @@
|
||||
# Leistungsbeschreibung Website-Analyse
|
||||
|
||||
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung.
|
||||
|
||||
**Fassung 2026-10-01** · Teil der Abo-Bedingungen (Wartungs-Abo)
|
||||
|
||||
## 1. Leistung
|
||||
|
||||
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten und wird vom Kunden im Kundenportal eingeschaltet.
|
||||
|
||||
(2) WEBklar fügt beim Veröffentlichen der Website ein Messskript und ein Cookie-Banner (Leiste unten, „Ablehnen“ und „Annehmen“ gleichwertig, Akzentfarbe, die beim Einschalten gewählt wird, Link „Cookie-Einstellungen“) ein und erzeugt eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
|
||||
|
||||
(3) Gemessen wird:
|
||||
- **ohne Einwilligung des Besuchers:** Seitenaufrufe mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse sowie anonyme Banner-Zähler;
|
||||
- **nach „Annehmen“ zusätzlich:** eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap) und die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website).
|
||||
|
||||
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur dessen Banner-Auswahl im lokalen Speicher des Browsers (sechs Monate). Signale „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen.
|
||||
|
||||
(5) Die Auswertung (Kennzahlen, Diagramme, Heatmap) steht im Kundenportal zur Verfügung. Die Daten liegen auf Servern der Hetzner Online GmbH in Deutschland; es erfolgt keine Übermittlung in Drittländer. Rohdaten und Klicks werden nach 30 Tagen gelöscht, Tageswerte bis zum Ende der Analyse und weitere 60 Tage.
|
||||
|
||||
(6) Nicht enthalten sind Sitzungsaufzeichnungen, Auswertung von Formularinhalten, Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalyse sowie ein Datenexport (außer nach Nr. 6 Abs. 2).
|
||||
|
||||
## 2. Voraussetzungen
|
||||
|
||||
(1) Ein aktives Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.
|
||||
|
||||
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Login-geschützte Vorschauseiten werden nicht gemessen.
|
||||
|
||||
(3) Der Kunde stimmt beim Einschalten dem Auftragsverarbeitungsvertrag Website-Analyse in der aktuellen Fassung zu.
|
||||
|
||||
## 3. Pflichten des Kunden
|
||||
|
||||
(1) Der Kunde ist Verantwortlicher im Sinne der DSGVO für die Messung auf seiner Website.
|
||||
|
||||
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Er verändert den Banner-Text oder die Gestaltung nur nach Abstimmung mit WEBklar.
|
||||
|
||||
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics, Meta Pixel), weil sie eine eigene Einwilligung und eine Anpassung des Banners erfordern können.
|
||||
|
||||
(4) Er pflegt seine Datenschutzerklärung und ergänzt sie um einen Hinweis auf die Zusatzseite „/datenschutz-analyse.html“. Beim Einschalten legt WEBklar dafür ein Ticket an. Der Kunde prüft die Angaben zu Firma, Anschrift und E-Mail-Adresse, die auf der Zusatzseite erscheinen, und teilt Änderungen mit.
|
||||
|
||||
(5) Er richtet die Website nicht gezielt an Kinder und verarbeitet über die Website keine besonderen Kategorien personenbezogener Daten in einer Weise, die die Messung berührt, ohne vorher mit WEBklar zu sprechen.
|
||||
|
||||
(6) Er nutzt die Auswertung nur für eigene Zwecke und versucht nicht, einzelne Besucher zu identifizieren.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P19: Ausschluss von Websites mit Kinderzielgruppe; Datenschutz-Folgenabschätzung (laut Recherche meist nicht nötig). -->
|
||||
|
||||
## 4. Verfügbarkeit
|
||||
|
||||
(1) Für die Website-Analyse gilt keine Verfügbarkeitszusage. Ausfälle, Wartung oder Fehler der Messung können zu Lücken in den Daten führen; ein Anspruch auf nachträgliche Messung besteht nicht.
|
||||
|
||||
(2) Ein Ausfall der Website-Analyse beeinträchtigt die Website des Kunden nicht. Die Website wird weiter ausgeliefert, nur ohne Messung.
|
||||
|
||||
(3) Die Zahlen sind Näherungswerte. Besucher mit Blockern, deaktivierten Skripten oder abgelehnter Einwilligung werden nur eingeschränkt oder nicht erfasst. Zahlen zu Besuchern, Verweildauer, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P10: Haftungsausschluss/Verfügbarkeit in AGB (§§ 307 ff. BGB), Zusammenspiel mit § 5 und § 13 der Abo-Bedingungen. -->
|
||||
|
||||
## 5. Ende der Leistung
|
||||
|
||||
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten. Mit Ende des Wartungs-Abos (auch bei Wechsel ins Hosting-Abo) endet die Website-Analyse automatisch.
|
||||
|
||||
(2) Mit dem Abschalten werden keine Messdaten mehr gespeichert und die Heatmap ist nicht mehr abrufbar. Skript, Banner und Zusatzseite bleiben auf der Website sichtbar, bis die Website das nächste Mal veröffentlicht wird; dann entfernt WEBklar sie. Bei Websites ohne automatische Veröffentlichung geschieht das erst bei einer manuellen Veröffentlichung. WEBklar informiert den Kunden per E-Mail über die Abschaltung.
|
||||
|
||||
(3) Die gespeicherten Daten der Website löscht WEBklar 60 Tage nach der Abschaltung (Einzelheiten im Auftragsverarbeitungsvertrag). Der Kunde ergänzt seine Datenschutzerklärung nach der Abschaltung entsprechend.
|
||||
|
||||
## 6. Hinweise
|
||||
|
||||
(1) **Keine Rechtsberatung.** Die Zusatzseite und die Hinweise von WEBklar sind Muster und Unterstützung. Sie ersetzen keine Rechtsberatung. WEBklar schuldet keine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist. Der Kunde lässt seine Datenschutzerklärung bei Bedarf rechtlich prüfen.
|
||||
|
||||
(2) Auf Verlangen in Textform stellt WEBklar dem Kunden vor der Löschung eine Kopie der Tageswerte zur Verfügung.
|
||||
|
||||
(3) Die Länderzuordnung nutzt die Datenbank „DB-IP Lite Country“ von DB-IP.com (CC BY 4.0).
|
||||
|
||||
<!-- Prüfpunkt Anwalt P17: RDG – Abgrenzung der Mustertexte von Rechtsberatung; Haftung für Mustertext. -->
|
||||
<!-- Prüfpunkt Anwalt P1: Rechtliche Einordnung der Zählung ohne Einwilligung (§ 25 TDDDG, DSK-Lesart); Aussage in Nr. 1 Abs. 3 und Hinweis an den Kunden auf das Restrisiko. -->
|
||||
<!-- Prüfpunkt Anwalt P16: Die Leistungsbeschreibung beschreibt den Einwilligungsnachweis nur technisch; P4 siehe AVV. -->
|
||||
@@ -0,0 +1,137 @@
|
||||
# Website-Analyse – Umsetzungsplan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Kunden mit Wartungs-Abo schalten im Portal eine Website-Analyse ein; Banner + Skript werden vom
|
||||
Runner eingesetzt, ein eigener Sammeldienst zählt datenschutzkonform, das Portal zeigt Zahlen und Heatmap.
|
||||
|
||||
**Architecture:** Neuer Dienst `analytics/` (Node 24, `node:sqlite`, keine Abhängigkeiten) unter
|
||||
`https://project.webklar.com/a/…`; Client-Skripte `w.js`/`h.js` liefert er aus. Der Runner setzt das
|
||||
Skript beim Veröffentlichen ein. Das Portal verwaltet Freischaltung (portalFeatures), spricht den
|
||||
Sammeldienst per Admin-Token an und zeigt die Auswertung. WOMS bekommt einen Schalter.
|
||||
|
||||
**Tech Stack:** Node 24 (Sammeldienst), Node 20 (Portal/Runner), vanilla JS (Portal-UI, Client-Skript),
|
||||
React (WOMS), Playwright (Prüfung).
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-website-analyse-design.md`
|
||||
|
||||
**Worktrees:** Portal/Runner/Analytics `/home/webklar/apps/kundenbereich.webklar.com/dev-analyse`
|
||||
(Branch `feature/website-analyse`). WOMS: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add
|
||||
-b feature/website-analyse ../dev-analyse origin/test`, node_modules skip-worktree + eigenes `npm install`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Nur Wartungs-Abo: `productKey === 'wartung'` und Status ∈ `ACTIVE_STATUSES` (stripeBilling.js). Hosting ausgeschlossen.
|
||||
- Nur öffentliche Projekte (`isPublic === true`) mit Subdomain; Domains der Website = `<subdomain>.project.webklar.com` + `liveDomain` (falls gesetzt).
|
||||
- Ereignisse exakt laut Spec-Tabelle (`pv`, `bn`, `en`, `ck`, `ct`) mit genau den dort genannten Feldern; `bn` ohne weitere Felder; `en`/`ck`/`ct` nur nach Annehmen.
|
||||
- Keine IP, kein User-Agent gespeichert; keine Cookies; Banner-Wahl nur `localStorage['wk-consent'] = {v:1,a,t}`, 6 Monate gültig; GPC/DNT ⇒ wie Ablehnen ohne Banner.
|
||||
- Besucher-Kennung `HMAC-SHA256(tagesSalz, siteKey|ip|ua)` → 16 Hex, Salz nur RAM, Wechsel 00:00 Europe/Berlin.
|
||||
- Rohdaten/Klicks 30 Tage; Websites mit `offSince` > 60 Tage komplett löschen; Job täglich 02:30 Europe/Berlin.
|
||||
- Limits: Body ≤ 4 KB, path ≤ 300, sel ≤ 200, sec 0–3600, scroll 0–100, x/y 0–1, 120 Ereignisse/Min je (site, IP).
|
||||
- Banner: Leiste unten, „Ablehnen“ und „Annehmen“ gleich groß/gleich auffällig, Link „Datenschutz“ → `/datenschutz-analyse.html`; Akzentfarbe nur für „Annehmen“ (Kontrast ≥ 4,5:1, sonst Fallback `#111111`); Handy (< 768 px) kompakte Karte; Footer-Link „Cookie-Einstellungen“.
|
||||
- siteKey `^[a-z0-9]{16,40}$`, Farbe `^#[0-9a-f]{6}$`.
|
||||
- Heatmap-Link: HMAC-Token, 30 Minuten gültig, enthält siteKey, path, vp, exp.
|
||||
- Schalter `ANALYSE_ENABLED` (Vorgabe off) ⇒ Kachel bleibt „Kommt bald“, Routen 409.
|
||||
- Texte deutsch (Du-Form im Portal), kein `confirm()`. Nichts pushen/live schalten.
|
||||
|
||||
## Review Focus
|
||||
|
||||
1. Ohne „Annehmen“ verlässt kein `en`/`ck`/`ct` den Browser (auch nicht nach Widerruf). → Task 6
|
||||
2. Fremde Origin oder unbekannter siteKey ⇒ nichts gespeichert. → Task 2
|
||||
3. Analyse aus / Abo weg ⇒ nach Neubau kein Skript und keine Zusatzseite mehr im Ordner. → Task 7, 10
|
||||
4. Heatmap-Token einer Website liefert keine Punkte einer anderen Website. → Task 4
|
||||
5. Kontakt-Klick speichert nie Telefonnummer/E-Mail-Adresse. → Task 6
|
||||
|
||||
---
|
||||
|
||||
## Sammeldienst (`analytics/`)
|
||||
|
||||
### Task 1: Gerüst, Datenbank, Website-Register
|
||||
**Files:** `analytics/package.json` (type module, test `node --test test/*.test.js`), `src/config.js`, `src/db.js`, `src/server.js`, `src/index.js`, `test/sites.test.js`.
|
||||
**Produces:** `loadConfig(env) ⇒ { port: 8090, dataDir: '/srv/webklar-analytics', adminToken, heatSecret, geoFile, baseHost: 'project.webklar.com' }`; `openDb(path) ⇒ db` (Schema: `sites(siteKey PK, projectId, domains JSON, active INT, offSince TEXT)`, `events_raw(id, site, ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host)`, `clicks(id, site, ts, path, vp, x, y, sel)`, `daily(site, day, dim, key, count, PK(site,day,dim,key))`); Admin-Routen (Bearer adminToken, timingSafeEqual): `PUT /a/admin/sites/:siteKey {projectId, domains[], active}`, `DELETE /a/admin/sites/:siteKey`, `GET /a/health`.
|
||||
- [ ] Tests: 401 ohne/mit falschem Token; PUT legt an/ändert; active=false setzt offSince einmalig; ungültiger siteKey 400; Domains nur Hostnamen.
|
||||
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Geruest, Datenbank, Website-Register“
|
||||
|
||||
### Task 2: Annahme `POST /a/e`
|
||||
**Files:** `src/ingest.js`, `src/ua.js`, `src/salt.js`, `test/ingest.test.js`.
|
||||
**Produces:** `classifyUa(ua) ⇒ { bot:boolean, device:'m'|'t'|'d' }` (kleine eigene Regeln, gängige Bots); `createSalt({ now }) ⇒ { visitorId(site, ip, ua) }` (Rotation 00:00 Berlin); Handler: Body ≤ 4 KB text/plain JSON; Website aktiv + Origin/Referer-Host ∈ domains, sonst 204 ohne Speichern; Felder streng nach Global Constraints validieren/kürzen; Land über `geo.lookup(ip)` (Task 3, injizierbar); Limit 120/Min je (site, ip); CORS-Header nur für passende Origin; Antwort 204; nie `Set-Cookie`.
|
||||
- [ ] Tests: pv gespeichert ohne visitor; bn nur mit a; en/ck/ct mit visitor; fremde Origin ⇒ nichts; Bot ⇒ nichts; 121. Ereignis ⇒ verworfen; Query/Hash aus path entfernt; ref nur Host und nicht eigene Domain; kein IP/UA in der Datenbank (Tabelleninhalte prüfen); Salz wechselt um Mitternacht Berlin.
|
||||
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Ereignisse annehmen“
|
||||
|
||||
### Task 3: Länder (DB-IP Lite)
|
||||
**Files:** `src/geo.js`, `scripts/geo-update.sh` (lädt monatliche CSV `dbip-country-lite-YYYY-MM.csv.gz` nach `dataDir/geo.csv`), `test/geo.test.js`.
|
||||
**Produces:** `loadGeo(file) ⇒ { lookup(ip) ⇒ 'DE'|…|'' }` – IPv4 und IPv6 als BigInt-Bereiche, sortiert, Binärsuche; fehlende Datei ⇒ '' für alles.
|
||||
- [ ] Tests mit kleiner Fixture-CSV (v4, v6, Grenzen, unbekannt). FAIL → implementieren → PASS → Commit „Analytics: Laenderzuordnung“
|
||||
|
||||
### Task 4: Verdichtung, Löschung, Statistik, Heatmap-Daten
|
||||
**Files:** `src/jobs.js`, `src/stats.js`, `src/heat.js`, `test/stats.test.js`.
|
||||
**Produces:** `runNightly(db, { now })` (Rohdaten > 1 Tag in `daily` verdichten: dims `pv_path`, `pv_ref`, `pv_country`, `pv_device`, `pv_total`, `visitors`, `en_sec_sum`, `en_count`, `scroll_sum`, `ct_kind`, `bn_shown|accept|reject`; Rohdaten/Klicks > 30 Tage löschen; Websites offSince > 60 Tage: alle Zeilen löschen); `GET /a/admin/stats/:siteKey?range=7|30|90` ⇒ `{ totals:{pv, visitors, contacts, consentRate}, series:[{day,pv}], pages, refs, countries, devices, contacts, banner }` (heute aus Rohdaten + Vergangenheit aus daily); `GET /a/heat?token=` (Token HMAC heatSecret: siteKey|path|vp|exp, abgelaufen/falsch ⇒ 403) ⇒ `{ points:[{x,y}] }` max 5000 neueste.
|
||||
- [ ] Tests: Verdichtung + idempotent; 31 Tage alte Rohdaten weg; Website nach 61 Tagen weg; Stats-Form; consentRate = accept/(accept+reject); Token fremder Website/abgelaufen ⇒ 403.
|
||||
- [ ] FAIL → implementieren → PASS → Commit „Analytics: Verdichtung, Statistik, Heatmap-Daten“
|
||||
|
||||
### Task 5: Auslieferung der Client-Skripte
|
||||
**Files:** `src/assets.js`, Route `GET /a/w.js`, `GET /a/h.js` (Cache-Control `public, max-age=3600`, `Content-Type: text/javascript`), Quellen in `client/` (Task 6).
|
||||
- [ ] Test: beide Dateien ausgeliefert, richtige Header, ETag. → Commit „Analytics: Skripte ausliefern“
|
||||
|
||||
### Task 6: Client-Skript `w.js` und Overlay `h.js`
|
||||
**Files:** `analytics/client/w.js`, `analytics/client/h.js`, `analytics/client/core.js` (reine Funktionen, auch von Tests importiert), `test/client.test.js`.
|
||||
**Produces (core):** `vpClass(width)`, `cleanPath(href)`, `refHost(referrer, ownHost)`, `cssPath(el)` (≤ 200, ohne IDs mit Zahlen-Hashes), `contactKind(a)` ⇒ `'tel'|'mail'|'wa'|'out'|null` (wa: wa.me / api.whatsapp.com), `readConsent(storage, now)`, `contrastOk(hex)`.
|
||||
`w.js`: liest `data-site`/`data-color` aus dem eigenen `<script>`; GPC/DNT ⇒ reject ohne Banner; sendet `pv`; Banner laut Global Constraints (zugänglich: Buttons, Fokus, `role="dialog"`, `aria-label`); `bn` bei Anzeige/Klick; nach accept Listener für Klick (`ck`, `ct`), Verlassen (`en` via `visibilitychange`/`pagehide`); Footer-Link bzw. `[data-webklar-consent]` öffnet Banner erneut; Widerruf entfernt Listener sofort; `?__wk_heat=` ⇒ lädt `h.js`, entfernt den Parameter per `history.replaceState`, sendet in diesem Modus nichts.
|
||||
`h.js`: holt Punkte, zeichnet Canvas-Overlay (radiale Verläufe, Deckkraft), Leiste oben mit Gerätewahl-Hinweis und „Schließen“.
|
||||
- [ ] Tests (Node, core): alle reinen Funktionen inkl. Grenzfälle. Verhalten von w.js/h.js prüft Task 15 im Browser.
|
||||
- [ ] Commit „Analytics: Banner-Skript und Heatmap-Overlay“
|
||||
|
||||
## Runner
|
||||
|
||||
### Task 7: Einsetzen beim Veröffentlichen
|
||||
**Files:** `runner/src/validate.js` (Feld `analytics` streng prüfen: siteKey, color, privacy {company ≤ 200, address ≤ 300, email ≤ 200, kein Steuerzeichen}), `runner/src/analytics-inject.js`, Aufruf in `jobs.js publish` (auf dem `.new`-Ordner vor dem Tausch), `runner/test/analytics-inject.test.js`.
|
||||
**Produces:** `injectAnalytics(dir, analytics, { baseUrl })`: in jede `*.html` (rekursiv, ohne node_modules) vor `</head>` (sonst am Anfang von `<body>`, sonst Dateianfang) genau einmal `<script src="https://project.webklar.com/a/w.js" data-site=".." data-color=".." defer></script>`; erzeugt `datenschutz-analyse.html` aus Vorlage `runner/src/templates/datenschutz-analyse.html` (Platzhalter HTML-maskiert; Task 7 legt die Vorlage mit vorläufigem Text und allen Platzhaltern an, Task 13 schreibt den endgültigen Inhalt); ohne `analytics` nichts tun (ein früher eingesetztes Skript existiert im neuen Ordner ohnehin nicht, da aus der Quelle neu gebaut).
|
||||
- [ ] Tests: Einsetzen in mehrere Dateien, kein Doppel, ohne head, Maskierung (`<script>` im Firmennamen), ohne analytics unverändert, Dev-Modus unberührt.
|
||||
- [ ] Commit „Runner: Analyse-Skript und Datenschutz-Zusatzseite einsetzen“
|
||||
|
||||
## Portal
|
||||
|
||||
### Task 8: Schema, Konfiguration, Analytics-Client
|
||||
**Files:** `scripts/analyse-setup.mjs` (portalFeatures: `accentColor` 7, `avvVersion` 20, `avvAcceptedAt` 40, `avvAcceptedBy` 100, `activatedAt` 40, `offSince` 40, `privacyTicketId` 40; limit 200), `server/config.js` (`analyse: { enabled: ANALYSE_ENABLED==='on', url: ANALYTICS_URL||'http://webklar-analytics:8090', adminToken, heatSecret, avvVersion: '2026-10-01' }`), `server/services/analyticsClient.js` (`putSite`, `deleteSite`, `getStats`, `heatToken(siteKey,path,vp,now)`), Tests.
|
||||
- [ ] Commit „Analyse: Schema, Konfiguration, Client“
|
||||
|
||||
### Task 9: Freischalt-Logik
|
||||
**Files:** `server/services/analyse.js`, `test/analyse.test.js`.
|
||||
**Produces:** `analyseEligibility(customerId, projectId) ⇒ { ok, reason }` (Eigentum, Wartungs-Abo aktiv, isPublic, Subdomain); `activateAnalyse({ customerId, projectId, accentColor, consent, by })` (siteKey aus portalFeatures-Dokument `featureKey:'analyse'` wiederverwenden/neu, Zustand active, AVV-Felder, `putSite` mit Domains, Neubau über `runDeploy(..., { project })`, Ticket über `createFeatureTicket` mit `featureKey:'analyse'` und Topic „Datenschutzerklärung von <Projekt> um Hinweis auf Analyse ergänzen“, Mail K18 „Deine Website-Analyse ist aktiv“); `deactivateAnalyse(...)` (off, offSince, putSite active false, Neubau); `runAboCheck({ now })` (täglich: aktiv ohne Wartungs-Abo ⇒ deactivate + Mail K19 „Website-Analyse beendet“). Idempotent.
|
||||
- [ ] Tests: ohne Abo/Hosting-Abo ⇒ abgelehnt; nicht öffentlich ⇒ abgelehnt; AVV fehlt ⇒ 400; doppelt aktivieren ⇒ ein Ticket; deaktivieren ⇒ offSince; Abo-Ende-Job.
|
||||
- [ ] Commit „Analyse: Freischalten, Abschalten, Abo-Pruefung“
|
||||
|
||||
### Task 10: Runner-Auftrag mit Analyse-Feld
|
||||
**Files:** `server/services/previewDeploy.js` (runDeployViaRunner + registerDevRound unverändert für dev), Test.
|
||||
Im Build-Auftrag `analytics` setzen, wenn portalFeatures(analyse) `active` und Projekt öffentlich: `{ siteKey, color, privacy: { company: customer.companyName||customer.name, address: street + PLZ Ort, email: customer.email } }`; sonst `null`.
|
||||
- [ ] Tests: aktiv+öffentlich ⇒ Feld; aus ⇒ null; nicht öffentlich ⇒ null. Commit „Analyse: Runner-Auftrag“
|
||||
|
||||
### Task 11: Routen
|
||||
**Files:** `server/routes/funktionen.js` (`GET` liefert Analyse-Zustand je Projekt + eligibility; `POST /analyse/activate`, `/analyse/deactivate`, `GET /analyse/stats?projectId&range`, `POST /analyse/heatmap-link {projectId,path,vp}` ⇒ `{ url }`), `server/routes/admin/analyse.js` (`POST /api/admin/analyse/:projectId { on, avvConfirmedVerbally }`), Schalter aus ⇒ 409; Tests.
|
||||
- [ ] Commit „Analyse: Routen fuer Portal und WOMS“
|
||||
|
||||
### Task 12: Portal-Oberfläche
|
||||
**Files:** `public/funktionen.js` (+ CSS), neue Ansicht `#view-analyse` (Hash `#analyse`, App-Unterseite wie News).
|
||||
Kachel: Zustände (kein Abo / nicht öffentlich / aus / aktiv); Einschalt-Dialog (Erklärung, Farbwahl + Live-Vorschau des Banners, AVV-Haken mit Link zur AVV-Fassung, Knopf); Auswertung: Zeitraum, Kennzahl-Karten, SVG-Liniendiagramm, Listen (Seiten, Herkunft, Länder mit Flaggen-Emoji, Geräte, Kontakt-Klicks, Banner), Hinweis zu Einwilligung, Heatmap-Auswahl (Seite aus Top-Seiten, Gerät) + Knopf; DB-IP-Namensnennung klein unten; „Analyse ausschalten“ mit In-Seite-Bestätigung. 390 px tauglich.
|
||||
- [ ] `npm test` grün; Commit „Analyse: Portal-Kachel und Auswertung“
|
||||
|
||||
## Rechtstexte und WOMS
|
||||
|
||||
### Task 13: Rechtstexte (Entwürfe)
|
||||
**Files:** `docs/rechtliches/avv-website-analyse.md`, `leistungsbeschreibung-website-analyse.md`, `abo-bedingungen-ergaenzung-analyse.md`, Vorlage `runner/src/templates/datenschutz-analyse.html` (Inhalt laut Spec, Platzhalter `{{company}}`, `{{address}}`, `{{email}}`), Absatz in `public/datenschutz.html` (WEBklar als Auftragsverarbeiter für Kunden-Websites).
|
||||
- [ ] Commit „Analyse: Rechtstexte (Entwuerfe, anwaltlich zu pruefen)“
|
||||
|
||||
### Task 14: WOMS-Schalter und Services
|
||||
**Files (WOMS):** `src/lib/analyseApi.js`, Zeile in `src/components/AssignedProjectCard.jsx` („Website-Analyse: an/aus“, Einschalten nur mit Haken „Kunde hat AVV bestätigt“), Services-Statuslabel für `analyse` aktiv.
|
||||
- [ ] `npm test` + Build; Commit „Analyse: Schalter im WOMS“
|
||||
|
||||
### Task 15: Browser-Prüfung (Playwright)
|
||||
Testseite + Sammeldienst lokal (Testdaten-Verzeichnis), echtes `w.js`: ohne Annehmen nur `pv`+`bn` im Netzwerk; Annehmen ⇒ `ck`/`ct`/`en`; Ablehnen + Widerruf; GPC; Banner 390 px; Heatmap-Overlay mit Token; Portal-Ansichten mit Fixtures. Screenshots `/root/reports/website-analyse-2026-10-01/`.
|
||||
|
||||
## Livegang (jeder Schritt mit OK des Nutzers)
|
||||
|
||||
- **L1** `/srv/webklar-analytics`, `.env` (`ANALYTICS_ADMIN_TOKEN`, `ANALYTICS_HEAT_SECRET`), Compose-Dienst `analytics` (node:24-alpine, Code ro, webklar-network, Traefik-Router `Host(project.webklar.com) && PathPrefix(/a/)` Priorität > Portal, kein IP-Access-Log), `geo-update.sh` + monatlicher Cron, Schema-Skript, `deploy.sh` Neustart bei `analytics/`-Änderung.
|
||||
- **L2** Portal/Runner/Analytics pushen (`ANALYSE_ENABLED=off`).
|
||||
- **L3** Probe: öffentliche Wegwerf-Website, Analyse per Admin an, Banner/Ereignisse/Statistik/Heatmap prüfen, Abschalten ⇒ Skript weg, aufräumen.
|
||||
- **L4** WOMS pushen; Nutzer lädt Rechtstexte in „Rechtliches“; `ANALYSE_ENABLED=on`.
|
||||
- **L5** Live-Test des Nutzers mit einer echten Kunden-Website mit Wartungs-Abo.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Website-Analyse (Funktion im Kundenportal) – Design
|
||||
|
||||
Stand: 2026-10-01 · Status: vom Nutzer im Chat freigegeben (Abschnitte 1–3), Spec zur Durchsicht
|
||||
Teilprojekt **3** von 3 (1 Projektarten – live 30.09.; 2 Preview-Runner – live 01.10.).
|
||||
Regel des Nutzers: bauen → live → Nutzer testet live.
|
||||
Recherchen: `/root/reports/analyse-funktion-2026-09-30/` (recht-tracking-deutschland.md,
|
||||
technik-analyse-heatmap.md). Keine Rechtsberatung – AVV/Texte vor Livegang anwaltlich prüfen lassen.
|
||||
|
||||
## Ziel
|
||||
|
||||
Kunden mit Wartungs-Abo schalten im Portal (Funktionen) eine Website-Analyse ein. Auf ihrer Website
|
||||
erscheint ein Cookie-Banner (Annehmen/Ablehnen). Ohne Einwilligung werden nur serverseitig Seitenaufrufe,
|
||||
Herkunft, Land und Gerätetyp gezählt; nach „Annehmen“ zusätzlich Besucher, Verweildauer, Scrolltiefe,
|
||||
Klick-Heatmap und Kontakt-Klicks. Alles läuft auf dem eigenen Server, ohne Google o. ä., und wird
|
||||
**ohne Änderung am Kunden-Repo** beim Veröffentlichen durch den Preview-Runner eingebunden.
|
||||
|
||||
## Entscheidungen (vom Nutzer)
|
||||
|
||||
| # | Entscheidung |
|
||||
|---|---|
|
||||
| W1 | Nur mit **Wartungs-Abo** (`productKey === 'wartung'`, Status in `ACTIVE_STATUSES`). Ohne Abo: Kachel „Im Wartungs-Abo enthalten“ mit Link zum Abo. Hosting-Abo (`hosting`) ausgeschlossen (bestätigt 01.10.). |
|
||||
| W2 | **Kunde schaltet selbst ein** (Portal, AVV-Haken); WOMS kann ebenfalls schalten (Ausnahmen, protokolliert). |
|
||||
| W3 | Umfang V1 wie unten (Abschnitt 2); nicht: Session-Aufzeichnung, Formularinhalte, Ladezeiten/JS-Fehler, Trichter/UTM. |
|
||||
| W4 | Banner: **schlichte Leiste unten**, „Ablehnen“ und „Annehmen“ gleichwertig, Link „Datenschutz“, eine wählbare Akzentfarbe; Handy: kompakte Karte. Footer-Link „Cookie-Einstellungen“. Entscheidung gilt 6 Monate. Deutsch. |
|
||||
| W5 | Datenschutz: **automatische Zusatzseite** `/datenschutz-analyse.html` + **WOMS-Ticket** „Datenschutzerklärung von <Projekt> um Hinweis auf Analyse ergänzen“. |
|
||||
| W6 | **Eigene Lösung** (Sammeldienst + Skript), kein Umami/Matomo/Google. |
|
||||
| W7 | Nur auf **öffentlichen** Websites (`isPublic`), nicht auf login-geschützten Previews. |
|
||||
| W8 | Abo endet oder Analyse aus ⇒ Banner/Skript beim nächsten Veröffentlichen entfernt, Daten nach **60 Tagen** gelöscht. |
|
||||
| W9 | Aufbewahrung: Rohdaten **30 Tage**, Tageswerte bis Abo-Ende + 60 Tage. |
|
||||
|
||||
## 1. Bausteine
|
||||
|
||||
1. **Sammeldienst** `webklar-analytics` – Code im Portal-Repo unter `analytics/` (eigenes `package.json`,
|
||||
keine Laufzeit-Abhängigkeiten), Container `node:24-alpine` (eingebautes `node:sqlite`), Code
|
||||
schreibgeschützt eingehängt wie der Runner, Daten `/srv/webklar-analytics/` (SQLite). `deploy.sh`
|
||||
startet ihn neu, wenn sich `analytics/` geändert hat. Erreichbar über Traefik unter
|
||||
`https://project.webklar.com/a/…` (eigener Router mit höherer Priorität auf `PathPrefix(/a/)`).
|
||||
Länder über **DB-IP Lite Country** (CSV/MMDB, CC BY 4.0, Namensnennung im Portal), monatlich
|
||||
aktualisiert per Skript.
|
||||
2. **Einbinden über den Runner** (Teilprojekt 2): Ist die Analyse für das Projekt aktiv **und** die Seite
|
||||
öffentlich, setzt der Runner beim Veröffentlichen (static und build) vor `</head>` jeder HTML-Datei ein:
|
||||
`<script src="https://project.webklar.com/a/w.js" data-site="<siteKey>" data-color="<hex>" defer></script>`
|
||||
und erzeugt `/datenschutz-analyse.html` aus einer Vorlage (Firmenname, Anschrift, E-Mail des Kunden aus
|
||||
Appwrite). Ist sie aus, unterbleibt beides. Das Portal übergibt dafür im Build-Auftrag ein Feld
|
||||
`analytics: { siteKey, color, privacy: { company, address, email } } | null` (validiert im Runner:
|
||||
siteKey `^[a-z0-9]{16,40}$`, color `^#[0-9a-f]{6}$`, Texte HTML-maskiert, Länge begrenzt).
|
||||
Einschalten/Ausschalten löst einen Neubau aus (Portal `runDeploy`).
|
||||
3. **Skript** `w.js` (≤ 6 KB minifiziert, ohne Abhängigkeiten, vom Sammeldienst ausgeliefert, Cache 1 h):
|
||||
Seitenaufruf ohne Einwilligung; Banner; nach „Annehmen“ Zusatzereignisse (Abschnitt 2); Footer-Link
|
||||
„Cookie-Einstellungen“ (fest unten rechts, klein, wenn kein Element `[data-webklar-consent]` auf der
|
||||
Seite existiert – sonst wird dieses Element zum Auslöser); Overlay-Modus für die Heatmap
|
||||
(Parameter `__wk_heat=<token>`, lädt `h.js` nach).
|
||||
4. **Portal:** Funktionen-Kachel „Analyse“ (Abo-Prüfung, Projektwahl, Erklärung, Akzentfarbe mit
|
||||
Banner-Vorschau, AVV-Haken, Ein/Aus), Ansicht „Analyse“ (Abschnitt 3), Admin-API für WOMS.
|
||||
5. **WOMS:** Zeile „Website-Analyse an/aus“ auf der Projektkarte; Services zeigt die Funktion als aktiv.
|
||||
6. **Rechtliches:** AVV Website-Analyse, Leistungsbeschreibung „Website-Analyse“, neue Fassung der
|
||||
Abo-Bedingungen, Vorlage Zusatzseite, Absatz in WEBklar-Datenschutzerklärung (Auftragsverarbeitung).
|
||||
|
||||
## 2. Daten und Datenschutz
|
||||
|
||||
**Ereignisse** (alle per `navigator.sendBeacon` als `text/plain` an `POST /a/e`, Body JSON ≤ 4 KB):
|
||||
|
||||
| Typ | Wann | Felder |
|
||||
|---|---|---|
|
||||
| `pv` Seitenaufruf | immer | site, path (ohne Query/Hash, ≤ 300), ref (nur Host, nur wenn ≠ eigene Domain), vp (`m` < 768 ≤ `t` < 1024 ≤ `d`) |
|
||||
| `bn` Banner | Anzeige / Klick | site, a: `shown`\|`accept`\|`reject` – **keine** weiteren Felder |
|
||||
| `en` Verweildauer | nur nach Annehmen, beim Verlassen | site, path, vp, sec (0–3600), scroll (0–100) |
|
||||
| `ck` Klick | nur nach Annehmen | site, path, vp, x, y (0–1, relativ zur Dokumentgröße), sel (CSS-Pfad ≤ 200) |
|
||||
| `ct` Kontakt-Klick | nur nach Annehmen | site, path, kind: `tel`\|`mail`\|`wa`\|`out`, host (nur bei `out`) |
|
||||
|
||||
**Server:** IP ⇒ Land (DB-IP), danach verworfen; User-Agent ⇒ Gerätetyp + Bot-Erkennung, danach verworfen.
|
||||
Besucher-Kennung nur für `en`/`ck`/`ct`: `HMAC-SHA256(tagesSalz, siteKey|ip|ua)` gekürzt auf 16 Hex,
|
||||
Salz zufällig, nur im RAM, wird um 00:00 Europe/Berlin neu erzeugt (nach Neustart neues Salz). Keine
|
||||
Cookies, keine `Set-Cookie`-Header. Gespeichert auf dem Gerät nur die Banner-Wahl
|
||||
(`localStorage['wk-consent'] = {v:1, a:'accept'|'reject', t}`), gültig 6 Monate.
|
||||
GPC (`navigator.globalPrivacyControl`) oder DNT ⇒ wie „Ablehnen“, Banner nicht anzeigen.
|
||||
|
||||
**Annahme-Prüfung:** Unbekannter siteKey, Origin/Referer-Host nicht in den Domains der Website
|
||||
(Subdomain `*.project.webklar.com` + `liveDomain`) ⇒ 204 ohne Speichern. Bots ⇒ verworfen. Mengenlimit
|
||||
120 Ereignisse/Minute je (site, IP) im RAM. CORS nur für die eigenen Domains der Website.
|
||||
|
||||
**Speicherung (SQLite):** Tabellen `events_raw` (30 Tage), `daily` (site, day, dim, key, count),
|
||||
`clicks` (30 Tage), `sites` (siteKey, projectId, domains, active, offSince). Nächtlicher Job 02:30:
|
||||
Verdichten, Rohdaten > 30 Tage löschen, Websites mit `offSince` > 60 Tage komplett löschen.
|
||||
Traefik: Zugriffsprotokoll für `/a/` ohne Client-IP (Feld filtern oder Router ohne Access-Log) – im
|
||||
Livegang prüfen.
|
||||
|
||||
**Einwilligungsnachweis:** technischer Nachweis (ohne Annehmen sendet das Skript nachweislich nichts
|
||||
Weiteres); so in AVV und Leistungsbeschreibung beschrieben; Prüfpunkt für den Anwalt.
|
||||
|
||||
## 3. Portal, WOMS, Abläufe
|
||||
|
||||
**Funktionsdaten:** vorhandenes Collection `portalFeatures` mit `featureKey: 'analyse'` je
|
||||
(Kunde, Projekt): `state` (`off`|`active`|`disabled`), `siteKey`, neu `accentColor`, `avvVersion`,
|
||||
`avvAcceptedAt`, `avvAcceptedBy` (`customer`|Mitarbeitername), `activatedAt`, `offSince`, `privacyTicketId`.
|
||||
Fehlende Attribute per Schema-Skript (Muster `scripts/preview-runner-setup.mjs`, limit 200).
|
||||
|
||||
**Einschalten (Kunde):** `POST /api/funktionen/analyse/activate { projectId, accentColor, avvVersion,
|
||||
consent:true }` ⇒ Prüfungen (Eigentum, Wartungs-Abo aktiv, Projekt öffentlich mit Subdomain/Live-Domain,
|
||||
AVV-Fassung aktuell) ⇒ Dokument `active`, Sammeldienst-Website registrieren (`PUT /a/admin/sites/:siteKey`
|
||||
mit Token), Neubau anstoßen, Ticket „Datenschutzerklärung … ergänzen“ (Muster `createFeatureTicket`),
|
||||
Mail an Kunden (neue Vorlage K-Nummer laut Bestand) „Deine Website-Analyse ist aktiv“.
|
||||
**Ausschalten:** `POST …/deactivate { projectId }` ⇒ `off`, `offSince`, Neubau, Sammeldienst `active=false`.
|
||||
**Abo-Ende:** täglicher Job: aktive Analyse ohne aktives Wartungs-Abo ⇒ wie Ausschalten (Mail an Kunden).
|
||||
**WOMS:** `POST /api/admin/analyse/:projectId { on, avvConfirmedVerbally }` (requireAppwriteStaff);
|
||||
beim Einschalten durch Mitarbeiter Pflicht-Bestätigung „Kunde hat AVV bestätigt“, protokolliert.
|
||||
|
||||
**Auswertung:** `GET /api/funktionen/analyse/stats?projectId&range=7|30|90` (Portal holt beim Sammeldienst
|
||||
`GET /a/admin/stats/:siteKey`, Token): Kennzahlen (Aufrufe, Besucher*, Kontakt-Klicks*, Zustimmungsquote),
|
||||
Tagesverlauf, Top-Seiten, Herkunft, Länder, Geräte, Kontakt-Klicks nach Art, Banner-Zahlen
|
||||
(*nur aus Einwilligungen – Hinweistext). Heatmap: `POST …/heatmap-link { projectId, path, vp }` ⇒ signierter
|
||||
Link `https://<domain><path>?__wk_heat=<token>` (HMAC, 30 min); `h.js` lädt Punkte von
|
||||
`GET /a/heat?token=…` und zeichnet sie (Canvas, eigene kleine Implementierung) über die Seite.
|
||||
Ansicht handytauglich; Diagramme als leichtes SVG (keine Chart-Bibliothek nötig).
|
||||
|
||||
**Rechtstexte** (Entwürfe im Repo unter `docs/rechtliches/`, Upload in WOMS „Rechtliches“ durch den Nutzer):
|
||||
AVV Website-Analyse (Art. 28 DSGVO; Gegenstand, Daten, Betroffene, TOMs, Unterauftragsverarbeiter
|
||||
Hetzner, Löschung, Kontrollrechte), Leistungsbeschreibung (Umfang, Pflichten des Kunden: Banner nicht
|
||||
umgehen, keine weiteren Tracker, Datenschutzerklärung pflegen), Abo-Bedingungen neue Fassung (Analyse als
|
||||
Abo-Leistung), Vorlage `/datenschutz-analyse.html` (Art. 13: Zwecke, Rechtsgrundlagen § 25 Abs. 1 TDDDG /
|
||||
Art. 6 Abs. 1 lit. a und f DSGVO, Empfänger, Speicherdauer, Widerruf, Rechte), Absatz WEBklar-
|
||||
Datenschutzerklärung.
|
||||
|
||||
## Fehlerfälle
|
||||
|
||||
Sammeldienst nicht erreichbar ⇒ Website läuft normal (Skript fängt Fehler, Beacon ohne Wirkung);
|
||||
Portal-Statistik zeigt „Auswertung gerade nicht erreichbar“. Länder-DB fehlt ⇒ Land „unbekannt“.
|
||||
Neubau nach Ein/Aus scheitert ⇒ Zustand bleibt gespeichert, Hinweis in der Kachel, nächster Neubau holt nach.
|
||||
|
||||
## Tests
|
||||
|
||||
Sammeldienst (kein IP/UA gespeichert, Bots, Domain-Prüfung, Limit, Salz-Rotation, Verdichtung, Löschung),
|
||||
Skript (jsdom/Playwright: ohne Annehmen nur `pv`+`bn`; GPC; Banner gleichwertig; Widerruf; Overlay),
|
||||
Runner (Einsetzen/Entfernen, nur öffentlich, Maskierung, Zusatzseite), Portal (Abo-Pflicht, AVV-Pflicht,
|
||||
Ticket, Abo-Ende-Job, Statistik-Proxy, Heatmap-Token), Playwright für Kachel, Auswertung, Banner (390 px).
|
||||
|
||||
## Livegang (je Schritt mit OK des Nutzers)
|
||||
|
||||
L1 Sammeldienst-Container + Datenverzeichnis + DB-IP + Traefik-Route `/a/` (ohne IP-Log) + Schema-Skript ·
|
||||
L2 Portal/Runner pushen (Kachel noch „Kommt bald“ per Schalter `ANALYSE_ENABLED=off`) · L3 Probe auf einer
|
||||
Wegwerf-Website (öffentlich) inkl. Banner, Heatmap, Löschung · L4 WOMS pushen, Rechtstexte durch den Nutzer
|
||||
in „Rechtliches“, Schalter an · L5 Live-Test des Nutzers an einer echten Kunden-Website mit Wartungs-Abo.
|
||||
|
||||
## Nicht Teil dieser Spec
|
||||
|
||||
Session-Aufzeichnung, Formularanalyse, Ladezeiten/JS-Fehler, UTM/Trichter, Einwilligung für fremde
|
||||
Inhalte (z. B. Google-Karte) über denselben Banner, Websites außerhalb des eigenen Hostings, Export.
|
||||
@@ -0,0 +1,83 @@
|
||||
/* Reine Hilfsfunktionen der Analyse-Ansicht (funktionen.js). Ohne DOM, damit Node-Tests sie direkt importieren koennen. */
|
||||
|
||||
// Gleiche Regel wie analytics/client/core.js (contrastOk): weisser Text auf der Farbe braucht >= 4,5:1
|
||||
export function contrastOk(hex) {
|
||||
if (typeof hex !== 'string' || !/^#[0-9a-f]{6}$/i.test(hex)) return false
|
||||
const lin = (i) => {
|
||||
const c = parseInt(hex.slice(1 + i * 2, 3 + i * 2), 16) / 255
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
|
||||
}
|
||||
const L = 0.2126 * lin(0) + 0.7152 * lin(1) + 0.0722 * lin(2)
|
||||
return 1.05 / (L + 0.05) >= 4.5
|
||||
}
|
||||
|
||||
export const FALLBACK_COLOR = '#111111'
|
||||
/** Farbe der beiden Banner-Knoepfe: die Akzentfarbe, wenn sie genug Kontrast hat, sonst #111111. */
|
||||
export const bannerColor = (hex) => (contrastOk(hex) ? String(hex).toLowerCase() : FALLBACK_COLOR)
|
||||
|
||||
export const DEVICE_LABELS = { m: 'Handy', t: 'Tablet', d: 'Computer' }
|
||||
export const CONTACT_LABELS = { tel: 'Telefon', mail: 'E-Mail', wa: 'WhatsApp', out: 'Externer Link' }
|
||||
|
||||
/** Flaggen-Emoji aus ISO-3166-Alpha-2-Code ('' bei ungueltigem Code). */
|
||||
export function flagEmoji(code) {
|
||||
if (!/^[A-Za-z]{2}$/.test(code || '')) return ''
|
||||
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => 0x1f1a5 + c.charCodeAt(0)))
|
||||
}
|
||||
|
||||
let regionNames
|
||||
/** "DE" => "Deutschland"; '' => "Unbekannt"; unbekannter Code => der Code selbst. */
|
||||
export function countryName(code) {
|
||||
if (!/^[A-Za-z]{2}$/.test(code || '')) return 'Unbekannt'
|
||||
try {
|
||||
regionNames ||= new Intl.DisplayNames('de', { type: 'region' })
|
||||
return regionNames.of(code.toUpperCase()) || code.toUpperCase()
|
||||
} catch {
|
||||
return code.toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
/** Herkunft: '' => "Direkt". */
|
||||
export const refLabel = (key) => key || 'Direkt'
|
||||
|
||||
/** Hoehe der Skala: naechste "runde" Zahl (1, 2, 5 x 10^n) ueber dem Maximum. */
|
||||
export function niceMax(max) {
|
||||
if (!(max > 0)) return 1
|
||||
const pow = 10 ** Math.floor(Math.log10(max))
|
||||
for (const m of [1, 2, 5, 10]) if (m * pow >= max) return m * pow
|
||||
return 10 * pow
|
||||
}
|
||||
|
||||
/** "2026-10-01" => "01.10." */
|
||||
export function shortDay(day) {
|
||||
const m = /^\d{4}-(\d{2})-(\d{2})$/.exec(day || '')
|
||||
return m ? `${m[2]}.${m[1]}.` : String(day || '')
|
||||
}
|
||||
|
||||
/** Koordinaten fuer das Liniendiagramm. series: [{day, pv}] => Punkte, Achsenbeschriftung, Pfad. */
|
||||
export function chartModel(series, { width = 640, height = 240, pad = { l: 40, r: 12, t: 12, b: 28 }, maxLabels = 6 } = {}) {
|
||||
const data = Array.isArray(series) ? series : []
|
||||
const innerW = width - pad.l - pad.r
|
||||
const innerH = height - pad.t - pad.b
|
||||
const top = niceMax(Math.max(0, ...data.map((d) => Number(d.pv) || 0)))
|
||||
const x = (i) => pad.l + (data.length > 1 ? (innerW * i) / (data.length - 1) : innerW / 2)
|
||||
const y = (v) => pad.t + innerH - (innerH * v) / top
|
||||
const points = data.map((d, i) => ({ x: round(x(i)), y: round(y(Number(d.pv) || 0)), day: d.day, pv: Number(d.pv) || 0 }))
|
||||
const step = Math.max(1, Math.ceil(data.length / maxLabels))
|
||||
const xLabels = points.filter((_, i) => i % step === 0 || i === data.length - 1).filter((_, i, a) => i === a.length - 1 || a[a.length - 1].x - a[i].x > 36).map((p) => ({ x: p.x, label: shortDay(p.day) }))
|
||||
const yTicks = [0, 0.5, 1].map((f) => ({ y: round(y(top * f)), label: String(Math.round(top * f)) }))
|
||||
const line = points.map((p, i) => `${i ? 'L' : 'M'}${p.x} ${p.y}`).join(' ')
|
||||
const baseY = round(pad.t + innerH)
|
||||
const area = points.length ? `${line} L${points[points.length - 1].x} ${baseY} L${points[0].x} ${baseY} Z` : ''
|
||||
return { width, height, pad, top, points, xLabels, yTicks, line, area }
|
||||
}
|
||||
|
||||
function round(n) {
|
||||
return Math.round(n * 10) / 10
|
||||
}
|
||||
|
||||
/** Zeilen fuer eine Balkenliste: [{label, count, pct}] mit pct relativ zum groessten Wert (0-100). */
|
||||
export function barRows(items, labelOf) {
|
||||
const list = (Array.isArray(items) ? items : []).map((i) => ({ key: i.key ?? '', count: Number(i.count) || 0 }))
|
||||
const max = Math.max(1, ...list.map((i) => i.count))
|
||||
return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) }))
|
||||
}
|
||||
+3
-2
@@ -1961,13 +1961,14 @@ function initPasswordView() {
|
||||
// "projekt" (Projektdetails als eigene Seite) gibt es ebenfalls nur in der App-Ansicht.
|
||||
// "passwort" oeffnet das Konto-Menue (Profilbild). #fortschritt fuehrt zu Projekte und scrollt zum Abschnitt
|
||||
// "Was wir zuletzt erledigt haben" (#pj-activity); hinter dem Hash darf ein Query stehen (#news?bestellt=1, Rueckkehr von Stripe).
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const VIEW_HASHES = { projects: '', funktionen: 'funktionen', news: 'news', analyse: 'analyse', invoices: 'rechnungen', chat: 'chat', vertraege: 'vertraege', stunden: 'stunden', 'bestellen-hosting': 'bestellen-hosting', 'bestellen-wartung': 'bestellen-wartung', wechsel: 'wechsel', takeover: 'gutschrift', projekt: 'projekt', passwort: 'passwort' }
|
||||
const HASH_ALIASES = { wartung: 'vertraege', abo: 'vertraege' }
|
||||
const viewLoaders = { chat: initChat }
|
||||
// Funktionen und News laden bei jedem Oeffnen neu (Zustand aendert sich durch Bestellung und Freischaltung); Modul erst dann laden
|
||||
const viewRefreshers = {
|
||||
funktionen: () => import('/funktionen.js').then((m) => m.initFunktionen(document.getElementById('funktionen-body'))),
|
||||
news: () => import('/funktionen.js').then((m) => m.initNews(document.getElementById('news-body'))),
|
||||
analyse: () => import('/funktionen.js').then((m) => m.initAnalyse(document.getElementById('analyse-body'))),
|
||||
}
|
||||
const loadedViews = new Set()
|
||||
|
||||
@@ -2157,7 +2158,7 @@ const appLayout = window.matchMedia('(max-width: 880px), (pointer: coarse) and (
|
||||
const coarsePointer = window.matchMedia('(pointer: coarse)')
|
||||
// Unterseite -> Tab, zu dem sie gehoert (und Text neben dem Zurueck-Pfeil).
|
||||
// "passwort" setzt onAppViewShown: die Ansicht, aus der das Konto-Menue geoeffnet wurde.
|
||||
const APP_PARENT = { news: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT = { news: 'funktionen', analyse: 'funktionen', stunden: 'vertraege', 'bestellen-hosting': 'vertraege', 'bestellen-wartung': 'vertraege', wechsel: 'vertraege', takeover: 'vertraege', projekt: 'projects' }
|
||||
const APP_PARENT_LABEL = { vertraege: 'Verträge', projects: 'Projekte', funktionen: 'Funktionen', invoices: 'Rechnungen', chat: 'Chat' }
|
||||
const appShell = { view: 'projects', scroll: {}, fromHistory: false, sideCards: [], installPrompt: null }
|
||||
const TEXT_INPUT = 'textarea, input:not([type=checkbox]):not([type=radio]):not([type=file]):not([type=date]):not([type=button]):not([type=submit]):not([type=range]):not([type=color])'
|
||||
|
||||
@@ -176,6 +176,15 @@
|
||||
<div id="news-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Analyse: Einschalten oder Auswertung (funktionen.js, initAnalyse) -->
|
||||
<section class="kb-view" id="view-analyse">
|
||||
<header class="kb-head">
|
||||
<h1>Analyse</h1>
|
||||
<p>Sieh, wie deine Website genutzt wird – ohne Cookies.</p>
|
||||
</header>
|
||||
<div id="analyse-body"><p class="kb-empty">Lade…</p></div>
|
||||
</section>
|
||||
|
||||
<!-- Rechnungen -->
|
||||
<section class="kb-view" id="view-invoices">
|
||||
<header class="kb-head">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
<p>Mit UptimeRobot haben wir einen Vertrag über die Auftragsverarbeitung geschlossen; er ist Teil der Nutzungsbedingungen von UptimeRobot. UptimeRobot verarbeitet diese Daten auch außerhalb der EU, insbesondere in den USA. Grundlage dafür sind die Standardvertragsklauseln der EU-Kommission (Art. 46 Abs. 2 lit. c DSGVO). Sie sind Teil des Auftragsverarbeitungsvertrags von UptimeRobot (uptimerobot.com/dpa); eine Kopie erhalten Sie auch bei uns. Rechtsgrundlage ist für die Websites unserer Abo-Kunden Art. 6 Abs. 1 lit. b DSGVO, weil die Überwachung zu den Leistungen des Abos gehört, im Übrigen Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt auch hier im sicheren und funktionsfähigen Betrieb. Die Adresse einer Website entfernen wir bei UptimeRobot, sobald wir die Website nicht mehr betreiben.</p>
|
||||
|
||||
<h2>4. Cookies und Speicher im Browser</h2>
|
||||
<p>Wir setzen keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
|
||||
<p>Auf unseren eigenen Seiten (webklar.com und dem Kundenportal) setzen wir keine Analyse-, Tracking- oder Marketing-Werkzeuge ein und binden keine Inhalte fremder Anbieter wie Schriftarten oder Karten ein. Alle Bestandteile unserer Seiten werden von unserem eigenen Server geladen.</p>
|
||||
<table>
|
||||
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr>
|
||||
<tr><td>webklar_portal_session (Cookie)</td><td>hält Sie im Kundenportal angemeldet</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
|
||||
@@ -90,6 +90,10 @@
|
||||
<p>Veröffentlichte Beiträge sind auf Ihrer Website öffentlich sichtbar, bis die von Ihnen gewählte Anzeigedauer endet oder Sie den Beitrag abnehmen. Die Besucher Ihrer Website sehen dabei nur den Inhalt der Beiträge, nicht Ihre Kundendaten. Sie können Beiträge und Fotos jederzeit im Kundenportal löschen. Was beim Aufruf der Pinnwand durch einen Besucher übertragen wird, steht in der Datenschutzerklärung Ihrer Website.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag). Auf Ihren Wunsch oder wenn die Zusammenarbeit endet und wir die Funktion abschalten, ist die Pinnwand auf Ihrer Website nicht mehr erreichbar. Beiträge und Fotos löschen wir, wenn Sie sie selbst löschen, andernfalls nach den Fristen in Abschnitt 7.</p>
|
||||
|
||||
<h2 id="analyse">6b. Website-Analyse auf Websites unserer Kunden</h2>
|
||||
<!-- Entwurf – vor Verwendung anwaltlich prüfen lassen (Prüfpunkt Anwalt P6: Rollenverteilung Auftragsverarbeiter) -->
|
||||
<p>Sobald diese Funktion freigeschaltet ist, können Kunden mit Wartungs-Abo für ihre eigene Website eine Reichweitenmessung einschalten. Dabei verarbeiten wir die Daten der Besucher dieser Websites ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer eine Kundenwebsite besucht, findet die Informationen zur Messung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) auf der Website des Kunden, in der Regel unter „/datenschutz-analyse.html“. Die Daten liegen dann auf unserem Server bei der Hetzner Online GmbH in Deutschland.</p>
|
||||
|
||||
<h2>7. Abos und Zahlung über Stripe</h2>
|
||||
<p>Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.</p>
|
||||
<p>Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.</p>
|
||||
|
||||
@@ -137,3 +137,66 @@
|
||||
.fn-tiles { grid-template-columns: minmax(0, 1fr); }
|
||||
.fn-facts dt { min-width: 0; flex: 1 0 100%; }
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------- Analyse --- */
|
||||
#view-analyse .kb-panel { max-width: none; }
|
||||
#view-analyse .an-panel { max-width: 720px; }
|
||||
#view-analyse .fn-project { max-width: 640px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 18px; }
|
||||
#view-analyse .kb-panel + .kb-panel { margin-top: 16px; }
|
||||
.an-panel p { margin: 0 0 12px; line-height: 1.55; }
|
||||
.an-h4 { margin: 18px 0 8px; font-size: 14px; }
|
||||
.an-colors { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
||||
.an-colors input[type=color] { width: 48px; height: 40px; padding: 2px; border-radius: 10px; border: 1px solid var(--kb-border-strong); background: transparent; cursor: pointer; }
|
||||
.an-presets { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.an-preset { width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--kb-border-strong); cursor: pointer; padding: 0; }
|
||||
.an-hint { margin: 10px 0 0; font-size: 13px; color: #fcd34d; }
|
||||
/* Banner-Vorschau: gleiche Optik wie der echte Banner (analytics/client/w.js), immer hell */
|
||||
.an-bn { margin: 16px 0; padding: 14px 20px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; background: #fff; color: #111; border: 1px solid #d0d0d0; border-radius: 12px; font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
.an-bn-text { flex: 1 1 260px; min-width: 0; margin: 0 !important; color: #111 !important; font-size: 15px !important; line-height: 1.45 !important; }
|
||||
.an-bn-link { text-decoration: underline; }
|
||||
.an-bn-row { display: flex; gap: 10px; flex: none; }
|
||||
.an-bn-btn { display: inline-flex; align-items: center; justify-content: center; box-sizing: border-box; width: 150px; height: 44px; padding: 0 20px; border: 2px solid; border-radius: 8px; font: 600 15px/1 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
|
||||
.an-range { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.an-tab { padding: 8px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 14px; font-weight: 700; cursor: pointer; }
|
||||
.an-tab.active { background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
|
||||
.an-kpis { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.an-kpi { padding: 14px 16px; border-radius: 14px; background: var(--kb-surface); border: 1px solid var(--kb-border); min-width: 0; }
|
||||
.an-kpi span { display: block; font-size: 13px; color: var(--kb-muted); }
|
||||
.an-kpi b { display: block; margin-top: 4px; font-size: 26px; font-variant-numeric: tabular-nums; }
|
||||
.an-chart-box { margin-bottom: 16px; }
|
||||
.an-chart { display: block; width: 100%; height: auto; }
|
||||
.an-grid { stroke: var(--kb-border-strong); stroke-width: 1; }
|
||||
.an-axis { fill: var(--kb-muted); font-size: 11px; }
|
||||
.an-line { fill: none; stroke: var(--kb-lilac); stroke-width: 2.5; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.an-area { fill: var(--kb-purple-soft); stroke: none; }
|
||||
.an-dot { fill: var(--kb-lilac); }
|
||||
.an-grid-lists { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; align-items: start; }
|
||||
#view-analyse .an-grid-lists .kb-panel { margin-top: 0; }
|
||||
.an-rows { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
|
||||
.an-row-top { display: flex; justify-content: space-between; gap: 12px; font-size: 14px; }
|
||||
.an-row-label { min-width: 0; overflow-wrap: anywhere; }
|
||||
.an-row-top b { font-variant-numeric: tabular-nums; }
|
||||
.an-bar { height: 6px; margin-top: 4px; border-radius: 3px; background: rgba(255, 255, 255, 0.07); overflow: hidden; }
|
||||
.an-bar i { display: block; height: 100%; border-radius: 3px; background: var(--kb-purple); }
|
||||
.an-note, .an-credit { margin: 16px 0 0; font-size: 12.5px; line-height: 1.5; color: var(--kb-muted); }
|
||||
.an-credit { font-size: 11.5px; color: var(--kb-faint); }
|
||||
.an-credit a { color: inherit; }
|
||||
.an-heat { margin-top: 16px; }
|
||||
.an-heat p, .an-off p { margin: 0 0 12px; line-height: 1.55; }
|
||||
.an-heat-row { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; }
|
||||
.an-heat-row label { display: grid; gap: 4px; font-size: 13px; font-weight: 600; flex: 1 1 200px; min-width: 0; }
|
||||
.an-heat-row select { padding: 10px 12px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.05); color: inherit; font: inherit; min-width: 0; }
|
||||
.an-heat-row select option { color: #111; }
|
||||
.an-off { margin-top: 16px; }
|
||||
.an-ghost { padding: 9px 16px; border-radius: 999px; border: 1px solid var(--kb-border-strong); background: transparent; color: inherit; font: inherit; font-size: 13px; font-weight: 700; cursor: pointer; }
|
||||
.an-ghost:hover { background: rgba(255, 255, 255, 0.07); }
|
||||
.an-danger { color: #fca5a5; }
|
||||
|
||||
@media (max-width: 880px) {
|
||||
.an-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.an-grid-lists { grid-template-columns: minmax(0, 1fr); }
|
||||
.an-bn { display: block; margin: 12px 0; padding: 16px; }
|
||||
.an-bn-row { flex-direction: column; margin-top: 12px; }
|
||||
.an-bn-btn { width: 100%; }
|
||||
}
|
||||
+279
-2
@@ -3,7 +3,10 @@
|
||||
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
|
||||
const PRICE = '30 €'
|
||||
const AVV_URL = '/rechtliches/avv-website-analyse.html'
|
||||
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
|
||||
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
|
||||
@@ -95,6 +98,44 @@ function newsTile(projects) {
|
||||
</article>`
|
||||
}
|
||||
|
||||
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
|
||||
const analyseOf = (project) => project.analyse || { comingSoon: true }
|
||||
const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked')
|
||||
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
|
||||
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
|
||||
|
||||
function pickAnalyseProject(projects) {
|
||||
return projects.find((p) => analyseState(analyseOf(p)) === 'active') || projects.find((p) => analyseState(analyseOf(p)) === 'off') || projects[0]
|
||||
}
|
||||
|
||||
function analyseTile(projects) {
|
||||
const target = pickAnalyseProject(projects)
|
||||
const a = target && analyseOf(target)
|
||||
if (!target || a.comingSoon) return soonTile('chart', 'Analyse', ANALYSE_TEXT)
|
||||
const state = analyseState(a)
|
||||
const devHint = state === 'active' && a.devMode ? `<p class="fn-count an-dev-hint">${DEV_HINT}</p>` : ''
|
||||
const active = projects.filter((p) => analyseState(analyseOf(p)) === 'active').length
|
||||
const pill = state === 'active' ? ['Aktiv', 'on'] : state === 'disabled' ? ['Gesperrt', 'off'] : ['Aus', 'off']
|
||||
let action
|
||||
let text = `${ANALYSE_TEXT} Ohne Cookies, auf einem Server in Deutschland.`
|
||||
if (state === 'unknown') action = '<p class="fn-count">Die Daten konnten gerade nicht geladen werden.</p>'
|
||||
else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>' }
|
||||
else if (state === 'active') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Auswertung ansehen</button>`
|
||||
else if (state === 'off') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Einrichten</button>`
|
||||
else if (a.reason === 'kein_abo') action = '<p class="fn-count">Im Wartungs-Abo enthalten. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button></p>'
|
||||
else action = `<p class="fn-count">${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}</p>`
|
||||
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
|
||||
return `
|
||||
<article class="fn-tile" data-fn-tile="analyse">
|
||||
<div class="fn-tile-head">${svg('chart')}<span class="fn-pill fn-pill-${pill[1]}">${pill[0]}</span></div>
|
||||
<h3>Analyse</h3>
|
||||
<p>${text}</p>
|
||||
${count}
|
||||
${devHint}
|
||||
${action}
|
||||
</article>`
|
||||
}
|
||||
|
||||
const soonTile = (icon, title, text) => `
|
||||
<article class="fn-tile fn-tile-soon">
|
||||
<div class="fn-tile-head">${svg(icon)}<span class="fn-pill fn-pill-soon">Kommt bald</span></div>
|
||||
@@ -109,9 +150,10 @@ export async function initFunktionen(root) {
|
||||
<div class="fn-tiles">
|
||||
${newsTile(projects)}
|
||||
${soonTile('calendar', 'Terminplaner', 'Deine Kundschaft bucht Termine direkt über deine Website.')}
|
||||
${soonTile('chart', 'Analyse', 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.')}
|
||||
${analyseTile(projects)}
|
||||
</div>`
|
||||
root.querySelector('.fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
|
||||
root.querySelector('[data-fn-tile="analyse"] .fn-tile-cta[data-project]')?.addEventListener('click', (ev) => { analyseSel.projectId = ev.currentTarget.dataset.project })
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- News --- */
|
||||
@@ -505,3 +547,238 @@ function renderEditor(stage, project) {
|
||||
sync()
|
||||
return loadList()
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------- Analyse --- */
|
||||
const analyseSel = { projectId: '', range: 30 }
|
||||
const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c']
|
||||
const HEX_RE = /^#[0-9a-f]{6}$/i
|
||||
const nf = new Intl.NumberFormat('de-DE')
|
||||
const KPI_NOTE = '* Besucher, Verweildauer und Klicks zählen nur Besucher, die zugestimmt haben. Seitenaufrufe sind vollständig.'
|
||||
|
||||
export async function initAnalyse(root, { projectId } = {}) {
|
||||
if (!root) return
|
||||
if (projectId) analyseSel.projectId = projectId
|
||||
const { projects = [] } = await request('/api/funktionen')
|
||||
const usable = projects.filter((p) => !analyseOf(p).comingSoon)
|
||||
if (!usable.length) {
|
||||
root.innerHTML = '<div class="kb-panel kb-empty">Die Analyse ist für deine Websites noch nicht freigeschaltet.</div>'
|
||||
return
|
||||
}
|
||||
const project = usable.find((p) => p.projectId === analyseSel.projectId) || pickAnalyseProject(usable)
|
||||
analyseSel.projectId = project.projectId
|
||||
root.innerHTML = `
|
||||
${usable.length > 1 ? `
|
||||
<div class="fn-project kb-panel">
|
||||
<label for="an-project">Website</label>
|
||||
<select id="an-project">${usable.map((p) => `<option value="${esc(p.projectId)}"${p.projectId === project.projectId ? ' selected' : ''}>${esc(p.projectName)}</option>`).join('')}</select>
|
||||
</div>` : ''}
|
||||
<div id="an-stage"></div>`
|
||||
root.querySelector('#an-project')?.addEventListener('change', (ev) => {
|
||||
analyseSel.projectId = ev.target.value
|
||||
initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||||
})
|
||||
const stage = root.querySelector('#an-stage')
|
||||
const a = analyseOf(project)
|
||||
const reload = () => initAnalyse(root).catch((err) => { root.innerHTML = `<p class="muted">${esc(err.message)}</p>` })
|
||||
if (a.state === 'active') renderAnalyseStats(stage, project, reload)
|
||||
else if (a.state === 'disabled') stage.innerHTML = '<div class="kb-panel"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
|
||||
else if (a.state === 'unknown') stage.innerHTML = '<div class="kb-panel"><p>Die Daten konnten gerade nicht geladen werden.</p></div>'
|
||||
else if (!a.eligible) stage.innerHTML = `<div class="kb-panel"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></div>`
|
||||
else renderAnalyseActivation(stage, project, reload)
|
||||
}
|
||||
|
||||
function bannerPreview(color) {
|
||||
const c = bannerColor(color)
|
||||
const btn = `style="background:${c};border-color:${c};color:#fff"`
|
||||
return `
|
||||
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
|
||||
<p class="an-bn-text">Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: <span class="an-bn-link">Datenschutz</span></p>
|
||||
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderAnalyseActivation(stage, project, reload) {
|
||||
const a = analyseOf(project)
|
||||
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
|
||||
stage.innerHTML = `
|
||||
<div class="kb-panel an-panel">
|
||||
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
|
||||
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern <strong>keine IP-Adressen</strong> und setzen <strong>keine Cookies</strong>. Alles läuft auf einem eigenen Server in Deutschland.</p>
|
||||
<ul class="kb-checks">
|
||||
<li>Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.</li>
|
||||
<li>Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.</li>
|
||||
<li>Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.</li>
|
||||
</ul>
|
||||
<h4 class="an-h4">Farbe des Banners</h4>
|
||||
<div class="an-colors">
|
||||
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
|
||||
<div class="an-presets" role="group" aria-label="Vorgaben">${PRESET_COLORS.map((c) => `<button type="button" class="an-preset" data-color="${c}" style="background:${c}" aria-label="Farbe ${c}"></button>`).join('')}</div>
|
||||
</div>
|
||||
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
|
||||
<div id="an-preview"></div>
|
||||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). <a href="${esc(AVV_URL)}" target="_blank" rel="noopener">AVV lesen</a></span></label>
|
||||
<p class="error hidden" id="an-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
|
||||
</div>`
|
||||
const input = stage.querySelector('#an-color')
|
||||
const hint = stage.querySelector('#an-hint')
|
||||
const paint = () => {
|
||||
stage.querySelector('#an-preview').innerHTML = bannerPreview(color)
|
||||
hint.classList.toggle('hidden', bannerColor(color) === color.toLowerCase())
|
||||
}
|
||||
paint()
|
||||
input.addEventListener('input', () => { color = input.value; paint() })
|
||||
stage.querySelectorAll('.an-preset').forEach((b) => b.addEventListener('click', () => { color = b.dataset.color; input.value = color; paint() }))
|
||||
const box = stage.querySelector('#an-avv')
|
||||
const button = stage.querySelector('#an-on')
|
||||
const error = stage.querySelector('#an-error')
|
||||
box.addEventListener('change', () => { button.disabled = !box.checked })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked) return
|
||||
button.disabled = true
|
||||
error.classList.add('hidden')
|
||||
try {
|
||||
await request('/api/funktionen/analyse/activate', { method: 'POST', body: { projectId: project.projectId, accentColor: color.toLowerCase(), consent: { accepted: true, avvVersion: a.avvVersion } } })
|
||||
reload()
|
||||
} catch (err) {
|
||||
error.textContent = err.message
|
||||
error.classList.remove('hidden')
|
||||
button.disabled = !box.checked
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function lineChart(series) {
|
||||
const m = chartModel(series)
|
||||
if (!m.points.length) return '<p class="nw-empty">Noch keine Daten.</p>'
|
||||
const total = m.points.reduce((sum, p) => sum + p.pv, 0)
|
||||
const first = m.points[0].day
|
||||
const last = m.points[m.points.length - 1].day
|
||||
return `
|
||||
<svg class="an-chart" viewBox="0 0 ${m.width} ${m.height}" role="img" aria-labelledby="an-chart-title">
|
||||
<title id="an-chart-title">Seitenaufrufe pro Tag vom ${esc(first)} bis ${esc(last)}, insgesamt ${esc(nf.format(total))}</title>
|
||||
${m.yTicks.map((t) => `<line class="an-grid" x1="${m.pad.l}" x2="${m.width - m.pad.r}" y1="${t.y}" y2="${t.y}"/><text class="an-axis" x="${m.pad.l - 6}" y="${t.y + 4}" text-anchor="end">${esc(t.label)}</text>`).join('')}
|
||||
${m.xLabels.map((l) => `<text class="an-axis" x="${l.x}" y="${m.height - 8}" text-anchor="middle">${esc(l.label)}</text>`).join('')}
|
||||
<path class="an-area" d="${m.area}"/>
|
||||
<path class="an-line" d="${m.line}"/>
|
||||
${m.points.length <= 31 ? m.points.map((p) => `<circle class="an-dot" cx="${p.x}" cy="${p.y}" r="3"><title>${esc(p.day)}: ${esc(nf.format(p.pv))}</title></circle>`).join('') : ''}
|
||||
</svg>`
|
||||
}
|
||||
|
||||
function barList(title, items, labelOf, { html = false } = {}) {
|
||||
const rows = barRows(items, labelOf)
|
||||
return `
|
||||
<section class="kb-panel an-list">
|
||||
<h3>${esc(title)}</h3>
|
||||
${rows.length ? `<ul class="an-rows">${rows.map((r) => `
|
||||
<li><div class="an-row-top"><span class="an-row-label">${html ? r.label : esc(r.label)}</span><b>${esc(nf.format(r.count))}</b></div><div class="an-bar"><i style="width:${r.pct}%"></i></div></li>`).join('')}</ul>` : '<p class="nw-empty">Noch keine Daten.</p>'}
|
||||
</section>`
|
||||
}
|
||||
|
||||
const COUNTRY_ROW = (key) => `${flagEmoji(key) ? `<span aria-hidden="true">${flagEmoji(key)}</span> ` : ''}${esc(countryName(key))}`
|
||||
|
||||
let statsRequestId = 0
|
||||
async function renderAnalyseStats(stage, project, reload) {
|
||||
const myRequest = ++statsRequestId
|
||||
const range = [7, 30, 90].includes(analyseSel.range) ? analyseSel.range : 30
|
||||
const pid = project.projectId
|
||||
stage.innerHTML = `
|
||||
<div class="an-range" role="tablist" aria-label="Zeitraum">${[7, 30, 90].map((r) => `<button type="button" role="tab" class="an-tab${r === range ? ' active' : ''}" aria-selected="${r === range}" data-range="${r}">${r} Tage</button>`).join('')}</div>
|
||||
<div id="an-data"><p class="kb-empty">Lade…</p></div>
|
||||
<section class="kb-panel an-heat" id="an-heat"></section>
|
||||
<section class="kb-panel an-off" id="an-off"></section>
|
||||
<p class="an-credit">Länderdaten: IP-Geolocation by <a href="https://db-ip.com" target="_blank" rel="noopener">DB-IP</a></p>`
|
||||
stage.querySelectorAll('.an-tab').forEach((b) => b.addEventListener('click', () => { analyseSel.range = Number(b.dataset.range); renderAnalyseStats(stage, project, reload) }))
|
||||
renderAnalyseOff(stage.querySelector('#an-off'), pid, reload)
|
||||
|
||||
const data = stage.querySelector('#an-data')
|
||||
let stats
|
||||
try {
|
||||
stats = await request(`/api/funktionen/analyse/stats?projectId=${encodeURIComponent(pid)}&range=${range}`)
|
||||
} catch (err) {
|
||||
if (!stage.isConnected || myRequest !== statsRequestId) return
|
||||
data.innerHTML = `<p class="error" role="alert">${esc(err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message)}</p>`
|
||||
return
|
||||
}
|
||||
if (!stage.isConnected || myRequest !== statsRequestId) return
|
||||
const t = stats.totals || {}
|
||||
const rate = Math.round((Number(t.consentRate) || 0) * 100)
|
||||
const kpi = (label, value) => `<div class="an-kpi"><span>${label}</span><b>${esc(value)}</b></div>`
|
||||
const b = stats.banner || {}
|
||||
data.innerHTML = `
|
||||
<div class="an-kpis">${kpi('Seitenaufrufe', nf.format(t.pv || 0))}${kpi('Besucher*', nf.format(t.visitors || 0))}${kpi('Kontakt-Klicks*', nf.format(t.contacts || 0))}${kpi('Zustimmung', `${rate} %`)}</div>
|
||||
<section class="kb-panel an-chart-box"><h3>Seitenaufrufe pro Tag</h3>${lineChart(stats.series)}</section>
|
||||
<div class="an-grid-lists">
|
||||
${barList('Meistbesuchte Seiten', stats.pages, (k) => k || '/')}
|
||||
${barList('Herkunft', stats.refs, refLabel)}
|
||||
${barList('Länder', stats.countries, COUNTRY_ROW, { html: true })}
|
||||
${barList('Geräte', stats.devices, (k) => DEVICE_LABELS[k] || 'Unbekannt')}
|
||||
${barList('Kontakt-Klicks', stats.contacts, (k) => CONTACT_LABELS[k] || k)}
|
||||
${barList('Banner', [{ key: 'shown', count: b.shown }, { key: 'accept', count: b.accept }, { key: 'reject', count: b.reject }], (k) => ({ shown: 'Angezeigt', accept: 'Angenommen', reject: 'Abgelehnt' }[k]))}
|
||||
</div>
|
||||
<p class="an-note">${esc(KPI_NOTE)}</p>`
|
||||
renderAnalyseHeat(stage.querySelector('#an-heat'), pid, stats.pages || [])
|
||||
}
|
||||
|
||||
function renderAnalyseHeat(box, projectId, pages) {
|
||||
if (!box) return
|
||||
if (!pages.length) {
|
||||
box.innerHTML = '<h3>Heatmap</h3><p class="nw-empty">Sobald Besucher zugestimmt haben und Seiten aufgerufen wurden, kannst du hier sehen, wo geklickt und gescrollt wird.</p>'
|
||||
return
|
||||
}
|
||||
box.innerHTML = `
|
||||
<h3>Heatmap</h3>
|
||||
<p>Sieh auf deiner Website, wo Besucher klicken und wie weit sie scrollen.</p>
|
||||
<div class="an-heat-row">
|
||||
<label>Seite<select id="an-heat-page">${pages.map((p) => `<option value="${esc(p.key)}">${esc(p.key || '/')}</option>`).join('')}</select></label>
|
||||
<label>Gerät<select id="an-heat-vp">${Object.entries(DEVICE_LABELS).map(([k, v]) => `<option value="${k}"${k === 'm' ? ' selected' : ''}>${v}</option>`).join('')}</select></label>
|
||||
</div>
|
||||
<p class="error hidden" id="an-heat-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-heat-go">Auf meiner Website ansehen</button></div>
|
||||
<div class="abo-actions" id="an-heat-link" aria-live="polite"></div>`
|
||||
const go = box.querySelector('#an-heat-go')
|
||||
const error = box.querySelector('#an-heat-error')
|
||||
go.addEventListener('click', async () => {
|
||||
go.disabled = true
|
||||
error.classList.add('hidden')
|
||||
box.querySelector('#an-heat-link').innerHTML = ''
|
||||
try {
|
||||
const { url } = await request('/api/funktionen/analyse/heatmap-link', { method: 'POST', body: { projectId, path: box.querySelector('#an-heat-page').value || '/', vp: box.querySelector('#an-heat-vp').value } })
|
||||
// Kein window.open nach await (Safari/iOS blockiert das): sichtbarer Link, den der Kunde selbst antippt
|
||||
const link = box.querySelector('#an-heat-link')
|
||||
link.innerHTML = safeUrl(url) ? `<a class="abo-btn" href="${esc(url)}" target="_blank" rel="noopener">Heatmap öffnen</a>` : ''
|
||||
} catch (err) {
|
||||
error.textContent = err.status === 503 ? 'Auswertung gerade nicht erreichbar' : err.message
|
||||
error.classList.remove('hidden')
|
||||
} finally {
|
||||
go.disabled = false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
function renderAnalyseOff(box, projectId, reload) {
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausschalten</h3>
|
||||
<p>Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.</p>
|
||||
<p class="error hidden" id="an-off-error" role="alert"></p>
|
||||
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
|
||||
const actions = box.querySelector('#an-off-actions')
|
||||
const error = box.querySelector('#an-off-error')
|
||||
box.querySelector('#an-off-ask').addEventListener('click', () => {
|
||||
actions.innerHTML = '<span>Wirklich ausschalten?</span> <button type="button" class="an-ghost an-danger" id="an-off-yes">Ja, ausschalten</button> <button type="button" class="an-ghost" id="an-off-no">Abbrechen</button>'
|
||||
actions.querySelector('#an-off-no').addEventListener('click', () => renderAnalyseOff(box, projectId, reload))
|
||||
actions.querySelector('#an-off-yes').addEventListener('click', async (ev) => {
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
|
||||
reload()
|
||||
} catch (err) {
|
||||
renderAnalyseOff(box, projectId, reload)
|
||||
const e2 = box.querySelector('#an-off-error')
|
||||
e2.textContent = err.message
|
||||
e2.classList.remove('hidden')
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Auftragsverarbeitungsvertrag Website-Analyse – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box.widerspruch { border: 2px solid #6d1fd1; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · AVV Website-Analyse"; font: 8pt sans-serif; color: #888; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
.box.draft { border: 2px solid #d9a400; background: #fff8e1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)</h1>
|
||||
|
||||
<div class="box draft"><p><strong>Entwurf – vor Verwendung anwaltlich prüfen lassen.</strong> Dieser Text ist keine Rechtsberatung. Stellen mit „Prüfpunkt Anwalt“ sind offen.</p></div>
|
||||
|
||||
<p class="meta"><strong>Fassung 2026-10-01</strong></p>
|
||||
|
||||
<p>zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).</p>
|
||||
|
||||
<p>Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Er wird vereinbart, wenn der Kunde im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei die Zustimmung zu dieser Fassung bestätigt (Haken im Portal; das Portal speichert Fassung, Zeitpunkt und Person). Schaltet ein Mitarbeiter von WEBklar die Funktion auf Wunsch des Kunden ein, bestätigt der Mitarbeiter im System, dass der Kunde diese Fassung des Vertrags bestätigt hat. Gespeichert werden die Fassung, der Zeitpunkt und der Name des Mitarbeiters. Bei Zustimmung im Kundenportal wird die Zustimmung dem Kundenkonto zugeordnet.</p>
|
||||
|
||||
<h2>1. Gegenstand und Dauer</h2>
|
||||
|
||||
<p>(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Die Leistung ist in der Leistungsbeschreibung „Website-Analyse“ beschrieben.</p>
|
||||
|
||||
<p>(2) Der Vertrag beginnt mit dem Einschalten der Funktion und gilt, solange die Funktion eingeschaltet ist. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos, und gilt darüber hinaus, bis alle Daten nach Nummer 9 gelöscht sind.</p>
|
||||
|
||||
<h2>2. Art und Zweck der Verarbeitung</h2>
|
||||
|
||||
<p>(1) Art: Erheben über ein Skript, das WEBklar in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.</p>
|
||||
|
||||
<p>(2) Zweck: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Andere Zwecke verfolgt WEBklar mit diesen Daten nicht. Es findet kein Profiling, keine Werbung und keine Zusammenführung mit anderen Daten statt.</p>
|
||||
|
||||
<h2>3. Datenarten und Betroffene</h2>
|
||||
|
||||
<p>(1) Datenarten ohne Einwilligung des Besuchers: Seitenaufruf mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (abgeleitet aus der IP-Adresse; die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse (Handy, Tablet, Computer) und Größenklasse des Browserfensters (vp), anonyme Zähler zur Anzeige, Annahme und Ablehnung des Banners.</p>
|
||||
|
||||
<p>(2) Zusätzlich nach Annehmen des Banners durch den Besucher: eine täglich wechselnde pseudonyme Besucher-Kennung (nicht über mehrere Tage verknüpfbar), Verweildauer und Scrolltiefe je Seite, Klickpositionen mit vereinfachtem Elementpfad (Heatmap, keine Formulareingaben), Art des Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit Domainname).</p>
|
||||
|
||||
<p>(3) Nicht gespeichert werden IP-Adressen und Browser-Kennzeichen (User-Agent); sie werden nur kurzzeitig im Arbeitsspeicher verarbeitet (siehe Anlage). Nicht erhoben werden Cookies, Formularinhalte, Seiteninhalte, Mausbewegungen oder Sitzungsaufzeichnungen. Besondere Kategorien nach Art. 9 DSGVO werden nicht gezielt verarbeitet.</p>
|
||||
|
||||
<p>(4) Betroffene: Besucher der Website des Kunden.</p>
|
||||
|
||||
<p>(5) WEBklar verwendet die Daten ausschließlich für den Kunden und gibt sie nicht an Dritte weiter, außer an Unterauftragsverarbeiter nach Nummer 7.</p>
|
||||
|
||||
<h2>4. Weisungen</h2>
|
||||
|
||||
<p>(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt.</p>
|
||||
|
||||
<p>(2) Die Weisungen ergeben sich aus diesem Vertrag, der Leistungsbeschreibung und den Einstellungen des Kunden im Kundenportal (Einschalten und Ausschalten; die Akzentfarbe des Banners wird beim Einschalten gewählt). Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin und darf die Ausführung aussetzen.</p>
|
||||
|
||||
<h2>5. Vertraulichkeit</h2>
|
||||
|
||||
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
|
||||
|
||||
<h2>6. Technische und organisatorische Maßnahmen</h2>
|
||||
|
||||
<p>(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.</p>
|
||||
|
||||
<p>(2) Die Maßnahmen der Anlage 2 zu den Abo-Bedingungen gelten ergänzend.</p>
|
||||
|
||||
<h2>7. Unterauftragsverarbeiter</h2>
|
||||
|
||||
<p>(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für die Datensicherung. Ort der Verarbeitung ist ein Rechenzentrum in Deutschland (Falkenstein). Die Daten werden nicht in Drittländer übermittelt.</p>
|
||||
|
||||
<p>(2) Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll.</p>
|
||||
|
||||
<p>(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.</p>
|
||||
|
||||
<p>(4) Die Länderzuordnung nutzt eine lokal gespeicherte Datenbank („DB-IP Lite Country“, Lizenz CC BY 4.0). Dabei werden keine Daten an den Anbieter der Datenbank übermittelt; er ist kein Unterauftragsverarbeiter.</p>
|
||||
|
||||
<h2>8. Unterstützung des Kunden, Betroffenenrechte</h2>
|
||||
|
||||
<p>(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter und unterstützt ihn bei der Beantwortung, soweit das mit den eingesetzten Systemen möglich ist.</p>
|
||||
|
||||
<p>(2) Die gespeicherten Daten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die über einen Tag hinaus gilt. WEBklar kann einzelne Besucher daher in der Regel nicht identifizieren und Auskunfts-, Berichtigungs- oder Löschverlangen zu einzelnen Besuchen nicht umsetzen, solange die betroffene Person keine zusätzlichen Angaben bereitstellt (Art. 11 DSGVO). Der Kunde weist Betroffene darauf in seiner Datenschutzerklärung hin.</p>
|
||||
|
||||
<p>(3) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO.</p>
|
||||
|
||||
<p>(4) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.</p>
|
||||
|
||||
<p>(5) Der Kunde ist dafür verantwortlich, dass die Besucher seiner Website informiert werden (Art. 13 DSGVO). WEBklar stellt dafür die Zusatzseite „/datenschutz-analyse.html“ bereit. Der Kunde ergänzt seine eigene Datenschutzerklärung um einen Hinweis darauf.</p>
|
||||
|
||||
<h2>9. Löschung und Rückgabe</h2>
|
||||
|
||||
<p>(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Zusammengefasste Tageswerte ohne Besucher-Kennung bleiben bis zum Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) und weitere 60 Tage gespeichert; danach löscht WEBklar alle Daten der Website. Eine Kopie der Tageswerte stellt WEBklar dem Kunden auf Verlangen in Textform innerhalb dieser Frist zur Verfügung.</p>
|
||||
|
||||
<p>(2) Ob und wie lange Sicherungskopien Daten der Website-Analyse enthalten, ist noch zu klären (Prüfpunkt P8, siehe Anlage); soweit sie enthalten sind, werden sie im Rahmen der regulären Rotation überschrieben.</p>
|
||||
|
||||
<p>(3) Gesetzliche Aufbewahrungspflichten bleiben unberührt.</p>
|
||||
|
||||
<h2>10. Nachweise und Kontrollen</h2>
|
||||
|
||||
<p>WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Prüfpunkt P13). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.</p>
|
||||
|
||||
<h2>11. Pflichten des Kunden</h2>
|
||||
|
||||
<p>Der Kunde ist Verantwortlicher für die Verarbeitung. Er sorgt dafür, dass das Banner nicht entfernt oder verdeckt wird, dass keine weiteren Messwerkzeuge ohne Abstimmung eingebunden werden, und dass seine Datenschutzerklärung auf die Zusatzseite hinweist (Leistungsbeschreibung „Website-Analyse“).</p>
|
||||
|
||||
<h2>12. Haftung und Schlussbestimmungen</h2>
|
||||
|
||||
<p>(1) Für die Haftung gilt Art. 82 DSGVO. Im Übrigen gelten die Haftungsregeln der Abo-Bedingungen.</p>
|
||||
|
||||
<p>(2) Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Abo-Bedingungen und der Leistungsbeschreibung vor.</p>
|
||||
|
||||
<p>(3) Änderungen dieses Vertrags richten sich nach § 15 der Abo-Bedingungen; eine neue Fassung wird erst wirksam, wenn der Kunde sie im Portal bestätigt hat.</p>
|
||||
|
||||
<p>(4) Es gilt deutsches Recht.</p>
|
||||
|
||||
<h2>Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Bereich</th><th>Maßnahmen</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Übertragung</td><td>Skript und Messdaten werden nur über TLS (HTTPS) übertragen.</td></tr>
|
||||
<tr><td>Keine Cookies</td><td>Es werden keine Cookies gesetzt. Auf dem Gerät des Besuchers wird nur die Banner-Auswahl im lokalen Speicher (Eintrag „wk-consent“) gespeichert; sie gilt sechs Monate.</td></tr>
|
||||
<tr><td>Datenminimierung</td><td>IP-Adresse und Browser-Kennzeichen werden nicht gespeichert und nicht protokolliert. Die IP-Adresse wird nur im Moment der Abfrage verwendet für: Länderzuordnung, Schlüssel der Mengenbegrenzung (Hash mit Tagessalz, nur im Arbeitsspeicher) und, nur nach Einwilligung, die tägliche Besucher-Kennung. Das Browser-Kennzeichen wird nur im Moment der Abfrage verwendet für: Geräteklasse, Bot-Erkennung und, nur nach Einwilligung, die tägliche Besucher-Kennung. Pfade ohne Suchparameter, Elementpfade ohne Kennungen und Klassennamen. Keine Formulareingaben.</td></tr>
|
||||
<tr><td>Pseudonymisierung</td><td>Die Besucher-Kennung (nur nach Einwilligung) ist ein gekürzter Hash mit Tagessalz. Das Salz liegt nur im Arbeitsspeicher und wird täglich um 00:00 Uhr (Europe/Berlin) neu erzeugt; ältere Kennungen sind danach nicht mehr verknüpfbar.</td></tr>
|
||||
<tr><td>Einwilligung</td><td>Ohne „Annehmen“ sendet das Skript nur Seitenaufruf und Banner-Zähler. „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen.</td></tr>
|
||||
<tr><td>Zugriffskontrolle</td><td>Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem getrennten, internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff nur über das Kundenportal und die Verwaltung von WEBklar. Im Portal sieht jeder Kunde nur die Daten seiner eigenen Website.</td></tr>
|
||||
<tr><td>Eingangsprüfung</td><td>Messdaten werden nur angenommen, wenn die Website registriert ist und Origin bzw. Referer zu ihren Domains passen; Bots werden verworfen; Größen- und Mengenbegrenzung je Website und Anfrager (nur im Arbeitsspeicher).</td></tr>
|
||||
<tr><td>Heatmap-Zugriff</td><td>Die Heatmap wird über einen signierten Link abgerufen, der 30 Minuten gültig ist.</td></tr>
|
||||
<tr><td>Löschung</td><td>Täglicher automatischer Job (02:30 Uhr, Europe/Berlin): Rohdaten und Klicks nach 30 Tagen; Websites, die seit mehr als 60 Tagen abgeschaltet sind, werden vollständig gelöscht.</td></tr>
|
||||
<tr><td>Trennung</td><td>Daten sind je Website getrennt (Website-Schlüssel); eigener Container und eigenes Datenverzeichnis des Messdienstes; kein Zugriff auf andere Kundendaten.</td></tr>
|
||||
<tr><td>Rechenzentrum</td><td>Hetzner Online GmbH, Falkenstein (Deutschland). <span class="offen">[Prüfpunkt: Zertifizierung des Rechenzentrums (ISO/IEC 27001) vor Verwendung belegen, bevor sie genannt wird.]</span></td></tr>
|
||||
<tr><td>Protokolle des Reverse-Proxys</td><td>Das Zugriffsprotokoll für die Messdienst-Adresse wird ohne Client-IP geführt. <span class="offen">[Prüfpunkt P7: Einstellung beim Livegang verifizieren.]</span></td></tr>
|
||||
<tr><td>Datensicherung</td><td>Die tägliche Serversicherung (7 Tage) erfasst voraussichtlich auch das Datenverzeichnis des Messdienstes. <span class="offen">[Prüfpunkt P8: ob das Verzeichnis in der Sicherung enthalten ist und wie lange dort gelöschte Daten verbleiben, ist noch zu klären.]</span></td></tr>
|
||||
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nr. 8 Abs. 4), Überprüfung der Maßnahmen mindestens einmal im Jahr.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
// 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, '"').replace(/'/g, ''');
|
||||
|
||||
// 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" 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 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');
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
// Konfiguration aus Umgebungsvariablen. Pfade sind HOST-Pfade (R6): der Runner mountet sie identisch.
|
||||
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');
|
||||
return {
|
||||
analyticsPublicBase,
|
||||
token: env.PREVIEW_RUNNER_TOKEN || '',
|
||||
giteaUrl: env.GITEA_URL || '',
|
||||
giteaToken: env.GITEA_TOKEN || '',
|
||||
|
||||
@@ -7,6 +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';
|
||||
|
||||
const LOG_LINES = 300;
|
||||
const MAX_LINE = 2000;
|
||||
@@ -254,6 +255,8 @@ export function createJobs({ config, docker, fs: fsOver = {}, git, mem = readMem
|
||||
await fs.mkdir(config.previewsRoot, { recursive: true });
|
||||
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, 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}`));
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Datenschutz – Reichweitenmessung</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 4rem}
|
||||
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
|
||||
.stand{color:#555;font-size:.9rem}
|
||||
ul{padding-left:1.25rem}
|
||||
a{color:#0b57d0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Datenschutz bei der Reichweitenmessung</h1>
|
||||
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 01.10.2026</p>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>{{company}}<br>{{#address}}{{address}}<br>{{/address}}E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
|
||||
|
||||
<h2>Worum es geht</h2>
|
||||
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo bleiben sie hängen? Es gibt dabei keine Werbung, kein Profiling und keine Weitergabe an Werbenetzwerke. Die Messung setzt keine Cookies und bindet keine Dienste von Google, Meta oder anderen Drittanbietern ein. Die Messung läuft auf Servern in Deutschland.</p>
|
||||
|
||||
<h2>Wenn Du nichts oder „Ablehnen“ auswählst</h2>
|
||||
<p>Dann zählen wir nur, dass eine Seite aufgerufen wurde. Dabei werden gespeichert:</p>
|
||||
<ul>
|
||||
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
|
||||
<li>die Website, von der Du kommst (nur der Domainname, falls es nicht diese Website selbst ist),</li>
|
||||
<li>das Land, abgeleitet aus Deiner IP-Adresse,</li>
|
||||
<li>die Geräteklasse (Handy, Tablet, Computer) aus dem Browser-Kennzeichen und zusätzlich die Größenklasse des Browserfensters, und</li>
|
||||
<li>anonyme Zähler dafür, ob das Banner angezeigt, angenommen oder abgelehnt wurde.</li>
|
||||
</ul>
|
||||
<p>Deine IP-Adresse wird nur für den Augenblick der Abfrage verwendet, um das Land zu bestimmen. Sie wird nicht gespeichert, ebenso wenig Dein Browser-Kennzeichen (User-Agent). Ohne Einwilligung gibt es keine Kennung, mit der wir Dich wiedererkennen könnten. Dasselbe gilt, wenn Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“ sendet: Wir behandeln das wie ein „Ablehnen“ und zeigen das Banner nicht an.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen und das Banner korrekt zu betreiben. Auf Informationen in Deinem Endgerät greifen wir dafür nach unserer Einschätzung nicht im Sinne von § 25 TDDDG zu.</p>
|
||||
|
||||
<h2>Wenn Du „Annehmen“ wählst</h2>
|
||||
<p>Dann messen wir zusätzlich:</p>
|
||||
<ul>
|
||||
<li>eine Besucher-Kennung, die täglich wechselt (aus IP-Adresse und Browser-Kennzeichen errechnet, nicht zurückrechenbar und nicht über mehrere Tage verknüpfbar; IP-Adresse und Browser-Kennzeichen selbst werden nicht gespeichert),</li>
|
||||
<li>wie lange Du auf einer Seite bleibst und wie weit Du scrollst,</li>
|
||||
<li>wo auf der Seite geklickt wird (Klickposition und die Art des angeklickten Elements, für eine „Heatmap“; keine Eingaben in Formulare) und</li>
|
||||
<li>ob Du auf Telefonnummer, E-Mail-Adresse, WhatsApp-Link oder einen Link zu einer anderen Website klickst.</li>
|
||||
</ul>
|
||||
<p>Rechtsgrundlage ist Deine Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG. Außerdem speichern wir Deine Auswahl in Deinem Browser (lokaler Speicher, Eintrag „wk-consent“), damit wir Dich nicht bei jedem Besuch erneut fragen müssen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt sechs Monate, danach fragen wir erneut.</p>
|
||||
|
||||
<h2>Speicherdauer</h2>
|
||||
<p>Einzelne Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte ohne Personenbezug, die wir bis zum Ende der Messung und weitere 60 Tage aufbewahren. Danach werden auch sie gelöscht.</p>
|
||||
|
||||
<h2>Empfänger</h2>
|
||||
<p>Die Messung wird für uns von der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter betrieben. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Die Server stehen bei der Hetzner Online GmbH (Rechenzentrum in Deutschland), die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||
|
||||
<h2>Widerruf</h2>
|
||||
<p>Du kannst Deine Entscheidung jederzeit ändern oder Deine Einwilligung widerrufen: Klicke dazu auf den Link „Cookie-Einstellungen“ (in der Regel unten rechts auf jeder Seite oder über das dafür vorgesehene Element der Website) und wähle „Ablehnen“. Der Widerruf gilt ab dann für die Zukunft. Die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt unberührt.</p>
|
||||
|
||||
<h2>Deine Rechte</h2>
|
||||
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). Du kannst der Verarbeitung nach Art. 6 Abs. 1 lit. f DSGVO aus Gründen, die sich aus Deiner besonderen Situation ergeben, jederzeit widersprechen (Art. 21 DSGVO). Da wir Dich anhand der gespeicherten Daten nicht wiedererkennen können, können wir Auskunft, Berichtigung oder Löschung zu einzelnen Besuchen in der Regel nicht vornehmen, es sei denn, Du stellst uns zusätzliche Angaben bereit, mit denen wir Dich identifizieren können (Art. 11, Art. 12 Abs. 2 DSGVO). Du hast außerdem das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Wende Dich für Deine Anliegen an die oben genannte Adresse.</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,4 +1,5 @@
|
||||
// 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}$/;
|
||||
@@ -45,9 +46,28 @@ export function validateJob(body) {
|
||||
env[n] = v;
|
||||
}
|
||||
}
|
||||
let analytics;
|
||||
if (body.analytics !== undefined && body.analytics !== null) {
|
||||
const a = body.analytics;
|
||||
if (!a || typeof a !== 'object' || Array.isArray(a)) return { ok: false, error: 'Ungültige Analyse-Angaben' };
|
||||
if (typeof a.siteKey !== 'string' || !RE_SITE_KEY.test(a.siteKey)) return { ok: false, error: 'Ungültiger Analyse-Schlüssel' };
|
||||
if (typeof a.color !== 'string' || !RE_COLOR.test(a.color)) return { ok: false, error: 'Ungültige Analyse-Farbe' };
|
||||
const pr = a.privacy;
|
||||
if (!pr || typeof pr !== 'object' || Array.isArray(pr)) return { ok: false, error: 'Ungültige Analyse-Datenschutzangaben' };
|
||||
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 (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.trim();
|
||||
}
|
||||
analytics = { siteKey: a.siteKey, color: a.color, privacy };
|
||||
}
|
||||
// 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;
|
||||
return { ok: true, job };
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
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 { injectAnalytics, stripComments } 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/a/';
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o });
|
||||
const TAG = `<script data-wk-analytics src="${BASE}w.js" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
|
||||
const dir = () => mkdtempSync(join(tmpdir(), 'inj-'));
|
||||
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('mehrere Dateien inkl. Unterordner: Tag vor </head>, node_modules/Punktordner übersprungen', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<html><head><title>x</title></head><body>a</body></html>');
|
||||
put(d, 'sub/seite.htm', '<head></head><p>b</p>');
|
||||
put(d, 'node_modules/p/x.html', '<head></head>');
|
||||
put(d, '.hidden/x.html', '<head></head>');
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'index.html'), `<html><head><title>x</title>${TAG}</head><body>a</body></html>`);
|
||||
assert.equal(get(d, 'sub/seite.htm'), `<head>${TAG}</head><p>b</p>`);
|
||||
assert.equal(get(d, 'node_modules/p/x.html'), '<head></head>');
|
||||
assert.equal(get(d, '.hidden/x.html'), '<head></head>');
|
||||
});
|
||||
|
||||
test('zweimal ausführen: kein Doppel; alter Tag wird ersetzt', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
assert.equal(count(get(d, 'index.html'), 'data-wk-analytics'), 1);
|
||||
put(d, 'b.html', '<head><script data-wk-analytics src="https://alt/w.js" data-site="x" defer></script></head><body></body>');
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'b.html'), `<head>${TAG}</head><body></body>`);
|
||||
});
|
||||
|
||||
test('ohne head: nach <body …>; ohne body: am Dateianfang; großes </HEAD>', async () => {
|
||||
const d = dir();
|
||||
put(d, 'a.html', '<body class="x">hi</body>');
|
||||
put(d, 'b.html', '<p>nur text</p>');
|
||||
put(d, 'c.html', '<HEAD><TITLE>x</TITLE></HEAD><BODY>y</BODY>');
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
assert.equal(get(d, 'a.html'), `<body class="x">${TAG}hi</body>`);
|
||||
assert.equal(get(d, 'b.html'), `${TAG}<p>nur text</p>`);
|
||||
assert.equal(get(d, 'c.html'), `<HEAD><TITLE>x</TITLE>${TAG}</HEAD><BODY>y</BODY>`);
|
||||
});
|
||||
|
||||
test('Nicht-UTF8-Bytes bleiben erhalten', async () => {
|
||||
const d = dir();
|
||||
const before = Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from('</head>')]);
|
||||
writeFileSync(join(d, 'l.html'), before);
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
const after = readFileSync(join(d, 'l.html'));
|
||||
assert.deepEqual(after, Buffer.concat([Buffer.from('<head>'), Buffer.from([0xe4, 0xf6, 0xfc, 0xff, 0x80]), Buffer.from(TAG), Buffer.from('</head>')]));
|
||||
});
|
||||
|
||||
test('Datei > 5 MB wird übersprungen (Logzeile)', async () => {
|
||||
const d = dir();
|
||||
const big = Buffer.alloc(5 * 1024 * 1024 + 1, 0x20); big.write('<head></head>', 0);
|
||||
writeFileSync(join(d, 'gross.html'), big);
|
||||
const lines = [];
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE, onLine: (l) => lines.push(l) });
|
||||
assert.deepEqual(readFileSync(join(d, 'gross.html')), big);
|
||||
assert.ok(lines.some((l) => l.includes('gross.html') && /5 MB/.test(l)));
|
||||
});
|
||||
|
||||
test('Datenschutzseite: Platzhalter maskiert, Skript auch dort, Pflichtabschnitte', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectAnalytics(d, A({ privacy: { company: '<script>alert(1)</script> & Co', address: 'A "B" <C>', email: "x'y@z.de" } }), { baseUrl: BASE });
|
||||
const p = get(d, 'datenschutz-analyse.html');
|
||||
assert.ok(p.includes('<script>alert(1)</script> & Co'));
|
||||
assert.ok(!p.includes('<script>alert(1)'));
|
||||
assert.ok(p.includes('A "B" <C>') && p.includes('x'y@z.de'));
|
||||
assert.ok(!p.includes('{{'));
|
||||
assert.equal(count(p, 'data-wk-analytics'), 1);
|
||||
for (const s of ['Verantwortlicher', 'Zweck', 'Einwilligung', 'Rechtsgrundlage', 'Auftragsverarbeiter', 'Hetzner', 'Speicherdauer', 'Cookie-Einstellungen', 'Rechte']) assert.ok(p.includes(s), s);
|
||||
});
|
||||
|
||||
test('ohne analytics: nichts geändert, keine Seite', async () => {
|
||||
const d = dir();
|
||||
put(d, 'index.html', '<head></head>');
|
||||
await injectAnalytics(d, undefined, { baseUrl: BASE });
|
||||
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||
assert.deepEqual(readdirSync(d), ['index.html']);
|
||||
});
|
||||
|
||||
test('ungültige Eingaben werfen (siteKey, Farbe, Basis)', async () => {
|
||||
const d = dir(); put(d, 'index.html', '<head></head>');
|
||||
await assert.rejects(injectAnalytics(d, A({ siteKey: 'ABC' }), { baseUrl: BASE }));
|
||||
await assert.rejects(injectAnalytics(d, A({ color: 'red' }), { baseUrl: BASE }));
|
||||
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'http://x/a/' }));
|
||||
await assert.rejects(injectAnalytics(d, A(), { baseUrl: 'https://x/a' }));
|
||||
assert.equal(get(d, 'index.html'), '<head></head>');
|
||||
});
|
||||
|
||||
test('validateJob: analytics streng, Weiterreichen in den Job', () => {
|
||||
const base = { sub: 'demo', repo: 'JUSN/demo', sha: 'abcdef1', branch: 'main' };
|
||||
const ok = validateJob({ ...base, analytics: { ...A(), extra: 1 } });
|
||||
assert.equal(ok.ok, true);
|
||||
assert.deepEqual(ok.job.analytics, A());
|
||||
assert.equal(validateJob(base).job.analytics, undefined);
|
||||
const nul = validateJob({ ...base, analytics: null });
|
||||
assert.equal(nul.ok, true); assert.equal(nul.job.analytics, undefined);
|
||||
const noAddr = validateJob({ ...base, analytics: A({ privacy: { company: 'F', email: 'e@x.de' } }) });
|
||||
assert.equal(noAddr.ok, true); assert.equal(noAddr.job.analytics.privacy.address, '');
|
||||
assert.equal(validateJob({ ...base, analytics: A({ privacy: { company: 'F', email: '' } }) }).ok, false);
|
||||
assert.equal(validateJob({ ...base, analytics: A({ privacy: { email: 'e@x.de' } }) }).ok, false);
|
||||
const bad = [
|
||||
A({ siteKey: 'kurz' }), A({ siteKey: 'A'.repeat(20) }), A({ color: '#ABCDEF' }), A({ color: '#abc' }),
|
||||
A({ privacy: undefined }), A({ privacy: { ...A().privacy, company: 'x'.repeat(201) } }),
|
||||
A({ privacy: { ...A().privacy, address: 'x'.repeat(301) } }), A({ privacy: { ...A().privacy, email: 'x'.repeat(201) } }),
|
||||
A({ privacy: { ...A().privacy, company: 'a\nb' } }), A({ privacy: { ...A().privacy, email: 5 } }), 'x', [],
|
||||
];
|
||||
for (const a of bad) { const r = validateJob({ ...base, analytics: a }); assert.equal(r.ok, false, JSON.stringify(a)); assert.match(r.error, /Analyse/); }
|
||||
});
|
||||
|
||||
test('loadConfig: analyticsPublicBase Vorgabe, Override, ungültig wirft', () => {
|
||||
assert.equal(loadConfig({}).analyticsPublicBase, BASE);
|
||||
assert.equal(loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q/' }).analyticsPublicBase, 'https://x.de/q/');
|
||||
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'http://x.de/q/' }));
|
||||
assert.throws(() => loadConfig({ ANALYTICS_PUBLIC_BASE: 'https://x.de/q' }));
|
||||
});
|
||||
|
||||
// ---- Integration über publish ----
|
||||
function setup(cfg = {}, fsOver = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'injjobs-'));
|
||||
const config = { giteaUrl: 'https://git.x', giteaToken: 't', previewsRoot: join(root, 'previews'), workRoot: join(root, 'work'), cacheRoot: join(root, 'cache'), minFreeBytes: 1000, buildTimeoutMs: 900000, analyticsPublicBase: BASE, ...cfg };
|
||||
mkdirSync(config.previewsRoot, { recursive: true });
|
||||
const git = async ({ dir: d }) => { put(d, 'index.html', '<html><head></head><body>NEU</body></html>'); put(d, 'x/y.html', '<p>y</p>'); };
|
||||
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 analytics injiziert im veröffentlichten Ordner', async () => {
|
||||
const s = setup();
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ analytics: A() })));
|
||||
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(existsSync(join(t, 'datenschutz-analyse.html')));
|
||||
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||
});
|
||||
|
||||
test('publish: Job ohne analytics bleibt unverändert', async () => {
|
||||
const s = setup();
|
||||
const r = await wait(s.jobs, s.jobs.submit(job()));
|
||||
assert.equal(r.state, 'done');
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
assert.equal(get(t, 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||
assert.ok(!existsSync(join(t, 'datenschutz-analyse.html')));
|
||||
});
|
||||
|
||||
test('publish: Fehler beim Einsetzen ⇒ failed, alter Ordner bleibt (V4)', 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({ analytics: A() })));
|
||||
assert.equal(r.state, 'failed');
|
||||
assert.equal(get(t, 'index.html'), 'ALT');
|
||||
assert.deepEqual(readdirSync(s.config.previewsRoot), ['demo']);
|
||||
});
|
||||
|
||||
test('Dev-Modus (prepareWorkspace) fasst Dateien nicht an', async () => {
|
||||
const s = setup();
|
||||
const { workDir } = await s.jobs.prepareWorkspace(job({ analytics: A() }), { workDir: join(s.config.workRoot, 'dev', 'x') });
|
||||
assert.equal(get(workDir, 'index.html'), '<html><head></head><body>NEU</body></html>');
|
||||
assert.ok(!existsSync(join(workDir, 'datenschutz-analyse.html')));
|
||||
});
|
||||
|
||||
test('leere Adresse: keine leere Zeile, kein Platzhalterrest', async () => {
|
||||
const d = dir(); put(d, 'index.html', '<head></head>');
|
||||
await injectAnalytics(d, A({ privacy: { company: 'Muster GmbH', address: '', email: 'info@muster.de' } }), { baseUrl: BASE });
|
||||
const p = get(d, 'datenschutz-analyse.html');
|
||||
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');
|
||||
});
|
||||
|
||||
test('Zusatzseite enthält keine HTML-Kommentare', async () => {
|
||||
const d = dir();
|
||||
await injectAnalytics(d, A(), { baseUrl: BASE });
|
||||
assert.ok(!get(d, 'datenschutz-analyse.html').includes('<!--'));
|
||||
assert.equal(stripComments('a<!-- Prüfpunkt\nx -->b<!--y-->c'), 'abc');
|
||||
});
|
||||
@@ -40,6 +40,24 @@ test('static: kopiert ohne .git, Rechte 755/644', async () => {
|
||||
assert.equal(s.calls.length, 0);
|
||||
});
|
||||
|
||||
test('build: Analyse wird in den gebauten Ausgabeordner eingesetzt (wie bei static), Dev nicht', async () => {
|
||||
const analytics = { siteKey: 'abcdefghij0123456789', color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' } };
|
||||
const s = setup({
|
||||
cfg: { analyticsPublicBase: 'https://project.webklar.com/a/' },
|
||||
files: { 'package.json': PKG() },
|
||||
dockerImpl: (args) => { if (args.includes('build')) { mkdirSync(join(workOf(args), 'dist'), { recursive: true }); writeFileSync(join(workOf(args), 'dist', 'index.html'), '<html><head></head><body>NEU</body></html>'); } return { code: 0 }; },
|
||||
});
|
||||
const r = await wait(s.jobs, s.jobs.submit(job({ mode: 'build', analytics })));
|
||||
assert.equal(r.state, 'done'); assert.equal(r.result, 'build');
|
||||
const t = join(s.config.previewsRoot, 'demo');
|
||||
assert.match(readFileSync(join(t, 'index.html'), 'utf8'), /<script data-wk-analytics [^>]*data-site="abcdefghij0123456789"/);
|
||||
assert.ok(existsSync(join(t, 'datenschutz-analyse.html')));
|
||||
const d = setup({ files: { 'package.json': PKG({ dev: 'vite' }) }, dockerImpl: () => ({ code: 0 }) });
|
||||
const dr = await wait(d.jobs, d.jobs.submit(job({ mode: 'dev', analytics })));
|
||||
assert.equal(dr.result, 'dev');
|
||||
assert.ok(!existsSync(join(d.config.previewsRoot, 'demo')), 'dev veröffentlicht nichts, also keine Einsetzung');
|
||||
});
|
||||
|
||||
test('build: Install+Build im Container (corepack für pnpm), Erfolg ersetzt Ordner atomar', async () => {
|
||||
const s = setup({
|
||||
files: { 'package.json': PKG(), 'pnpm-lock.yaml': '' },
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an.
|
||||
* Idempotent: vorhandene Attribute werden uebersprungen.
|
||||
* Usage:
|
||||
* node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen)
|
||||
* node scripts/analyse-setup.mjs (fehlende Attribute anlegen)
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { config } from '../server/config.js'
|
||||
|
||||
export const ANALYSE_ATTRIBUTES = [
|
||||
{ key: 'accentColor', type: 'string', size: 7, required: false },
|
||||
{ key: 'avvVersion', type: 'string', size: 20, required: false },
|
||||
{ key: 'avvAcceptedAt', type: 'string', size: 40, required: false },
|
||||
{ key: 'avvAcceptedBy', type: 'string', size: 100, required: false },
|
||||
{ key: 'activatedAt', type: 'string', size: 40, required: false },
|
||||
{ key: 'offSince', type: 'string', size: 40, required: false },
|
||||
{ key: 'activatedMailAt', type: 'string', size: 40, required: false },
|
||||
{ key: 'privacyTicketId', type: 'string', size: 40, required: false },
|
||||
]
|
||||
|
||||
/** Echter Appwrite-Client (REST) fuer die Collection portalFeatures. */
|
||||
export function createAppwriteClient() {
|
||||
const { endpoint, projectId, databaseId, apiKey } = config.appwrite
|
||||
const collectionId = config.collections.portalFeatures
|
||||
const base = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections/${collectionId}`
|
||||
|
||||
async function aw(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Appwrite-Project': projectId,
|
||||
'X-Appwrite-Key': apiKey,
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(data.message || `Appwrite ${response.status}`)
|
||||
return data
|
||||
}
|
||||
|
||||
return {
|
||||
async listAttributes() {
|
||||
// Ohne limit liefert Appwrite nur 25 Attribute - die Sammlung hat mehr
|
||||
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }))
|
||||
const data = await aw(`/attributes?queries[]=${q}`)
|
||||
return data.attributes || []
|
||||
},
|
||||
async createAttribute({ type, ...body }) {
|
||||
await aw(`/attributes/${type}`, { method: 'POST', body })
|
||||
},
|
||||
async waitForAttribute(key, maxSec = 30) {
|
||||
for (let i = 0; i < maxSec; i += 1) {
|
||||
const attr = await aw(`/attributes/${key}`).catch(() => null)
|
||||
if (attr?.status === 'available') return
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Attribut ${key} wurde nicht verfuegbar`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function main({ check = false, client, log = console.log } = {}) {
|
||||
const api = client || createAppwriteClient()
|
||||
const existing = new Set((await api.listAttributes()).map((a) => a.key))
|
||||
const missing = ANALYSE_ATTRIBUTES.filter((a) => !existing.has(a.key))
|
||||
|
||||
for (const attr of ANALYSE_ATTRIBUTES) {
|
||||
if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`)
|
||||
else if (check) log(`- Attribut ${attr.key} FEHLT`)
|
||||
}
|
||||
|
||||
if (check) {
|
||||
log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.')
|
||||
return { missing }
|
||||
}
|
||||
|
||||
for (const attr of missing) {
|
||||
await api.createAttribute(attr)
|
||||
log(`+ Attribut ${attr.key}`)
|
||||
}
|
||||
for (const attr of missing) await api.waitForAttribute(attr.key)
|
||||
log('Fertig.')
|
||||
return { missing }
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (invokedDirectly) {
|
||||
const check = process.argv.includes('--check')
|
||||
main({ check })
|
||||
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e.message)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
@@ -310,6 +310,14 @@ export const config = {
|
||||
logDir: process.env.PREVIEW_LOG_DIR || path.join(process.cwd(), 'upload-data', 'preview-logs'),
|
||||
},
|
||||
|
||||
analyse: {
|
||||
enabled: process.env.ANALYSE_ENABLED === 'on',
|
||||
url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091',
|
||||
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
|
||||
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
|
||||
avvVersion: '2026-10-01',
|
||||
},
|
||||
|
||||
invoiceNinja: {
|
||||
url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''),
|
||||
apiToken: process.env.INVOICE_NINJA_API_TOKEN || '',
|
||||
|
||||
@@ -20,6 +20,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
||||
import { runActivation } from './services/siteFeatures.js'
|
||||
import { runAboCheck } from './services/analyse.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
@@ -32,6 +33,7 @@ import adminInvoiceClientsRoutes from './routes/admin/invoiceClients.js'
|
||||
import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
|
||||
import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
@@ -82,6 +84,7 @@ app.use('/api/admin/employees', adminEmployeesRoutes)
|
||||
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||
app.use('/api/admin/preview-open', adminPreviewOpenRoutes)
|
||||
app.use('/api/admin/switch-link', adminSwitchLinkRoutes)
|
||||
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
||||
app.use('/webhook', giteaWebhookRoutes)
|
||||
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
||||
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||
@@ -166,6 +169,15 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
.catch((err) => console.warn('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
setInterval(activateFeatures, 5 * 60 * 1000).unref()
|
||||
|
||||
// Website-Analyse: aktive Analysen ohne laufendes Wartungs-Abo beenden (taeglich, 2 Minuten nach Start beginnend).
|
||||
// Laeuft unabhaengig vom Schalter ANALYSE_ENABLED: er liest nur Analyse-Dokumente und ist ohne solche wirkungslos.
|
||||
const analyseAboCheck = () =>
|
||||
runAboCheck({ now: new Date() })
|
||||
.then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`))
|
||||
.catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message))
|
||||
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
|
||||
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// Neue Gitea-Repos erkennen, Zuordnungs-Mail senden, Erinnerungen/Wiederholungen (alle 10 Minuten)
|
||||
const repoAssign = () =>
|
||||
runRepoAssignJob({ now: new Date() }).catch((err) =>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K18 · Analyse aktiv (Website-Analyse)
|
||||
Betreff: Deine Website-Analyse ist aktiv – {{projekt}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Deine Website-Analyse ist aktiv – {{projekt}}</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Website-Analyse ist eingeschaltet. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Aktiv</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">✓ Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse ist aktiv</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist eingeschaltet. Besucher sehen jetzt einen Hinweis-Banner, Du findest die Auswertung im Kundenportal. Wir ergänzen Deine Datenschutzerklärung um einen Hinweis auf die Analyse und melden uns, sobald das erledigt ist.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auswertung öffnen →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
K19 · Analyse beendet (Website-Analyse)
|
||||
Betreff: Deine Website-Analyse wurde beendet – {{projekt}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="supported-color-schemes" content="light dark">
|
||||
<title>Deine Website-Analyse wurde beendet – {{projekt}}</title>
|
||||
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
|
||||
<style>
|
||||
a{color:#111111;}
|
||||
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
|
||||
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background:#f1f0ec;">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Website-Analyse wurde beendet. ͏ ͏ ͏</span>
|
||||
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
|
||||
<tr><td align="center" style="padding:32px 12px;">
|
||||
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
|
||||
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
|
||||
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
|
||||
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Beendet</td>
|
||||
</tr></table></td></tr>
|
||||
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse wurde beendet</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
|
||||
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,82 @@
|
||||
// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. AVV wird mündlich bestätigt.
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { activateAnalyse, analyseEligibility, deactivateAnalyse, ELIGIBILITY_MESSAGES } from '../../services/analyse.js'
|
||||
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
||||
|
||||
const router = Router()
|
||||
router.use(requireAppwriteStaff)
|
||||
|
||||
function sendError(res, err) {
|
||||
const status = [400, 403, 404, 409, 503].includes(err.status) && !err.type ? err.status : 500
|
||||
if (status === 500) console.error('[admin-analyse]', err.message)
|
||||
res.status(status).json({ error: status === 500 ? 'Die Analyse konnte nicht geändert werden' : err.message })
|
||||
}
|
||||
|
||||
// Lesezugriff fuer das WOMS: Zustand + AVV-Angaben (nie siteKey oder Geheimnisse)
|
||||
router.get('/:projectId', async (req, res) => {
|
||||
try {
|
||||
let project
|
||||
try {
|
||||
project = await getDocument(config.collections.websiteProjects, req.params.projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
throw err
|
||||
}
|
||||
const customerId = project.customerId || ''
|
||||
const [doc, eligibility] = await Promise.all([
|
||||
customerId ? getFeatureDoc(customerId, project.$id, 'analyse') : null,
|
||||
analyseEligibility(customerId, project.$id),
|
||||
])
|
||||
res.json({
|
||||
enabled: config.analyse.enabled,
|
||||
state: doc ? doc.state || 'off' : null,
|
||||
accentColor: doc?.accentColor || '#111111',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
avvAcceptedAt: doc?.avvAcceptedAt || null,
|
||||
avvAcceptedBy: doc?.avvAcceptedBy || null,
|
||||
activatedAt: doc?.activatedAt || null,
|
||||
offSince: doc?.offSince || null,
|
||||
eligible: eligibility.ok,
|
||||
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
|
||||
})
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/:projectId', async (req, res) => {
|
||||
try {
|
||||
if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' })
|
||||
const { on, avvConfirmedVerbally } = req.body || {}
|
||||
if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' })
|
||||
if (on && avvConfirmedVerbally !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat' })
|
||||
}
|
||||
let project
|
||||
try {
|
||||
project = await getDocument(config.collections.websiteProjects, req.params.projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return res.status(404).json({ error: 'Projekt nicht gefunden' })
|
||||
throw err
|
||||
}
|
||||
const customerId = project.customerId
|
||||
if (!customerId) return res.status(400).json({ error: 'Projekt hat keinen Kunden' })
|
||||
const by = String(req.appwriteUser?.name || req.appwriteUser?.email || 'Mitarbeiter').slice(0, 100)
|
||||
const { doc } = on
|
||||
? await activateAnalyse({
|
||||
customerId,
|
||||
projectId: project.$id,
|
||||
consent: { accepted: true, avvVersion: config.analyse.avvVersion },
|
||||
by,
|
||||
})
|
||||
: await deactivateAnalyse({ customerId, projectId: project.$id, by })
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
} catch (err) {
|
||||
sendError(res, err)
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -6,9 +6,8 @@ import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { envKey, encryptEnv, decryptEnv, envNames, validateEnvChange } from '../../services/previewEnv.js'
|
||||
import { listRunLogs, readRunLog, isRunFile } from '../../services/previewLogs.js'
|
||||
import * as runnerClient from '../../services/previewRunnerClient.js'
|
||||
import { runDeploy, fetchPreviewConfig } from '../../services/previewDeploy.js'
|
||||
import { triggerRebuild, previewBranchOf } from '../../services/previewRebuild.js'
|
||||
import { withLocks } from '../../services/keyedLock.js'
|
||||
import { getRepoInfo } from '../../services/giteaAdmin.js'
|
||||
import { repoHasDevScript } from '../../services/previewDevScript.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -64,29 +63,6 @@ async function view(doc) {
|
||||
}
|
||||
}
|
||||
|
||||
async function previewBranchOf(doc) {
|
||||
return doc.previewBranch || (await getRepoInfo(doc.repoFullName).catch(() => null))?.defaultBranch || 'main'
|
||||
}
|
||||
|
||||
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||||
async function triggerRebuild(doc) {
|
||||
const repo = doc.repoFullName
|
||||
const branch = await previewBranchOf(doc)
|
||||
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
displayName: doc.projectName,
|
||||
...(found || {}),
|
||||
branch,
|
||||
subdomain: doc.subdomain,
|
||||
}
|
||||
const result = await runDeploy(repo, branch, cfg, { project: doc })
|
||||
// runDeploy meldet Runner-Fehler im Ergebnis; Schutzschalter (503) an WOMS durchreichen
|
||||
if (result?.error && /Server ausgelastet/.test(result.error)) throw fail(503, BUSY)
|
||||
return result
|
||||
}
|
||||
|
||||
const wrap = (fn) => async (req, res) => {
|
||||
try {
|
||||
await fn(req, res)
|
||||
|
||||
@@ -30,7 +30,11 @@ function previewUrlFor(p) {
|
||||
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const { kinds, features } = servicesCatalog()
|
||||
const { kinds, features: catalogFeatures } = servicesCatalog()
|
||||
// Katalog nicht mutieren: Analyse ist 'available', sobald der Schalter ANALYSE_ENABLED an ist
|
||||
const features = config.analyse.enabled
|
||||
? { ...catalogFeatures, analyse: { ...catalogFeatures.analyse, status: 'available' } }
|
||||
: catalogFeatures
|
||||
const projRes = await listDocuments(config.collections.websiteProjects, [Query.limit(5000)])
|
||||
const projects = (projRes || []).filter((p) => !p.archived && !p.isTemplate)
|
||||
|
||||
@@ -39,6 +43,18 @@ router.get('/', async (_req, res) => {
|
||||
loadByIds(config.collections.workorders, projects.map((p) => p.ticketId)),
|
||||
])
|
||||
|
||||
// Ein Fehler hier darf die Services-Seite nicht kosten: dann ohne Analyse-Zustand
|
||||
let analyseState = new Map()
|
||||
try {
|
||||
const analyseDocs = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('featureKey', 'analyse'),
|
||||
Query.limit(5000),
|
||||
])
|
||||
analyseState = new Map((analyseDocs || []).map((d) => [d.projectId, d.state || 'off']))
|
||||
} catch (err) {
|
||||
console.warn('[admin-services] Analyse-Zustände nicht geladen:', err.message)
|
||||
}
|
||||
|
||||
const groups = {}
|
||||
for (const p of projects) {
|
||||
const key = kinds.some((k) => k.key === p.kind) ? p.kind : 'missing'
|
||||
@@ -52,7 +68,7 @@ router.get('/', async (_req, res) => {
|
||||
woid: tickets.get(p.ticketId)?.woid || '',
|
||||
previewUrl: previewUrlFor(p),
|
||||
previewState: p.previewState || '',
|
||||
features: {},
|
||||
features: { analyse: analyseState.get(p.$id) || null },
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+142
-2
@@ -14,18 +14,61 @@ import {
|
||||
runActivation,
|
||||
} from '../services/siteFeatures.js'
|
||||
|
||||
import {
|
||||
ELIGIBILITY_MESSAGES,
|
||||
activateAnalyse,
|
||||
analyseDeps,
|
||||
analyseHosts,
|
||||
isDevPreview,
|
||||
analyseEligibility,
|
||||
deactivateAnalyse,
|
||||
} from '../services/analyse.js'
|
||||
|
||||
const router = Router()
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
|
||||
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
|
||||
function fail(res, err, fallback) {
|
||||
const own = err.status >= 400 && err.status < 500 && !err.type
|
||||
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
|
||||
if (own) return res.status(err.status).json({ error: err.message })
|
||||
console.error('[funktionen]', err.status || '', err.type || '', err.message)
|
||||
return res.status(500).json({ error: fallback })
|
||||
}
|
||||
|
||||
const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet'
|
||||
const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
async function analyseView(customerId, projectId) {
|
||||
if (!config.analyse.enabled) return { comingSoon: true }
|
||||
const [doc, eligibility] = await Promise.all([getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId)])
|
||||
let devMode = false
|
||||
if (doc?.state === 'active') {
|
||||
const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null))
|
||||
devMode = isDevPreview(project)
|
||||
}
|
||||
return {
|
||||
state: doc?.state || 'off',
|
||||
devMode,
|
||||
accentColor: doc?.accentColor || '#111111',
|
||||
eligible: eligibility.ok,
|
||||
reason: eligibility.reason || null,
|
||||
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
}
|
||||
}
|
||||
|
||||
// Schalter aus: alle Analyse-Routen 409
|
||||
function requireAnalyseEnabled(_req, res, next) {
|
||||
if (!config.analyse.enabled) return res.status(409).json({ error: ANALYSE_OFF })
|
||||
next()
|
||||
}
|
||||
|
||||
function analyseProjectId(value) {
|
||||
if (typeof value !== 'string' || !value) throw Object.assign(new Error('Bitte wähle ein Projekt.'), { status: 400 })
|
||||
return value
|
||||
}
|
||||
|
||||
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
|
||||
router.get('/', requireSession, async (req, res) => {
|
||||
try {
|
||||
@@ -33,8 +76,18 @@ router.get('/', requireSession, async (req, res) => {
|
||||
// Ein Fehler beim Freischalten (z. B. Ticketsystem) darf die Liste nicht verhindern
|
||||
await runActivation({ customerId }).catch((err) => console.error('[funktionen] Freischalten fehlgeschlagen:', err.message))
|
||||
await backfillInvoiceLinks(customerId)
|
||||
const projects = await listCustomerFeatures(customerId)
|
||||
// Ein Fehler bei einem Projekt (z. B. Abo-Pruefung) darf nicht die ganze Liste kosten
|
||||
await Promise.all(
|
||||
projects.map(async (p) => {
|
||||
p.analyse = await analyseView(customerId, p.projectId).catch((err) => {
|
||||
console.error('[funktionen] Analyse-Zustand fehlgeschlagen:', err.message)
|
||||
return { state: 'unknown' }
|
||||
})
|
||||
}),
|
||||
)
|
||||
res.json({
|
||||
projects: await listCustomerFeatures(customerId),
|
||||
projects,
|
||||
setupEstimate: config.funktionen.setupEstimate,
|
||||
consentText: config.funktionen.consentText,
|
||||
})
|
||||
@@ -110,4 +163,91 @@ router.post('/news/checkout', requireSession, async (req, res) => {
|
||||
}
|
||||
})
|
||||
|
||||
const analyseGuard = [requireSession, requireAnalyseEnabled]
|
||||
|
||||
router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
||||
}
|
||||
const { projectId, accentColor, consent } = req.body || {}
|
||||
const { doc } = await activateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(projectId),
|
||||
accentColor,
|
||||
consent,
|
||||
by: 'customer',
|
||||
})
|
||||
res.json({ ok: true, state: doc.state })
|
||||
} catch (err) {
|
||||
fail(res, err, ANALYSE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
if (req.session.viewAs) {
|
||||
return res.status(403).json({ error: 'Im "Als Kunde ansehen"-Modus kann nichts geändert werden.' })
|
||||
}
|
||||
const { doc } = await deactivateAnalyse({
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(req.body?.projectId),
|
||||
by: 'customer',
|
||||
})
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
} catch (err) {
|
||||
fail(res, err, ANALYSE_ERROR)
|
||||
}
|
||||
})
|
||||
|
||||
// Eigenes Projekt und Analyse-Dokument mit siteKey (aktiv oder aus: Daten liegen noch bis zu 60 Tage)
|
||||
async function ownAnalyseDoc(req, projectId) {
|
||||
const customerId = getSessionCustomerId(req)
|
||||
const project = await assertOwnProject(customerId, analyseProjectId(projectId))
|
||||
const doc = await getFeatureDoc(customerId, projectId, 'analyse')
|
||||
return { project, doc }
|
||||
}
|
||||
|
||||
router.get('/analyse/stats', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
const range = String(req.query.range || '30')
|
||||
if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum' })
|
||||
const { doc } = await ownAnalyseDoc(req, req.query.projectId)
|
||||
if (!doc?.siteKey || !['active', 'off'].includes(doc.state)) {
|
||||
return res.status(409).json({ error: 'Für dieses Projekt gibt es keine Auswertung.' })
|
||||
}
|
||||
let stats
|
||||
try {
|
||||
stats = await analyseDeps.client.getStats(doc.siteKey, Number(range))
|
||||
} catch (err) {
|
||||
console.error('[funktionen] Auswertung:', err.status || '', err.message)
|
||||
return res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' })
|
||||
}
|
||||
res.json(stats)
|
||||
} catch (err) {
|
||||
fail(res, err, 'Die Auswertung konnte gerade nicht geladen werden.')
|
||||
}
|
||||
})
|
||||
|
||||
router.post('/analyse/heatmap-link', analyseGuard, async (req, res) => {
|
||||
try {
|
||||
const { projectId, path, vp } = req.body || {}
|
||||
if (typeof path !== 'string' || !path.startsWith('/') || path.length > 300 || /[?#\\\s\u0000-\u001f\u007f]/.test(path)) {
|
||||
return res.status(400).json({ error: 'Ungültiger Pfad' })
|
||||
}
|
||||
if (!['m', 't', 'd'].includes(vp)) return res.status(400).json({ error: 'Ungültige Ansicht' })
|
||||
const { project, doc } = await ownAnalyseDoc(req, projectId)
|
||||
if (!doc?.siteKey || doc.state !== 'active') {
|
||||
return res.status(409).json({ error: 'Die Website-Analyse ist für dieses Projekt nicht aktiv.' })
|
||||
}
|
||||
const domains = analyseHosts(project)
|
||||
if (!domains.length) return res.status(409).json({ error: 'Keine gültige Domain' })
|
||||
const host = project.liveDomain ? domains[domains.length - 1] : domains[0]
|
||||
const token = analyseDeps.client.heatToken({ siteKey: doc.siteKey, path, vp })
|
||||
res.json({ url: `https://${host}${path}?__wk_heat=${encodeURIComponent(token)}` })
|
||||
} catch (err) {
|
||||
fail(res, err, 'Der Heatmap-Link konnte gerade nicht erstellt werden.')
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,242 @@
|
||||
// Website-Analyse: ein-/ausschalten je Projekt, Wartungs-Abo-Pruefung, Datenschutz-Ticket, Kundenmails
|
||||
import { config } from '../config.js'
|
||||
import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { analyticsClient } from './analyticsClient.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { sendCustomerMail } from './mailer.js'
|
||||
import { triggerRebuild } from './previewRebuild.js'
|
||||
import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
|
||||
const FEATURE_KEY = 'analyse'
|
||||
const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
|
||||
const COLOR_RE = /^#[0-9a-f]{6}$/
|
||||
const DEFAULT_COLOR = '#111111'
|
||||
|
||||
/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket }
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export const ELIGIBILITY_MESSAGES = {
|
||||
nicht_dein_projekt: 'Kein Zugriff auf dieses Projekt',
|
||||
kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.',
|
||||
nicht_oeffentlich: 'Die Website-Analyse gibt es nur für öffentliche Websites.',
|
||||
keine_subdomain: 'Für dieses Projekt ist noch keine Subdomain eingerichtet.',
|
||||
}
|
||||
const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 }
|
||||
|
||||
/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status. */
|
||||
async function hasWartungsAbo(customerId) {
|
||||
const sub = await getCurrentSubscription(customerId)
|
||||
return Boolean(sub && sub.productKey === 'wartung' && ACTIVE_STATUSES.has(sub.status))
|
||||
}
|
||||
|
||||
async function loadOwnProject(customerId, projectId) {
|
||||
try {
|
||||
return await assertOwnProject(customerId, projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 403) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {Promise<{ ok: boolean, reason?: string, project?: object }>} */
|
||||
export async function analyseEligibility(customerId, projectId) {
|
||||
const project = await loadOwnProject(customerId, projectId)
|
||||
if (!project) return { ok: false, reason: 'nicht_dein_projekt' }
|
||||
if (!(await hasWartungsAbo(customerId))) return { ok: false, reason: 'kein_abo' }
|
||||
if (project.isPublic !== true) return { ok: false, reason: 'nicht_oeffentlich' }
|
||||
if (!project.subdomain) return { ok: false, reason: 'keine_subdomain' }
|
||||
return { ok: true, project }
|
||||
}
|
||||
|
||||
const HOSTNAME_RE = /^(?=.{4,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z][a-z0-9-]{0,61}[a-z0-9]$/
|
||||
|
||||
/** Gespeicherte Hosts: `<subdomain>.project.webklar.com` plus Live-Domain (klein, ohne Schema/Pfad), ohne Doppelte; ungueltige werden uebersprungen. */
|
||||
export function analyseHosts(project) {
|
||||
const domains = []
|
||||
if (project.subdomain) domains.push(`${String(project.subdomain).trim().toLowerCase()}.project.webklar.com`)
|
||||
const live = String(project.liveDomain || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^[a-z]+:\/\//, '')
|
||||
.replace(/[/?#].*$/, '')
|
||||
if (live) domains.push(live)
|
||||
return [...new Set(domains)].filter((d) => HOSTNAME_RE.test(d))
|
||||
}
|
||||
|
||||
/** Erlaubte Origin-Domains des Messdienstes: gespeicherte Hosts plus www-/Apex-Gegenstueck der Live-Domain (Besucher kommen mit und ohne www), max. 5. */
|
||||
export function analyseDomains(project) {
|
||||
const hosts = analyseHosts(project)
|
||||
const live = project.liveDomain ? hosts[hosts.length - 1] : ''
|
||||
const extra = live && hosts.length > 1 ? (live.startsWith('www.') ? live.slice(4) : `www.${live}`) : ''
|
||||
return [...new Set([...hosts, extra].filter(Boolean))].filter((d) => HOSTNAME_RE.test(d)).slice(0, 5)
|
||||
}
|
||||
|
||||
/** Dev-Server-Projekte (previewDetected 'dev…') bekommen kein Messskript: der Runner injiziert nur in veroeffentlichte Ordner. */
|
||||
export const isDevPreview = (project) => String(project?.previewDetected || '').startsWith('dev')
|
||||
|
||||
async function customerOf(customerId) {
|
||||
try {
|
||||
return await getDocument(config.collections.customers, customerId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function rebuildSafe(project) {
|
||||
if (!project.repoFullName || project.previewMode === 'off') return
|
||||
try {
|
||||
await analyseDeps.rebuild(project)
|
||||
} catch (err) {
|
||||
// Zustand bleibt gespeichert, der naechste Neubau holt nach
|
||||
console.warn(`[analyse] Neubau für Projekt ${project.$id} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
async function mailSafe(customerId, template, project) {
|
||||
try {
|
||||
const customer = await customerOf(customerId)
|
||||
if (!customer?.email) return false
|
||||
await sendCustomerMail(customer.email, {
|
||||
template,
|
||||
values: {
|
||||
kunde_name: customer.name || '',
|
||||
projekt: project.projectName || project.subdomain || '',
|
||||
portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`,
|
||||
},
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error(`[analyse] ${template} an Kunde ${customerId} fehlgeschlagen:`, err.message)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function privacyTicket(customerId, project, siteKey) {
|
||||
const name = project.projectName || project.subdomain || ''
|
||||
const domains = analyseHosts(project)
|
||||
return analyseDeps.ticket({
|
||||
customerId,
|
||||
project,
|
||||
featureKey: FEATURE_KEY,
|
||||
siteKey,
|
||||
topic: `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`,
|
||||
details: [
|
||||
`Funktion: ${FEATURE_KEY}`,
|
||||
`Projekt: ${name}`,
|
||||
`Domain(s): ${domains.join(', ')}`,
|
||||
`Zusatzseite: https://${domains[domains.length - 1]}/datenschutz-analyse.html`,
|
||||
'Satz + Link in die bestehende Datenschutzerklärung einfügen',
|
||||
].join('\n'),
|
||||
})
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
|
||||
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
|
||||
}
|
||||
const color = accentColor === undefined || accentColor === null || accentColor === '' ? DEFAULT_COLOR : String(accentColor).toLowerCase()
|
||||
if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe')
|
||||
|
||||
return withLocks([`analyse:${projectId}`], async () => {
|
||||
const eligibility = await analyseEligibility(customerId, projectId)
|
||||
if (!eligibility.ok) throw httpError(ELIGIBILITY_STATUS[eligibility.reason], ELIGIBILITY_MESSAGES[eligibility.reason])
|
||||
const { project } = eligibility
|
||||
if (!analyseDomains(project).length) throw httpError(409, 'Keine gültige Domain für die Analyse')
|
||||
|
||||
const existing = await ensureFeatureDoc(customerId, projectId, FEATURE_KEY)
|
||||
if (existing.state === 'disabled' && by === 'customer') {
|
||||
throw httpError(403, 'Analyse wurde gesperrt – bitte melde dich bei uns')
|
||||
}
|
||||
|
||||
let doc = existing
|
||||
let changed = false
|
||||
if (existing.state !== 'active') {
|
||||
const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey()
|
||||
// Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar)
|
||||
await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true })
|
||||
const now = new Date().toISOString()
|
||||
doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
|
||||
...stateFields('active'),
|
||||
siteKey,
|
||||
accentColor: color,
|
||||
avvVersion: consent.avvVersion,
|
||||
avvAcceptedAt: now,
|
||||
avvAcceptedBy: String(by || 'customer').slice(0, 100),
|
||||
activatedAt: now,
|
||||
offSince: '',
|
||||
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
|
||||
})
|
||||
changed = true
|
||||
} else if (existing.accentColor !== color) {
|
||||
doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color })
|
||||
changed = true
|
||||
}
|
||||
|
||||
// Selbstheilend: was von frueheren Versuchen noch fehlt (Ticket, Mail), wird jetzt nachgeholt.
|
||||
// Ticket vor Mail, Marker erst nach Erfolg => je genau ein Ticket und eine K18.
|
||||
const needTicket = !doc.privacyTicketId
|
||||
const needMail = !doc.activatedMailAt
|
||||
if (needTicket) {
|
||||
const { ticketId } = await privacyTicket(customerId, project, doc.siteKey)
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId })
|
||||
}
|
||||
if (changed || needTicket || needMail) await rebuildSafe(project)
|
||||
if (needMail && (await mailSafe(customerId, 'K18', project))) {
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
|
||||
}
|
||||
return { doc, changed: changed || needTicket || needMail }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer' }) {
|
||||
return withLocks([`analyse:${projectId}`], async () => {
|
||||
const project = await loadOwnProject(customerId, projectId)
|
||||
if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt)
|
||||
const existing = await getFeatureDoc(customerId, projectId, FEATURE_KEY)
|
||||
if (!existing || existing.state === 'off') return { doc: existing, changed: false }
|
||||
// Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter)
|
||||
if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false }
|
||||
if (existing.siteKey) {
|
||||
await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false })
|
||||
}
|
||||
const doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
|
||||
...stateFields('off'),
|
||||
offSince: now.toISOString(),
|
||||
})
|
||||
await rebuildSafe(project)
|
||||
return { doc, changed: true }
|
||||
})
|
||||
}
|
||||
|
||||
/** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */
|
||||
export async function runAboCheck({ now = new Date() } = {}) {
|
||||
const docs = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('featureKey', FEATURE_KEY),
|
||||
Query.equal('state', 'active'),
|
||||
Query.limit(1000),
|
||||
])
|
||||
const deactivated = []
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
|
||||
if (await hasWartungsAbo(doc.customerId)) continue
|
||||
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now })
|
||||
if (!changed) continue
|
||||
deactivated.push(doc.$id)
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({}))
|
||||
await mailSafe(doc.customerId, 'K19', project)
|
||||
} catch (err) {
|
||||
console.error(`[analyse] Abo-Prüfung für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
return { checked: docs.length, deactivated }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Client fuer die Admin-API des Analytics-Collectors (interner Listener, Port 8091).
|
||||
// Token und Secret erscheinen nie in Fehlermeldungen oder Logs.
|
||||
import crypto from 'node:crypto'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const HEAT_TTL_SECONDS = 30 * 60
|
||||
|
||||
function fail(message, status) {
|
||||
const err = new Error(message)
|
||||
err.status = status
|
||||
return err
|
||||
}
|
||||
|
||||
export function createAnalyticsClient(opts = {}) {
|
||||
const cfg = { ...config.analyse, ...opts }
|
||||
const timeoutMs = opts.timeoutMs || 10000
|
||||
|
||||
async function request(method, path, body) {
|
||||
if (!cfg.adminToken) throw fail('Auswertung nicht eingerichtet', 503)
|
||||
const controller = new AbortController()
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
||||
try {
|
||||
const response = await fetch(`${String(cfg.url).replace(/\/$/, '')}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${cfg.adminToken}`,
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw fail(`Auswertung antwortet mit Fehler ${response.status}`, response.status)
|
||||
return data
|
||||
} catch (e) {
|
||||
if (e.status) throw e
|
||||
if (e.name === 'AbortError') throw fail('Auswertung antwortet nicht (Zeitueberschreitung)', 504)
|
||||
throw fail('Auswertung nicht erreichbar', 502)
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const sitePath = (siteKey) => `/a/admin/sites/${encodeURIComponent(siteKey)}`
|
||||
|
||||
return {
|
||||
putSite(siteKey, { projectId, domains, active }) {
|
||||
return request('PUT', sitePath(siteKey), { projectId, domains, active })
|
||||
},
|
||||
deleteSite(siteKey) {
|
||||
return request('DELETE', sitePath(siteKey))
|
||||
},
|
||||
getStats(siteKey, range) {
|
||||
return request('GET', `${sitePath(siteKey)}/stats?range=${encodeURIComponent(range)}`)
|
||||
},
|
||||
// Format (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, byte-gleich zu analytics/src/heat.js
|
||||
heatToken({ siteKey, path, vp, now = Date.now() }) {
|
||||
if (!cfg.heatSecret) throw fail('Auswertung nicht eingerichtet', 503)
|
||||
const exp = Math.floor(now / 1000) + HEAT_TTL_SECONDS
|
||||
const part = Buffer.from(JSON.stringify({ s: siteKey, p: path, v: vp, e: exp })).toString('base64url')
|
||||
return `${part}.${crypto.createHmac('sha256', cfg.heatSecret).update(part).digest('hex')}`
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const analyticsClient = createAnalyticsClient()
|
||||
@@ -3,7 +3,8 @@ import { promisify } from 'node:util'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../config.js'
|
||||
import { listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { listDocuments, updateDocument, getDocument, Query } from './appwriteAdmin.js'
|
||||
import { getFeatureDoc } from './siteFeatures.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
import { envKey, decryptEnv, maskLog } from './previewEnv.js'
|
||||
@@ -541,7 +542,7 @@ export async function syncPreviewTlsOnStartup({
|
||||
|
||||
// Zeiten des Hintergrund-Verfolgers (fuer Tests ueberschreibbar)
|
||||
const NO_DEV_SCRIPT_ERROR = 'Kein dev-Skript in package.json'
|
||||
export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000 }
|
||||
export const runnerTiming = { pollMs: 2000, maxMs: 60 * 60 * 1000, unreachableMs: 5 * 60 * 1000, docWaitMs: 15000, docPollMs: 50 }
|
||||
// Neuester Lauf je Repo: aeltere Verfolger schreiben nichts mehr (R14)
|
||||
const latestRun = new Map()
|
||||
const followers = new Set()
|
||||
@@ -599,6 +600,20 @@ function clampAttrs(data) {
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Neues Projekt: der Webhook legt das Dokument erst NACH runDeploy an. Ein schneller Verfolger
|
||||
* findet es dann noch nicht - kurz auf die Anlage warten, sonst geht sein Ergebnis verloren.
|
||||
*/
|
||||
async function waitForProject(repoFullName, project) {
|
||||
if (project?.$id) return project
|
||||
const until = Date.now() + runnerTiming.docWaitMs
|
||||
for (;;) {
|
||||
const doc = await findProject(repoFullName)
|
||||
if (doc || Date.now() >= until) return doc || project
|
||||
await new Promise((r) => setTimeout(r, runnerTiming.docPollMs))
|
||||
}
|
||||
}
|
||||
|
||||
async function patchProject(repoFullName, project, data) {
|
||||
data = clampAttrs(data)
|
||||
const doc = project?.$id ? project : await findProject(repoFullName)
|
||||
@@ -648,8 +663,11 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
||||
}
|
||||
if (!current()) return
|
||||
|
||||
project = await waitForProject(repoFullName, project)
|
||||
if (!current()) return
|
||||
|
||||
if (run?.log?.length) {
|
||||
try { await saveRunLog((project?.$id || (await findProject(repoFullName))?.$id), run.log, env) } catch (err) {
|
||||
try { await saveRunLog(project?.$id, run.log, env) } catch (err) {
|
||||
console.error('[previewDeploy] Protokoll speichern fehlgeschlagen:', err.message)
|
||||
}
|
||||
}
|
||||
@@ -669,7 +687,7 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
||||
}
|
||||
if (run.result === 'dev') {
|
||||
await patchProject(repoFullName, project, { ...base, previewState: 'sleeping' })
|
||||
try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined }) } catch (err) {
|
||||
try { await runnerClient.startDev(subdomain, { ...job, mode: 'dev', displayName: undefined, analytics: undefined }) } catch (err) {
|
||||
console.error('[previewDeploy] Dev-Registrierung fehlgeschlagen:', err.message)
|
||||
}
|
||||
} else {
|
||||
@@ -696,6 +714,39 @@ async function followRun({ repoFullName, project, token, runId, subdomain, job,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyse-Feld fuer den Build-Auftrag: nur bei aktiver Funktion 'analyse', oeffentlichem Projekt mit Subdomain
|
||||
* und vollstaendigen Datenschutz-Angaben (Firma + E-Mail Pflicht, Adresse optional). Sonst null.
|
||||
* Wirft nie: der Build darf an der Analyse nicht scheitern.
|
||||
*/
|
||||
async function buildAnalytics(project) {
|
||||
try {
|
||||
if (!project?.$id || project.isPublic !== true || !project.subdomain || !project.customerId) return null
|
||||
const feature = await getFeatureDoc(project.customerId, project.$id, 'analyse')
|
||||
if (!feature || feature.state !== 'active' || !feature.siteKey) return null
|
||||
let customer = null
|
||||
try {
|
||||
customer = await getDocument(config.collections.customers, project.customerId)
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err
|
||||
}
|
||||
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const company = clean(customer?.companyName || customer?.name).slice(0, 200).trim()
|
||||
const email = clean(customer?.email)
|
||||
if (!company || !email || email.length > 200 || !email.includes('@')) {
|
||||
console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`)
|
||||
return null
|
||||
}
|
||||
const address = [clean(customer.street), clean(`${customer.postalCode || ''} ${customer.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim()
|
||||
const stored = String(feature.accentColor || '').toLowerCase()
|
||||
const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111'
|
||||
return { siteKey: feature.siteKey, color, privacy: { company, address, email } }
|
||||
} catch (err) {
|
||||
console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function runDeployViaRunner(repoFullName, branch, previewConfig, { project, sha } = {}) {
|
||||
const subdomain = previewConfig.subdomain || repoFullName.split('/').pop()
|
||||
// Platz fuer diesen Aufruf sofort belegen (vor jedem await): aeltere Verfolger scheiden aus
|
||||
@@ -736,6 +787,7 @@ async function runDeployViaRunner(repoFullName, branch, previewConfig, { project
|
||||
...(previewConfig.index ? { index: previewConfig.index } : {}),
|
||||
...(displayName ? { displayName } : {}),
|
||||
env,
|
||||
analytics: await buildAnalytics(doc),
|
||||
}
|
||||
const { runId } = await runnerClient.submitBuild(job)
|
||||
if (current()) await patchProject(repoFullName, doc, { previewState: 'building', previewError: warning })
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// Neubau einer Preview/Live-Seite anstossen (WOMS-Preview-Block, Website-Analyse ein/aus)
|
||||
import { getRepoInfo } from './giteaAdmin.js'
|
||||
import { runDeploy, fetchPreviewConfig } from './previewDeploy.js'
|
||||
|
||||
const BUSY = 'Server ausgelastet, versuche es gleich noch mal'
|
||||
|
||||
export async function previewBranchOf(doc) {
|
||||
return doc.previewBranch || (await getRepoInfo(doc.repoFullName).catch(() => null))?.defaultBranch || 'main'
|
||||
}
|
||||
|
||||
// Neubau anstossen (wie beim Bearbeiten/Reaktivieren eines Projekts)
|
||||
export async function triggerRebuild(doc) {
|
||||
const repo = doc.repoFullName
|
||||
const branch = await previewBranchOf(doc)
|
||||
const found = await fetchPreviewConfig(repo, branch).catch(() => null)
|
||||
const cfg = {
|
||||
enabled: true,
|
||||
type: 'static',
|
||||
displayName: doc.projectName,
|
||||
...(found || {}),
|
||||
branch,
|
||||
subdomain: doc.subdomain,
|
||||
}
|
||||
const result = await runDeploy(repo, branch, cfg, { project: doc })
|
||||
// runDeploy meldet Runner-Fehler im Ergebnis; Schutzschalter (503) an WOMS durchreichen
|
||||
if (result?.error && /Server ausgelastet/.test(result.error)) {
|
||||
const err = new Error(BUSY)
|
||||
err.status = 503
|
||||
err.expected = true
|
||||
throw err
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { rm } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { config } from '../config.js'
|
||||
import { deleteDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||
import { deleteNewsImage } from './newsImages.js'
|
||||
import { disable } from './siteFeatures.js'
|
||||
import { getStripe } from './stripeBilling.js'
|
||||
@@ -219,6 +219,7 @@ export async function applyDeletions(plan) {
|
||||
}
|
||||
for (const id of entry.featureIds) {
|
||||
const off = await attempt('portalFeatures', id, async () => {
|
||||
// disable() meldet bei Analyse auch den Sammeldienst ab (best effort)
|
||||
await disable(id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||
return true
|
||||
})
|
||||
|
||||
@@ -45,7 +45,7 @@ function assertKey(key) {
|
||||
}
|
||||
|
||||
/** state und enabled werden immer zusammen geschrieben. */
|
||||
function stateFields(state) {
|
||||
export function stateFields(state) {
|
||||
return { state, enabled: state === 'active' }
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ export async function assertOwnProject(customerId, projectId) {
|
||||
}
|
||||
|
||||
/** Dokument holen oder mit state 'off' und dauerhaftem siteKey anlegen. */
|
||||
async function ensureFeatureDoc(customerId, projectId, key) {
|
||||
export async function ensureFeatureDoc(customerId, projectId, key) {
|
||||
const existing = await getFeatureDoc(customerId, projectId, key)
|
||||
if (existing) return existing
|
||||
try {
|
||||
@@ -293,11 +293,25 @@ export async function activate(docId, { by, adminNote } = {}) {
|
||||
|
||||
export async function disable(docId, { by, adminNote } = {}) {
|
||||
const note = adminNote ?? byNote(by, 'Abgeschaltet')
|
||||
return updateDocument(config.collections.portalFeatures, docId, {
|
||||
const doc = await updateDocument(config.collections.portalFeatures, docId, {
|
||||
...stateFields('disabled'),
|
||||
disabledAt: new Date().toISOString(),
|
||||
...(note ? { adminNote: note } : {}),
|
||||
})
|
||||
if (doc?.featureKey === 'analyse' && doc.siteKey) await stopAnalyseCollecting(doc)
|
||||
return doc
|
||||
}
|
||||
|
||||
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert.
|
||||
// Dynamischer Import: analyse.js importiert dieses Modul.
|
||||
async function stopAnalyseCollecting(doc) {
|
||||
try {
|
||||
const { analyseDeps, analyseDomains } = await import('./analyse.js')
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||
await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false })
|
||||
} catch (err) {
|
||||
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/** Freischaltung ohne Zahlung und ohne Ticket (unlockedByPurchase bleibt false). */
|
||||
@@ -310,7 +324,7 @@ export async function adminActivate(customerId, projectId, key, { by, adminNote
|
||||
|
||||
/** Alle 'setup'-Funktionen (optional nur eines Kunden), deren Ticket 'Closed' ist, werden 'active'. */
|
||||
export async function activateClosedTickets({ now = new Date(), customerId = null } = {}) {
|
||||
const queries = [Query.equal('state', 'setup'), Query.limit(1000)]
|
||||
const queries = [Query.equal('featureKey', 'news'), Query.equal('state', 'setup'), Query.limit(1000)]
|
||||
if (customerId) queries.push(Query.equal('customerId', customerId))
|
||||
const pending = await listDocuments(config.collections.portalFeatures, queries)
|
||||
const activated = []
|
||||
@@ -343,6 +357,7 @@ export async function findActiveBySiteKey(siteKey) {
|
||||
if (!/^[a-z2-7]{16}$/.test(String(siteKey || ''))) return null
|
||||
const [doc] = await listDocuments(config.collections.portalFeatures, [
|
||||
Query.equal('siteKey', siteKey),
|
||||
Query.equal('featureKey', 'news'),
|
||||
Query.equal('state', 'active'),
|
||||
Query.limit(1),
|
||||
])
|
||||
|
||||
@@ -72,7 +72,7 @@ export async function ensureProjectTicket(project, { repoFullName } = {}) {
|
||||
* Einrichtungs-Ticket fuer eine gekaufte Funktion (z. B. News-Pinnwand) einer Kundenwebsite.
|
||||
* Wird geschlossen, sobald die Funktion eingebaut ist; danach schaltet das Portal sie frei.
|
||||
*/
|
||||
export async function createFeatureTicket({ customerId, project, featureKey, siteKey }) {
|
||||
export async function createFeatureTicket({ customerId, project, featureKey, siteKey, topic = null, details = null }) {
|
||||
const customer = await getDocument(config.collections.customers, customerId).catch(() => null)
|
||||
const woid = await generateNextWoid()
|
||||
const projectName = project?.projectName || project?.subdomain || ''
|
||||
@@ -82,7 +82,7 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit
|
||||
const ticket = await createDocument(
|
||||
config.collections.workorders,
|
||||
{
|
||||
topic: `News-Pinnwand einbauen – ${projectName}`.trim(),
|
||||
topic: topic || `News-Pinnwand einbauen – ${projectName}`.trim(),
|
||||
type: 'Webpage',
|
||||
status: 'Open',
|
||||
priority: 1,
|
||||
@@ -92,7 +92,7 @@ export async function createFeatureTicket({ customerId, project, featureKey, sit
|
||||
customerName: customer?.name || '',
|
||||
customerLocation: customer?.location || '',
|
||||
woid,
|
||||
details: [
|
||||
details: details || [
|
||||
`Funktion: ${featureKey}`,
|
||||
`Projekt: ${projectName}`,
|
||||
`Domain: ${domain}`,
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
// Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt']
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { wartungDoc, hosting2Doc } = await import('./support/fixtures.js')
|
||||
const an = await import('../server/services/analyse.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const mails = mailSink()
|
||||
setMailTransportForTests(mails)
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const rebuilds = []
|
||||
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) }
|
||||
an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id)
|
||||
|
||||
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
|
||||
const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse'))
|
||||
const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' })
|
||||
aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' })
|
||||
aw.seed('subscriptions', wartungDoc)
|
||||
aw.seed('subscriptions', hosting2Doc)
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false })
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true })
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
rebuilds.length = 0
|
||||
mails.sent.length = 0
|
||||
})
|
||||
|
||||
test('Berechtigung: Wartungs-Abo, öffentlich, Subdomain, Eigentum', async () => {
|
||||
assert.equal((await an.analyseEligibility('c1', 'p1')).ok, true)
|
||||
assert.deepEqual(await an.analyseEligibility('c1', 'p2'), { ok: false, reason: 'nicht_oeffentlich' })
|
||||
assert.deepEqual(await an.analyseEligibility('c1', 'p3'), { ok: false, reason: 'keine_subdomain' })
|
||||
assert.deepEqual(await an.analyseEligibility('c2', 'p4'), { ok: false, reason: 'kein_abo' }, 'Hosting-Abo reicht nicht')
|
||||
assert.deepEqual(await an.analyseEligibility('c3', 'p5'), { ok: false, reason: 'kein_abo' })
|
||||
assert.deepEqual(await an.analyseEligibility('c1', 'p4'), { ok: false, reason: 'nicht_dein_projekt' })
|
||||
assert.deepEqual(await an.analyseEligibility('c1', 'gibts-nicht'), { ok: false, reason: 'nicht_dein_projekt' })
|
||||
})
|
||||
|
||||
test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => {
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', consent: consent() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', consent: consent() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', consent: consent() }), { status: 409 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', consent: consent() }), { status: 400 })
|
||||
assert.equal(puts.length, 0)
|
||||
assert.equal(analyseTickets().length, 0)
|
||||
})
|
||||
|
||||
test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => {
|
||||
const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() })
|
||||
assert.equal(changed, true)
|
||||
assert.equal(d.featureKey, 'analyse')
|
||||
assert.equal(d.state, 'active')
|
||||
assert.equal(d.enabled, true)
|
||||
assert.match(d.siteKey, /^[a-z0-9]{16,40}$/)
|
||||
assert.equal(d.accentColor, '#1a73e8')
|
||||
assert.equal(d.avvVersion, config.analyse.avvVersion)
|
||||
assert.equal(d.avvAcceptedBy, 'customer')
|
||||
assert.ok(d.avvAcceptedAt && d.activatedAt)
|
||||
assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de', 'www.baeckerei.de'], active: true }])
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
const tickets = analyseTickets()
|
||||
assert.equal(tickets.length, 1)
|
||||
assert.equal(tickets[0].topic, 'Datenschutzerklärung von Bäckerei um Hinweis auf Analyse ergänzen')
|
||||
assert.match(tickets[0].details, /Funktion: analyse/)
|
||||
assert.match(tickets[0].details, /Domain\(s\): baeckerei\.project\.webklar\.com, baeckerei\.de/)
|
||||
assert.match(tickets[0].details, /https:\/\/baeckerei\.de\/datenschutz-analyse\.html/)
|
||||
assert.match(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/)
|
||||
assert.equal((await doc('c1', 'p1')).privacyTicketId, tickets[0].$id)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(mails.sent[0].subject, 'Deine Website-Analyse ist aktiv – Bäckerei')
|
||||
assert.deepEqual(mails.sent[0].to.map((t) => t.address), ['eins@example.invalid'])
|
||||
})
|
||||
|
||||
test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => {
|
||||
const before = await doc('c1', 'p1')
|
||||
const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() })
|
||||
assert.equal(same.changed, false)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', consent: consent() })
|
||||
assert.equal(recolored.doc.accentColor, '#cc0000')
|
||||
assert.equal(recolored.doc.siteKey, before.siteKey)
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
assert.equal(analyseTickets().length, 1)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(puts.length, 0)
|
||||
})
|
||||
|
||||
test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => {
|
||||
const now = new Date('2026-10-05T10:00:00.000Z')
|
||||
const { doc: d, changed } = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })
|
||||
assert.equal(changed, true)
|
||||
assert.equal(d.state, 'off')
|
||||
assert.equal(d.enabled, false)
|
||||
assert.equal(d.offSince, '2026-10-05T10:00:00.000Z')
|
||||
assert.equal(puts.length, 1)
|
||||
assert.equal(puts[0].active, false)
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
assert.equal((await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })).changed, false)
|
||||
await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 })
|
||||
})
|
||||
|
||||
test('Wieder einschalten: gleicher siteKey, offSince leer, kein neues Ticket', async () => {
|
||||
const before = await doc('c1', 'p1')
|
||||
const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso' })
|
||||
assert.equal(d.siteKey, before.siteKey)
|
||||
assert.equal(d.state, 'active')
|
||||
assert.equal(d.offSince, '')
|
||||
assert.equal(d.accentColor, '#111111')
|
||||
assert.equal(d.avvAcceptedBy, 'Kenso')
|
||||
assert.equal(analyseTickets().length, 1)
|
||||
})
|
||||
|
||||
test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => {
|
||||
// c2 hat nur Hosting-Abo, c3 gar keins: beide Analysen von Hand "aktiv" (z. B. Abo wurde gewechselt)
|
||||
for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) {
|
||||
const d = await sf.ensureFeatureDoc(c, p, 'analyse')
|
||||
await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' })
|
||||
}
|
||||
const now = new Date('2026-11-01T03:15:00.000Z')
|
||||
const result = await an.runAboCheck({ now })
|
||||
assert.equal(result.checked, 3)
|
||||
assert.equal(result.deactivated.length, 2)
|
||||
assert.equal((await doc('c1', 'p1')).state, 'active', 'Wartungs-Abo bleibt aktiv')
|
||||
for (const [c, p] of [['c2', 'p4'], ['c3', 'p5']]) {
|
||||
const d = await doc(c, p)
|
||||
assert.equal(d.state, 'off')
|
||||
assert.equal(d.offSince, now.toISOString())
|
||||
}
|
||||
assert.deepEqual(mails.sent.map((m) => m.subject).sort(), ['Deine Website-Analyse wurde beendet – Hosting-Kunde', 'Deine Website-Analyse wurde beendet – Ohne Abo'])
|
||||
mails.sent.length = 0
|
||||
assert.equal((await an.runAboCheck({ now })).deactivated.length, 0, 'zweiter Lauf: nichts mehr zu tun')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('News-Jobs ignorieren Analyse-Dokumente', async () => {
|
||||
const d = await sf.ensureFeatureDoc('c1', 'p2', 'analyse')
|
||||
aw.seed('workorders', { $id: 't_an', status: 'Closed', woid: '1', topic: 'x' })
|
||||
await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('setup'), ticketId: 't_an' })
|
||||
assert.deepEqual(await sf.activateClosedTickets({ customerId: 'c1' }), [])
|
||||
assert.deepEqual(await sf.runActivation({ customerId: 'c1' }), [])
|
||||
assert.equal((await doc('c1', 'p2')).state, 'setup')
|
||||
const active = await doc('c1', 'p1')
|
||||
assert.equal(await sf.findActiveBySiteKey(active.siteKey.slice(0, 16)), null, 'Analyse-siteKey ist kein News-Schlüssel')
|
||||
})
|
||||
|
||||
test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau und Mail genau einmal nach', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Heilung', subdomain: 'heilung', isPublic: true, repoFullName: 'WEBklar/heilung' })
|
||||
const original = an.analyseDeps.ticket
|
||||
let failOnce = true
|
||||
an.analyseDeps.ticket = async (...args) => {
|
||||
if (failOnce) { failOnce = false; throw new Error('Ticketsystem down') }
|
||||
return original(...args)
|
||||
}
|
||||
const before = analyseTickets().length
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }), /Ticketsystem down/)
|
||||
assert.equal((await doc('c1', 'p6')).state, 'active')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt)
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
// dritter Aufruf: nichts mehr offen
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() })
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
an.analyseDeps.ticket = original
|
||||
})
|
||||
|
||||
test('putSite scheitert: Dokument bleibt unverändert', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true })
|
||||
const ok = an.analyseDeps.client
|
||||
an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } }
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', consent: consent() }), { status: 502 })
|
||||
assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true)
|
||||
assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0)
|
||||
an.analyseDeps.client = ok
|
||||
})
|
||||
|
||||
test('Neubau scheitert: Analyse bleibt aktiv, kein Fehler', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' })
|
||||
const ok = an.analyseDeps.rebuild
|
||||
an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') }
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', consent: consent() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
an.analyseDeps.rebuild = ok
|
||||
})
|
||||
|
||||
test('parallel einschalten: ein Ticket, eine Mail', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p9', customerId: 'c1', projectName: 'Parallel', subdomain: 'parallel', isPublic: true })
|
||||
const before = analyseTickets().length
|
||||
mails.sent.length = 0
|
||||
await Promise.all([
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }),
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }),
|
||||
])
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('gesperrte Analyse: Kunde 403, Mitarbeiter darf', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true })
|
||||
const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse')
|
||||
await sf.disable(g.$id, {})
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' })
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso' })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
})
|
||||
|
||||
test('analyseDomains: www-/Apex-Gegenstück der Live-Domain, ohne Doppelte, max. 5, nur gültige Hosts', () => {
|
||||
const d = (live, sub = 'x') => an.analyseDomains({ subdomain: sub, liveDomain: live })
|
||||
assert.deepEqual(d('example.de'), ['x.project.webklar.com', 'example.de', 'www.example.de'])
|
||||
assert.deepEqual(d('https://www.Example.de/start'), ['x.project.webklar.com', 'www.example.de', 'example.de'])
|
||||
assert.deepEqual(d(''), ['x.project.webklar.com'])
|
||||
assert.deepEqual(d('kaputt!'), ['x.project.webklar.com'])
|
||||
assert.ok(an.analyseDomains({ subdomain: 'x', liveDomain: 'a.b.c.example.de' }).length <= 5)
|
||||
// gespeicherter Host bleibt die Live-Domain (Heatmap-Link, Ticket)
|
||||
assert.deepEqual(an.analyseHosts({ subdomain: 'x', liveDomain: 'example.de' }), ['x.project.webklar.com', 'example.de'])
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import { test, after, beforeEach } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { startAppwriteMock } from './support/appwriteMock.js'
|
||||
import { startFakeStripe } from './support/fakeStripe.js'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt']
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions') },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
|
||||
const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js')
|
||||
const an = await import('../server/services/analyse.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { verifyHeatToken } = await import('../analytics/src/heat.js')
|
||||
const { createAnalyticsClient } = await import('../server/services/analyticsClient.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/admin/analyse': adminAnalyseRoutes })
|
||||
setMailTransportForTests(mailSink())
|
||||
after(() => {
|
||||
setMailTransportForTests(null)
|
||||
return Promise.all([app.close(), aw.close(), st.close()])
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const statsCalls = []
|
||||
let statsFail = false
|
||||
const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' })
|
||||
an.analyseDeps.client = {
|
||||
putSite: async (siteKey, body) => puts.push({ siteKey, ...body }),
|
||||
getStats: async (siteKey, range) => {
|
||||
statsCalls.push({ siteKey, range })
|
||||
if (statsFail) throw Object.assign(new Error('x'), { status: 502 })
|
||||
return { pv: 5 }
|
||||
},
|
||||
heatToken: (a) => realClient.heatToken(a),
|
||||
}
|
||||
an.analyseDeps.rebuild = async () => {}
|
||||
config.analyse.enabled = true
|
||||
config.analyse.heatSecret = 'h'.repeat(32)
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('subscriptions', wartungDoc)
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'baeckerei.de', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false })
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Café', subdomain: 'cafe', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'pd1', customerId: 'c1', projectName: 'Dev', subdomain: 'devsite', isPublic: true, previewDetected: 'dev:npm' })
|
||||
aw.seed('websiteProjects', { $id: 'pd2', customerId: 'c1', projectName: 'Build', subdomain: 'buildsite', isPublic: true, previewDetected: 'build:npm' })
|
||||
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c2', projectName: 'Fremd', subdomain: 'fremd', isPublic: true })
|
||||
|
||||
const cookie = customerCookie('c1')
|
||||
const A = '/api/funktionen/analyse'
|
||||
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
|
||||
const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } })
|
||||
const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
statsCalls.length = 0
|
||||
statsFail = false
|
||||
config.analyse.enabled = true
|
||||
})
|
||||
|
||||
test('Auth: ohne Anmeldung 401', async () => {
|
||||
assert.equal((await post(`${A}/activate`, { projectId: 'p1' })).status, 401)
|
||||
assert.equal((await rawGet(`${A}/stats?projectId=p1`)).status, 401)
|
||||
assert.equal((await post(`${A}/heatmap-link`, {})).status, 401)
|
||||
})
|
||||
|
||||
test('Eigentum: fremdes Projekt 403', async () => {
|
||||
assert.equal((await post(`${A}/activate`, { projectId: 'p5', consent: consent() }, { cookie })).status, 403)
|
||||
assert.equal((await post(`${A}/deactivate`, { projectId: 'p5' }, { cookie })).status, 403)
|
||||
assert.equal((await rawGet(`${A}/stats?projectId=p5`, { cookie })).status, 403)
|
||||
assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p5', path: '/', vp: 'd' }, { cookie })).status, 403)
|
||||
})
|
||||
|
||||
test('Schalter aus: alle Analyse-Routen 409', async () => {
|
||||
config.analyse.enabled = false
|
||||
for (const r of [
|
||||
post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie }),
|
||||
post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }),
|
||||
rawGet(`${A}/stats?projectId=p1`, { cookie }),
|
||||
post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }),
|
||||
adminPost('p1', { on: true, avvConfirmedVerbally: true }),
|
||||
]) {
|
||||
const res = await r
|
||||
assert.equal(res.status, 409)
|
||||
assert.equal(res.body.error, 'Website-Analyse ist noch nicht freigeschaltet')
|
||||
}
|
||||
assert.equal(await featureDoc('c1', 'p1'), null)
|
||||
})
|
||||
|
||||
test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', async () => {
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
const p = (id) => body.projects.find((x) => x.projectId === id)
|
||||
assert.equal(p('p1').features.news.state, 'off')
|
||||
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion })
|
||||
assert.equal(p('p2').analyse.eligible, false)
|
||||
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
|
||||
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
|
||||
config.analyse.enabled = false
|
||||
const off = await get('/api/funktionen', { cookie })
|
||||
for (const x of off.projects) assert.deepEqual(x.analyse, { comingSoon: true })
|
||||
assert.equal(off.projects[0].features.news.state, 'off')
|
||||
})
|
||||
|
||||
test('Aktivieren und Deaktivieren (Kunde)', async () => {
|
||||
const bad = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }, { cookie })
|
||||
assert.equal(bad.status, 400)
|
||||
const ok = await post(`${A}/activate`, { projectId: 'p1', accentColor: '#112233', consent: consent() }, { cookie })
|
||||
assert.equal(ok.status, 200)
|
||||
assert.deepEqual(ok.body, { ok: true, state: 'active' })
|
||||
assert.equal(puts[0].active, true)
|
||||
assert.equal((await featureDoc('c1', 'p1')).accentColor, '#112233')
|
||||
const view = (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === 'p1').analyse
|
||||
assert.equal(view.state, 'active')
|
||||
assert.equal(view.accentColor, '#112233')
|
||||
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
|
||||
assert.deepEqual(off.body, { ok: true, state: 'off' })
|
||||
assert.equal(puts[1].active, false)
|
||||
const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie })
|
||||
assert.equal(notElig.status, 409)
|
||||
})
|
||||
|
||||
test('Stats: Weiterleitung, Zeitraum, 503 bei Ausfall', async () => {
|
||||
const p3 = await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
|
||||
assert.equal(p3.status, 200)
|
||||
const key = (await featureDoc('c1', 'p3')).siteKey
|
||||
const ok = await rawGet(`${A}/stats?projectId=p3&range=7`, { cookie })
|
||||
assert.equal(ok.status, 200)
|
||||
assert.deepEqual(ok.body, { pv: 5 })
|
||||
assert.deepEqual(statsCalls[0], { siteKey: key, range: 7 })
|
||||
assert.equal((await rawGet(`${A}/stats?projectId=p3&range=5`, { cookie })).status, 400)
|
||||
statsFail = true
|
||||
const down = await rawGet(`${A}/stats?projectId=p3`, { cookie })
|
||||
assert.equal(down.status, 503)
|
||||
assert.equal(down.body.error, 'Auswertung gerade nicht erreichbar')
|
||||
statsFail = false
|
||||
// aus, Daten noch da: weiter erlaubt
|
||||
await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie })
|
||||
assert.equal((await rawGet(`${A}/stats?projectId=p3`, { cookie })).status, 200)
|
||||
// nie aktiviert: keine Auswertung
|
||||
aw.seed('websiteProjects', { $id: 'p6', customerId: 'c1', projectName: 'Neu', subdomain: 'neu', isPublic: true })
|
||||
assert.equal((await rawGet(`${A}/stats?projectId=p6`, { cookie })).status, 409)
|
||||
})
|
||||
|
||||
test('GET /api/funktionen: devMode nur bei aktiver Analyse eines Dev-Projekts', async () => {
|
||||
const view = async (id) => (await get('/api/funktionen', { cookie })).projects.find((x) => x.projectId === id).analyse
|
||||
assert.equal((await view('pd1')).devMode, false, 'nicht aktiv: kein Hinweis')
|
||||
await post(`${A}/activate`, { projectId: 'pd1', consent: consent() }, { cookie })
|
||||
await post(`${A}/activate`, { projectId: 'pd2', consent: consent() }, { cookie })
|
||||
assert.equal((await view('pd1')).devMode, true)
|
||||
assert.equal((await view('pd2')).devMode, false, 'node_build wird gemessen')
|
||||
await post(`${A}/deactivate`, { projectId: 'pd1' }, { cookie })
|
||||
await post(`${A}/deactivate`, { projectId: 'pd2' }, { cookie })
|
||||
})
|
||||
|
||||
test('Heatmap-Link: Prüfung, URL, Token', async () => {
|
||||
await post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie })
|
||||
const key = (await featureDoc('c1', 'p1')).siteKey
|
||||
const link = (b) => post(`${A}/heatmap-link`, { projectId: 'p1', ...b }, { cookie })
|
||||
for (const path of ['start', '/a?x=1', '/a#b', '/' + 'a'.repeat(300), 5]) assert.equal((await link({ path, vp: 'd' })).status, 400, String(path))
|
||||
assert.equal((await link({ path: '/', vp: 'x' })).status, 400)
|
||||
const ok = await link({ path: '/preise', vp: 'm' })
|
||||
assert.equal(ok.status, 200)
|
||||
const url = new URL(ok.body.url)
|
||||
assert.equal(url.origin, 'https://baeckerei.de')
|
||||
assert.equal(url.pathname, '/preise')
|
||||
const v = verifyHeatToken(url.searchParams.get('__wk_heat'), config.analyse.heatSecret, Date.now())
|
||||
assert.equal(v.siteKey, key)
|
||||
assert.equal(v.path, '/preise')
|
||||
assert.equal(v.vp, 'm')
|
||||
// ohne Live-Domain: Subdomain
|
||||
await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
|
||||
const sub = await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })
|
||||
assert.equal(new URL(sub.body.url).origin, 'https://cafe.project.webklar.com')
|
||||
// nicht aktiv: 409
|
||||
await post(`${A}/deactivate`, { projectId: 'p3' }, { cookie })
|
||||
assert.equal((await post(`${A}/heatmap-link`, { projectId: 'p3', path: '/', vp: 't' }, { cookie })).status, 409)
|
||||
})
|
||||
|
||||
test('Admin: 401, 400, 200', async () => {
|
||||
assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401)
|
||||
assert.equal((await adminPost('p6', { on: true, avvConfirmedVerbally: true }, 'jwt-kunde')).status, 403)
|
||||
const noAvv = await adminPost('p6', { on: true })
|
||||
assert.equal(noAvv.status, 400)
|
||||
assert.equal(noAvv.body.error, 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat')
|
||||
assert.equal((await adminPost('p6', { on: 'ja' })).status, 400)
|
||||
assert.equal((await adminPost('nix', { on: true, avvConfirmedVerbally: true })).status, 404)
|
||||
const on = await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
assert.equal(on.status, 200)
|
||||
assert.deepEqual(on.body, { ok: true, state: 'active' })
|
||||
const d = await featureDoc('c1', 'p6')
|
||||
assert.equal(d.avvAcceptedBy, 'kenso@webklar.com')
|
||||
assert.equal(d.avvVersion, config.analyse.avvVersion)
|
||||
const off = await adminPost('p6', { on: false })
|
||||
assert.deepEqual(off.body, { ok: true, state: 'off' })
|
||||
})
|
||||
|
||||
test('Admin: Lesezugriff GET', async () => {
|
||||
const adminGet = (id, jwt = 'jwt-admin') => rawGet(`/api/admin/analyse/${id}`, { headers: { Authorization: `Bearer ${jwt}` } })
|
||||
assert.equal((await rawGet('/api/admin/analyse/p1')).status, 401)
|
||||
assert.equal((await adminGet('p1', 'jwt-kunde')).status, 403)
|
||||
assert.equal((await adminGet('nix')).status, 404)
|
||||
await adminPost('p1', { on: false })
|
||||
const off = await adminGet('p1')
|
||||
assert.equal(off.status, 200)
|
||||
assert.equal(off.body.enabled, true)
|
||||
assert.equal(off.body.eligible, true)
|
||||
assert.equal(off.body.reasonText, null)
|
||||
assert.equal(off.body.avvVersion, config.analyse.avvVersion)
|
||||
await adminPost('p1', { on: true, avvConfirmedVerbally: true })
|
||||
const on = await adminGet('p1')
|
||||
assert.equal(on.body.state, 'active')
|
||||
assert.equal(on.body.avvAcceptedBy, 'kenso@webklar.com')
|
||||
assert.ok(on.body.activatedAt)
|
||||
assert.ok(!('siteKey' in on.body) && !JSON.stringify(on.body).includes((await featureDoc('c1', 'p1')).siteKey))
|
||||
const priv = await adminGet('p2')
|
||||
assert.equal(priv.body.eligible, false)
|
||||
assert.equal(priv.body.state, null)
|
||||
assert.equal(priv.body.reasonText, 'Die Website-Analyse gibt es nur für öffentliche Websites.')
|
||||
})
|
||||
|
||||
test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => {
|
||||
await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
const cur = await featureDoc('c1', 'p6')
|
||||
await sf.disable(cur.$id, { by: 'Test' })
|
||||
puts.length = 0
|
||||
const r = await post(`${A}/deactivate`, { projectId: 'p6' }, { cookie })
|
||||
assert.deepEqual(r.body, { ok: true, state: 'disabled' })
|
||||
assert.equal((await featureDoc('c1', 'p6')).state, 'disabled')
|
||||
const staff = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p6', by: 'Andrej' })
|
||||
assert.equal(staff.doc.state, 'off')
|
||||
})
|
||||
|
||||
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => {
|
||||
await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
const cur = await featureDoc('c1', 'p6')
|
||||
puts.length = 0
|
||||
await sf.disable(cur.$id, { by: 'Test' })
|
||||
assert.equal(puts.length, 1)
|
||||
assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false })
|
||||
// Ausfall des Sammeldienstes verhindert das Abschalten nicht
|
||||
const real = an.analyseDeps.client.putSite
|
||||
an.analyseDeps.client.putSite = async () => { throw new Error('weg') }
|
||||
const again = await sf.disable(cur.$id, { by: 'Test' })
|
||||
an.analyseDeps.client.putSite = real
|
||||
assert.equal(again.state, 'disabled')
|
||||
})
|
||||
|
||||
test('Härtung: ungültige Hosts, Pfadzeichen, Fehler bei einem Projekt', async () => {
|
||||
// analyseDomains: ungueltige Hosts werden uebersprungen
|
||||
assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'https://Cafe.DE/x' }), ['cafe.project.webklar.com', 'cafe.de', 'www.cafe.de'])
|
||||
assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'evil.de@x y' }), ['cafe.project.webklar.com'])
|
||||
assert.deepEqual(an.analyseDomains({ subdomain: 'ca fe', liveDomain: 'a_b.de' }), [])
|
||||
assert.deepEqual(an.analyseDomains({ subdomain: 'cafe', liveDomain: 'localhost' }), ['cafe.project.webklar.com'])
|
||||
// Heatmap-Pfad: Steuerzeichen, Backslash, Leerraum => 400; dekodierte Form bleibt erlaubt
|
||||
await post(`${A}/activate`, { projectId: 'p1', consent: consent() }, { cookie })
|
||||
const link = (path) => post(`${A}/heatmap-link`, { projectId: 'p1', path, vp: 'd' }, { cookie })
|
||||
for (const path of ['/a\\b', '/a b', '/a\nb', '/a\u0000b', '/a\tb', '/a\u007fb']) assert.equal((await link(path)).status, 400, JSON.stringify(path))
|
||||
assert.equal((await link('/über-uns')).status, 200)
|
||||
// keine gueltige Domain => Aktivieren scheitert mit 409, vor dem Sammeldienst
|
||||
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Kaputt', subdomain: 'ka putt', liveDomain: 'x_y', isPublic: true })
|
||||
const before = puts.length
|
||||
const bad = await post(`${A}/activate`, { projectId: 'p7', consent: consent() }, { cookie })
|
||||
assert.equal(bad.status, 409)
|
||||
assert.equal(bad.body.error, 'Keine gültige Domain für die Analyse')
|
||||
assert.equal(puts.length, before)
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ANALYSE_ATTRIBUTES, main } from '../scripts/analyse-setup.mjs'
|
||||
import { config } from '../server/config.js'
|
||||
|
||||
function mockClient(existing = []) {
|
||||
const created = []
|
||||
return {
|
||||
created,
|
||||
async listAttributes() {
|
||||
return existing.map((key) => ({ key }))
|
||||
},
|
||||
async createAttribute(attr) {
|
||||
created.push(attr.key)
|
||||
},
|
||||
async waitForAttribute() {},
|
||||
}
|
||||
}
|
||||
|
||||
test('Schema: 8 Attribute mit Groessen, alle optional', () => {
|
||||
const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.map((a) => [a.key, a.size]))
|
||||
assert.deepEqual(sizes, {
|
||||
accentColor: 7,
|
||||
avvVersion: 20,
|
||||
avvAcceptedAt: 40,
|
||||
avvAcceptedBy: 100,
|
||||
activatedAt: 40,
|
||||
offSince: 40,
|
||||
privacyTicketId: 40,
|
||||
activatedMailAt: 40,
|
||||
})
|
||||
assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false && a.type === 'string'))
|
||||
})
|
||||
|
||||
test('main legt nur fehlende Attribute an', async () => {
|
||||
const client = mockClient(['accentColor'])
|
||||
const result = await main({ client, log: () => {} })
|
||||
assert.equal(client.created.length, 7)
|
||||
assert.ok(!client.created.includes('accentColor'))
|
||||
assert.equal(result.missing.length, 7)
|
||||
})
|
||||
|
||||
test('main --check legt nichts an', async () => {
|
||||
const client = mockClient(['accentColor'])
|
||||
const result = await main({ check: true, client, log: () => {} })
|
||||
assert.equal(client.created.length, 0)
|
||||
assert.equal(result.missing.length, 7)
|
||||
})
|
||||
|
||||
test('Konfiguration: analyse ist standardmaessig aus', () => {
|
||||
assert.equal(config.analyse.enabled, false)
|
||||
assert.equal(config.analyse.url, 'http://webklar-analytics:8091')
|
||||
assert.equal(config.analyse.adminToken, '')
|
||||
assert.equal(config.analyse.heatSecret, '')
|
||||
assert.equal(config.analyse.avvVersion, '2026-10-01')
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { contrastOk } from '../analytics/client/core.js'
|
||||
import { bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js'
|
||||
|
||||
test('contrast und bannerColor stimmen mit core.js ueberein', () => {
|
||||
for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) {
|
||||
const { contrastOk: portal } = { contrastOk: bannerColor(c) !== '#111111' || c.toLowerCase() === '#111111' }
|
||||
assert.equal(portal, contrastOk(c) || c.toLowerCase() === '#111111', c)
|
||||
}
|
||||
assert.equal(bannerColor('#ffcc00'), '#111111')
|
||||
assert.equal(bannerColor('#1D4ED8'), '#1d4ed8')
|
||||
assert.equal(bannerColor('nonsense'), '#111111')
|
||||
})
|
||||
|
||||
test('Land und Flagge', () => {
|
||||
assert.equal(countryName('DE'), 'Deutschland')
|
||||
assert.equal(countryName(''), 'Unbekannt')
|
||||
assert.equal(flagEmoji('de'), '🇩🇪')
|
||||
assert.equal(flagEmoji(''), '')
|
||||
assert.equal(flagEmoji('<b>'), '')
|
||||
assert.equal(refLabel(''), 'Direkt')
|
||||
})
|
||||
|
||||
test('Diagramm-Skalierung', () => {
|
||||
assert.equal(niceMax(0), 1)
|
||||
assert.equal(niceMax(7), 10)
|
||||
assert.equal(niceMax(130), 200)
|
||||
assert.equal(shortDay('2026-10-01'), '01.10.')
|
||||
const series = Array.from({ length: 30 }, (_, i) => ({ day: `2026-09-${String(i + 1).padStart(2, '0')}`, pv: i }))
|
||||
const m = chartModel(series)
|
||||
assert.equal(m.points.length, 30)
|
||||
assert.equal(m.top, 50)
|
||||
assert.ok(m.points[0].x < m.points[29].x)
|
||||
assert.ok(m.points.every((p) => p.x >= m.pad.l && p.x <= m.width - m.pad.r && p.y >= m.pad.t && p.y <= m.height - m.pad.b))
|
||||
assert.ok(m.xLabels.length <= 7 && m.xLabels.length >= 2)
|
||||
assert.equal(chartModel([]).points.length, 0)
|
||||
assert.equal(chartModel([{ day: '2026-10-01', pv: 3 }]).points.length, 1)
|
||||
})
|
||||
|
||||
test('Balkenzeilen', () => {
|
||||
assert.deepEqual(barRows([{ key: 'a', count: 4 }, { key: 'b', count: 2 }], (k) => k.toUpperCase()), [
|
||||
{ key: 'a', label: 'A', count: 4, pct: 100 },
|
||||
{ key: 'b', label: 'B', count: 2, pct: 50 },
|
||||
])
|
||||
assert.deepEqual(barRows(undefined, String), [])
|
||||
})
|
||||
|
||||
test('Portal: Analyse-Ansicht ist verdrahtet', () => {
|
||||
const app = readFileSync(new URL('../public/app.js', import.meta.url), 'utf8')
|
||||
const html = readFileSync(new URL('../public/dashboard.html', import.meta.url), 'utf8')
|
||||
assert.match(app, /analyse: 'analyse'/)
|
||||
assert.match(app, /APP_PARENT = \{ news: 'funktionen', analyse: 'funktionen'/)
|
||||
assert.match(html, /id="view-analyse"/)
|
||||
assert.match(html, /id="analyse-body"/)
|
||||
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { test, before, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import http from 'node:http'
|
||||
import { createAnalyticsClient } from '../server/services/analyticsClient.js'
|
||||
import { verifyHeatToken } from '../analytics/src/heat.js'
|
||||
|
||||
let server
|
||||
let url
|
||||
let calls
|
||||
let respond
|
||||
|
||||
before(async () => {
|
||||
server = http.createServer((req, res) => {
|
||||
let body = ''
|
||||
req.on('data', (c) => { body += c })
|
||||
req.on('end', () => {
|
||||
calls.push({ method: req.method, url: req.url, auth: req.headers.authorization, body })
|
||||
const r = respond(req)
|
||||
res.writeHead(r.status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(r.body ?? {}))
|
||||
})
|
||||
})
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
||||
url = `http://127.0.0.1:${server.address().port}`
|
||||
})
|
||||
after(() => server.close())
|
||||
|
||||
const mk = (over = {}) => createAnalyticsClient({ url, adminToken: 'tok-secret', heatSecret: 'heat-secret', ...over })
|
||||
const reset = (r = () => ({ status: 200, body: {} })) => { calls = []; respond = r }
|
||||
|
||||
test('putSite: PUT mit Bearer und JSON', async () => {
|
||||
reset()
|
||||
await mk().putSite('abcdefghij123456', { projectId: 'p1', domains: ['a.project.webklar.com'], active: true })
|
||||
assert.equal(calls[0].method, 'PUT')
|
||||
assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456')
|
||||
assert.equal(calls[0].auth, 'Bearer tok-secret')
|
||||
assert.deepEqual(JSON.parse(calls[0].body), { projectId: 'p1', domains: ['a.project.webklar.com'], active: true })
|
||||
})
|
||||
|
||||
test('deleteSite und getStats', async () => {
|
||||
reset(() => ({ status: 200, body: { pv: 3 } }))
|
||||
const c = mk()
|
||||
await c.deleteSite('abcdefghij123456')
|
||||
assert.equal(calls[0].method, 'DELETE')
|
||||
const s = await c.getStats('abcdefghij123456', '7d')
|
||||
assert.deepEqual(s, { pv: 3 })
|
||||
assert.equal(calls[1].url, '/a/admin/sites/abcdefghij123456/stats?range=7d')
|
||||
})
|
||||
|
||||
test('Fehler tragen status, ohne Token in der Meldung', async () => {
|
||||
reset(() => ({ status: 500, body: { error: 'kaputt' } }))
|
||||
await assert.rejects(mk().getStats('abcdefghij123456', '7d'), (e) => {
|
||||
assert.equal(e.status, 500)
|
||||
assert.ok(!e.message.includes('tok-secret'))
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
test('fehlender adminToken => 503', async () => {
|
||||
reset()
|
||||
await assert.rejects(mk({ adminToken: '' }).putSite('abcdefghij123456', {}), (e) => {
|
||||
assert.equal(e.status, 503)
|
||||
assert.equal(e.message, 'Auswertung nicht eingerichtet')
|
||||
return true
|
||||
})
|
||||
assert.equal(calls.length, 0)
|
||||
})
|
||||
|
||||
test('Timeout wird als Fehler gemeldet', async () => {
|
||||
reset(() => ({ status: 200 }))
|
||||
const slow = http.createServer(() => {})
|
||||
await new Promise((r) => slow.listen(0, '127.0.0.1', r))
|
||||
const c = mk({ url: `http://127.0.0.1:${slow.address().port}`, timeoutMs: 50 })
|
||||
await assert.rejects(c.getStats('abcdefghij123456', '7d'), (e) => {
|
||||
assert.ok(e.status >= 500)
|
||||
assert.ok(!e.message.includes('tok-secret'))
|
||||
return true
|
||||
})
|
||||
slow.closeAllConnections?.()
|
||||
slow.close()
|
||||
})
|
||||
|
||||
test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', () => {
|
||||
const now = Date.UTC(2026, 9, 1, 12, 0, 0)
|
||||
const token = mk().heatToken({ siteKey: 'abcdefghij123456', path: '/preise', vp: 'm', now })
|
||||
const v = verifyHeatToken(token, 'heat-secret', now)
|
||||
assert.deepEqual(v, { siteKey: 'abcdefghij123456', path: '/preise', vp: 'm', exp: now / 1000 + 1800 })
|
||||
assert.equal(verifyHeatToken(token, 'heat-secret', now + 1801 * 1000), null)
|
||||
assert.equal(verifyHeatToken(token, 'anderes', now), null)
|
||||
})
|
||||
|
||||
test('heatToken ohne heatSecret => 503', () => {
|
||||
assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503)
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
||||
|
||||
test('AVV Website-Analyse: öffentliche Seite existiert, trägt Fassung und Entwurfshinweis, keine internen Kommentare', () => {
|
||||
const file = root('public/rechtliches/avv-website-analyse.html')
|
||||
assert.ok(existsSync(file))
|
||||
const html = readFileSync(file, 'utf8')
|
||||
assert.ok(html.includes(`Fassung ${config.analyse.avvVersion}`), 'avvVersion aus der Konfiguration steht auf der Seite')
|
||||
assert.match(html, /Entwurf – vor Verwendung anwaltlich prüfen lassen/)
|
||||
assert.doesNotMatch(html, /<!--/)
|
||||
assert.match(html, /<h2>12\. Haftung/)
|
||||
})
|
||||
|
||||
test('AVV-Link im Portal zeigt auf eine vorhandene Datei unter public/', () => {
|
||||
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
|
||||
const url = fn.match(/const AVV_URL = '([^']+)'/)?.[1]
|
||||
assert.equal(url, '/rechtliches/avv-website-analyse.html')
|
||||
assert.ok(existsSync(root(`public${url}`)))
|
||||
})
|
||||
|
||||
test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => {
|
||||
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
|
||||
assert.match(fn, /Im Dev-Modus wird nicht gemessen/)
|
||||
assert.match(fn, /a\.devMode/)
|
||||
})
|
||||
|
||||
test('Texte: Akzentfarbe wird beim Einschalten gewählt (kein späteres Portal-Setting behauptet)', () => {
|
||||
for (const f of ['docs/rechtliches/avv-website-analyse.md', 'docs/rechtliches/leistungsbeschreibung-website-analyse.md']) {
|
||||
assert.match(readFileSync(root(f), 'utf8'), /beim Einschalten gewählt/, f)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
// public/funktionen.js wirklich laden (der absolute Import /analyse-view.js wird auf die Datei umgeleitet)
|
||||
const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-'))
|
||||
const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.url).pathname).href
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen } = await import(pathToFileURL(file).href)
|
||||
|
||||
const news = { state: 'off' }
|
||||
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
|
||||
async function render(projects) {
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects }) })
|
||||
const root = { innerHTML: '', querySelector: () => null }
|
||||
await initFunktionen(root)
|
||||
return root.innerHTML
|
||||
}
|
||||
const tile = (html, key) => html.slice(html.indexOf(`data-fn-tile="${key}"`), html.indexOf('</article>', html.indexOf(`data-fn-tile="${key}"`)))
|
||||
|
||||
test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt', async () => {
|
||||
const soon = await render([project('a', { comingSoon: true })])
|
||||
assert.match(tile(soon, 'news'), /News-Pinnwand/)
|
||||
assert.match(soon, /Kommt bald/)
|
||||
assert.doesNotMatch(soon, /data-fn-tile="analyse"/)
|
||||
|
||||
const off = await render([project('a', { state: 'off', eligible: true, devMode: false })])
|
||||
assert.match(tile(off, 'analyse'), /Einrichten/)
|
||||
assert.doesNotMatch(off, /Dev-Modus/)
|
||||
|
||||
const dev = await render([project('a', { state: 'active', eligible: true, devMode: true })])
|
||||
assert.match(tile(dev, 'analyse'), /Auswertung ansehen/)
|
||||
assert.match(tile(dev, 'analyse'), /Im Dev-Modus wird nicht gemessen/)
|
||||
assert.doesNotMatch(tile(dev, 'news'), /Dev-Modus/)
|
||||
|
||||
const live = await render([project('a', { state: 'active', eligible: true, devMode: false })])
|
||||
assert.match(tile(live, 'analyse'), /Auswertung ansehen/)
|
||||
assert.doesNotMatch(live, /Dev-Modus/)
|
||||
|
||||
const disabled = await render([project('a', { state: 'disabled' })])
|
||||
assert.match(tile(disabled, 'analyse'), /Bitte melde dich bei uns/)
|
||||
assert.doesNotMatch(disabled, /Dev-Modus/)
|
||||
})
|
||||
@@ -6,7 +6,7 @@ import { contractAttachmentFor, termsDocFor } from '../server/services/products.
|
||||
|
||||
const DIR = new URL('../server/mail-templates/', import.meta.url)
|
||||
// K12, K14 und T12 (Wechsel ins Wartungs-Abo) aus Task 9, K13 und T13 (Zusatzspeicher) aus Task 8 (Fix 1);
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden
|
||||
// Endfassung der Texte in Task 12; T14 „Löschfristen“ aus Task 21 (F39); T15 Push-Zeitmail; K15, K16, T16 Funktionen (News-Pinnwand); T17 Repo zuordnen; K17 Chat-Hinweis an Kunden; K18, K19 Website-Analyse
|
||||
const EXPECTED = [
|
||||
...Array.from({ length: 11 }, (_, i) => `K${String(i + 1).padStart(2, '0')}`),
|
||||
'K12',
|
||||
@@ -15,6 +15,8 @@ const EXPECTED = [
|
||||
'K15',
|
||||
'K16',
|
||||
'K17',
|
||||
'K18',
|
||||
'K19',
|
||||
...Array.from({ length: 11 }, (_, i) => `T${String(i + 1).padStart(2, '0')}`),
|
||||
'T12',
|
||||
'T13',
|
||||
|
||||
@@ -132,6 +132,20 @@ test('C1: neues Projekt per Push wird nach Verfolger-Erfolg bereit', async () =>
|
||||
assert.equal(d.provisioningStatus, 'ready')
|
||||
})
|
||||
|
||||
test('C1: Verfolger ist vor der Anlage des Projektdokuments fertig -> Ergebnis geht nicht verloren', async () => {
|
||||
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
rs.script = done()
|
||||
await pd.runDeploy('WEBklar/spaet', 'main', { subdomain: 'spaet', type: 'static', branch: 'main' }, { sha: SHA })
|
||||
// Webhook legt das Dokument erst nach dem Lauf an (simulierte Verzoegerung, Lauf ist nach ~10 ms fertig)
|
||||
await new Promise((r) => setTimeout(r, 80))
|
||||
await createDocument('websiteProjects', { repoFullName: 'WEBklar/spaet', projectName: 'S', subdomain: 'spaet', status: 'pending', provisioningStatus: 'pending' }, 'spaet')
|
||||
await pd.waitForRunFollowers()
|
||||
const d = aw.get('websiteProjects', 'spaet')
|
||||
assert.equal(d.provisioningStatus, 'ready')
|
||||
assert.equal(d.status, 'deployed')
|
||||
assert.equal(d.previewState, 'online')
|
||||
})
|
||||
|
||||
test('C1: deployKeepsStatus nur mit Runner an', () => {
|
||||
assert.equal(pd.deployKeepsStatus({ deployed: false, queued: true }), true)
|
||||
assert.equal(pd.deployKeepsStatus({ deployed: false, skipped: true }), true)
|
||||
|
||||
@@ -112,7 +112,7 @@ test('an: Auftrag mit entschlüsselten Variablen, sofortige Rückgabe, Ergebnis
|
||||
assert.equal(aw.get('websiteProjects', id).previewState, 'building')
|
||||
const [sub] = submits()
|
||||
assert.equal(sub.auth, 'Bearer sekret')
|
||||
assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' } })
|
||||
assert.deepEqual(sub.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'auto', displayName: 'Mein Projekt', env: { API_URL: 'https://x.test' }, analytics: null })
|
||||
await pd.waitForRunFollowers()
|
||||
const d = aw.get('websiteProjects', id)
|
||||
assert.equal(d.previewState, 'online')
|
||||
@@ -366,3 +366,94 @@ test('dev-Ergebnis: PUT /dev/<sub> mit vollständigem Auftrag inkl. Variablen',
|
||||
const put = rs.calls.find((c) => c.method === 'PUT')
|
||||
assert.deepEqual(put.body, { sub: repo.split('/')[1], repo, sha: SHA, branch: 'main', mode: 'dev', env: { A: 'wert1234' } })
|
||||
})
|
||||
|
||||
// ---- Analyse-Feld im Build-Auftrag (Task 10) ----
|
||||
async function analyseProject({ feature = {}, customer = {}, project: pextra = {}, noFeature = false, noCustomer = false } = {}) {
|
||||
const { createDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const cid = `cust-an-${n + 1}`
|
||||
const p = await project({ customerId: cid, subdomain: `an-${n + 1}`, isPublic: true, ...pextra })
|
||||
if (!noCustomer) {
|
||||
await createDocument('customers', { name: 'Max Muster', companyName: 'Muster GmbH', email: 'info@muster.test', street: 'Hauptstr. 1', postalCode: '12345', city: 'Berlin', ...customer }, cid)
|
||||
}
|
||||
if (!noFeature) {
|
||||
await createDocument('portalFeatures', { customerId: cid, projectId: p.id, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdef0123456789abcd', accentColor: '#ff0066', ...feature }, `f_${p.id}_analyse`)
|
||||
}
|
||||
return p
|
||||
}
|
||||
async function submitFor(p) {
|
||||
await pd.runDeploy(p.repo, 'main', cfg(p.repo), { project: p.doc, sha: SHA })
|
||||
await pd.waitForRunFollowers()
|
||||
return submits().at(-1).body
|
||||
}
|
||||
|
||||
test('Analyse: aktiv + öffentlich ⇒ analytics mit siteKey, Farbe, Datenschutzangaben', async () => {
|
||||
const body = await submitFor(await analyseProject())
|
||||
assert.deepEqual(body.analytics, {
|
||||
siteKey: 'abcdef0123456789abcd',
|
||||
color: '#ff0066',
|
||||
privacy: { company: 'Muster GmbH', address: 'Hauptstr. 1, 12345 Berlin', email: 'info@muster.test' },
|
||||
})
|
||||
})
|
||||
|
||||
test('Analyse: Standardfarbe, Firma fällt auf Name zurück, Adresse optional', async () => {
|
||||
const p = await analyseProject({ feature: { accentColor: '' }, customer: { companyName: '', street: '', postalCode: '', city: '' } })
|
||||
const body = await submitFor(p)
|
||||
assert.equal(body.analytics.color, '#111111')
|
||||
assert.equal(body.analytics.privacy.company, 'Max Muster')
|
||||
assert.equal(body.analytics.privacy.address, '')
|
||||
})
|
||||
|
||||
test('Analyse: aus ⇒ null', async () => {
|
||||
assert.equal((await submitFor(await analyseProject({ feature: { state: 'off', enabled: false } }))).analytics, null)
|
||||
assert.equal((await submitFor(await analyseProject({ noFeature: true }))).analytics, null)
|
||||
})
|
||||
|
||||
test('Analyse: nicht öffentlich oder ohne Subdomain ⇒ null', async () => {
|
||||
assert.equal((await submitFor(await analyseProject({ project: { isPublic: false } }))).analytics, null)
|
||||
assert.equal((await submitFor(await analyseProject({ project: { subdomain: '' } }))).analytics, null)
|
||||
})
|
||||
|
||||
test('Analyse: fehlende E-Mail oder Firma ⇒ null mit Warnung, Build läuft trotzdem', async (t) => {
|
||||
const warn = t.mock.method(console, 'warn', () => {})
|
||||
const a = await analyseProject({ customer: { email: '' } })
|
||||
assert.equal((await submitFor(a)).analytics, null)
|
||||
assert.ok(warn.mock.calls.some((c) => String(c.arguments[0]).includes(a.id)))
|
||||
assert.equal((await submitFor(await analyseProject({ customer: { companyName: '', name: '' } }))).analytics, null)
|
||||
assert.equal((await submitFor(await analyseProject({ noCustomer: true }))).analytics, null)
|
||||
})
|
||||
|
||||
test('Analyse: Fehler bei der Funktions-Abfrage ⇒ null, Auftrag wird trotzdem gesendet', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
const p = await analyseProject()
|
||||
aw.failNext({ method: 'GET', pathIncludes: 'portalFeatures', status: 500 })
|
||||
const before = submits().length
|
||||
const body = await submitFor(p)
|
||||
assert.equal(submits().length, before + 1)
|
||||
assert.equal(body.analytics, null)
|
||||
})
|
||||
|
||||
test('Analyse: Dev-PUT nach erfolgreichem Build (Modus dev) enthält kein analytics', async () => {
|
||||
rs.script = done({ result: 'dev' })
|
||||
const p = await analyseProject({ project: { previewMode: 'dev' } })
|
||||
const body = await submitFor(p)
|
||||
assert.ok(body.analytics)
|
||||
const puts = rs.calls.filter((c) => c.method === 'PUT' && c.url.startsWith('/dev/'))
|
||||
assert.ok(puts.length >= 1)
|
||||
for (const c of puts) assert.equal('analytics' in c.body, false)
|
||||
})
|
||||
|
||||
test('Analyse: überlange Angaben, Steuerzeichen, schlechte Farbe ⇒ Grenzen eingehalten, Auftrag geht raus', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
const p = await analyseProject({
|
||||
feature: { accentColor: 'rot' },
|
||||
customer: { companyName: 'F\u0007irma ' + 'x'.repeat(250), street: 's'.repeat(400) },
|
||||
})
|
||||
const a = (await submitFor(p)).analytics
|
||||
assert.equal(a.color, '#111111')
|
||||
assert.ok(a.privacy.company.length <= 200 && !/[\x00-\x1f]/.test(a.privacy.company))
|
||||
assert.ok(a.privacy.address.length <= 300)
|
||||
const q = await analyseProject({ customer: { email: 'a'.repeat(250) + '@x.de' } })
|
||||
assert.equal((await submitFor(q)).analytics, null)
|
||||
const r = await analyseProject({ customer: { email: 'keine-mail' } })
|
||||
assert.equal((await submitFor(r)).analytics, null)
|
||||
})
|
||||
@@ -306,3 +306,24 @@ test('retention_loescht_news: Beiträge, Fotos und Funktionen fälliger Kunden',
|
||||
assert.equal(feat.enabled, false)
|
||||
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'rn2_feat').state, 'active')
|
||||
})
|
||||
|
||||
test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () => {
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
sub('ra_sub', 'ra', { status: 'canceled', endedAt: '2022-03-15T10:00:00.000Z' })
|
||||
stripeSub('ra_sub', 'ra')
|
||||
aw.seed('websiteProjects', { $id: 'rap', customerId: 'ra', projectName: 'Alt', subdomain: 'altprojekt', isPublic: true })
|
||||
aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' })
|
||||
const puts = []
|
||||
const ok = analyseDeps.client
|
||||
analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } }
|
||||
try {
|
||||
const plan = await findDueDeletions(JAN_2026)
|
||||
assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat'))
|
||||
const result = await applyDeletions(plan)
|
||||
assert.deepEqual(result.failed, [])
|
||||
assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]])
|
||||
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled')
|
||||
} finally {
|
||||
analyseDeps.client = ok
|
||||
}
|
||||
})
|
||||
@@ -18,6 +18,7 @@ before(async () => {
|
||||
],
|
||||
workorders: [{ $id: 't1', woid: '100115', customerId: 'c1', status: 'Open' }],
|
||||
customers: [{ $id: 'c1', name: 'Döner Nation' }],
|
||||
portalFeatures: [{ $id: 'f1', customerId: 'c1', projectId: 'p1', featureKey: 'analyse', state: 'active' }],
|
||||
},
|
||||
})
|
||||
Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key' })
|
||||
@@ -53,10 +54,43 @@ test('Katalog + Gruppen, Archiv/Vorlage ausgeschlossen, Namen aufgelöst', async
|
||||
assert.deepEqual(body.groups.website[0], {
|
||||
id: 'p1', projectName: 'Doener', repoFullName: 'JUSN/doener', customerId: 'c1',
|
||||
customerName: 'Döner Nation', ticketId: 't1', woid: '100115',
|
||||
previewUrl: 'https://doener.project.webklar.com', previewState: '', features: {},
|
||||
previewUrl: 'https://doener.project.webklar.com', previewState: '', features: { analyse: 'active' },
|
||||
})
|
||||
assert.equal(body.groups.missing[0].previewUrl, 'https://ohne.de')
|
||||
assert.equal(body.groups.missing[0].customerName, '')
|
||||
const all = Object.values(body.groups).flat().map((p) => p.id)
|
||||
assert.ok(!all.includes('p3') && !all.includes('p4'))
|
||||
})
|
||||
|
||||
test('Analyse: features.analyse je Projekt, Katalogstatus folgt ANALYSE_ENABLED', async () => {
|
||||
const { config } = await import('../server/config.js')
|
||||
const was = config.analyse.enabled
|
||||
try {
|
||||
config.analyse.enabled = false
|
||||
let body = await (await get('jwt-admin')).json()
|
||||
assert.equal(body.features.analyse.status, 'in_development')
|
||||
config.analyse.enabled = true
|
||||
body = await (await get('jwt-admin')).json()
|
||||
assert.equal(body.features.analyse.status, 'available')
|
||||
assert.equal(body.groups.website[0].features.analyse, 'active')
|
||||
assert.equal(body.groups.missing[0].features.analyse, null)
|
||||
} finally {
|
||||
config.analyse.enabled = was
|
||||
}
|
||||
const { FEATURES } = await import('../server/services/projectKinds.js')
|
||||
assert.equal(FEATURES.analyse.status, 'in_development')
|
||||
})
|
||||
|
||||
test('Analyse-Abfrage scheitert => 200, features.analyse null', async () => {
|
||||
aw.failNext({ method: 'GET', pathIncludes: '/portalFeatures/', status: 500 })
|
||||
const warn = console.warn
|
||||
console.warn = () => {}
|
||||
try {
|
||||
const r = await get('jwt-admin')
|
||||
assert.equal(r.status, 200)
|
||||
const body = await r.json()
|
||||
assert.equal(body.groups.website[0].features.analyse, null)
|
||||
} finally {
|
||||
console.warn = warn
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user