diff --git a/analytics/client/core.js b/analytics/client/core.js index e017e1a..bb6307d 100644 --- a/analytics/client/core.js +++ b/analytics/client/core.js @@ -5,6 +5,10 @@ export const CONSENT_KEY = 'wk-consent'; export const CONSENT_MAX_AGE = 183 * 86400000; export const BANNER_VERSION = '2026-10-02'; export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst +// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt. +export const BANNER_VERSION_EIGEN = '2026-10-17'; +export const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; export const BANNER_LINK_LABEL = 'Datenschutz'; export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; export const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; @@ -88,13 +92,20 @@ export function contactKind(a, ownHost) { return stripWww(host) === stripWww(own || '') ? null : 'out'; } -export function readConsent(storage, now) { +// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung. +export function bannerFor(variant) { + return variant === 'eigen' + ? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN } + : { version: BANNER_VERSION, text: BANNER_TEXT }; +} + +export function readConsent(storage, now, version = BANNER_VERSION) { 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; - if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen + if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; return { v: 1, a: c.a, t: c.t, bv: c.bv }; diff --git a/analytics/client/dist/h.js b/analytics/client/dist/h.js index 7b53d4d..e539375 100644 --- a/analytics/client/dist/h.js +++ b/analytics/client/dist/h.js @@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent'; const CONSENT_MAX_AGE = 183 * 86400000; const BANNER_VERSION = '2026-10-02'; const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst +// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt. +const BANNER_VERSION_EIGEN = '2026-10-17'; +const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; const BANNER_LINK_LABEL = 'Datenschutz'; const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; @@ -91,13 +95,20 @@ function contactKind(a, ownHost) { return stripWww(host) === stripWww(own || '') ? null : 'out'; } -function readConsent(storage, now) { +// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung. +function bannerFor(variant) { + return variant === 'eigen' + ? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN } + : { version: BANNER_VERSION, text: BANNER_TEXT }; +} + +function readConsent(storage, now, version = BANNER_VERSION) { 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; - if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen + if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; return { v: 1, a: c.a, t: c.t, bv: c.bv }; diff --git a/analytics/client/dist/w.js b/analytics/client/dist/w.js index 63ba72c..372e02b 100644 --- a/analytics/client/dist/w.js +++ b/analytics/client/dist/w.js @@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent'; const CONSENT_MAX_AGE = 183 * 86400000; const BANNER_VERSION = '2026-10-02'; const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; +// Eigene Websites der WEBklar GbR (webklar.com, Skript-Attribut data-variant="eigen"): Sie-Form, WEBklar misst selbst +// (keine Auftragsverarbeitung). Eigene Fassung, damit eine Textaenderung nur dort neu fragt. +const BANNER_VERSION_EIGEN = '2026-10-17'; +const BANNER_TEXT_EIGEN = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Ihrer Zustimmung messen wir zusätzlich, von welcher Website Sie kommen, wie lange Sie bleiben, wie weit Sie scrollen und wo Sie klicken (Heatmap), mit einer täglich wechselnden Kennung. Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben. Ihre Zustimmung können Sie jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '; const BANNER_LINK_LABEL = 'Datenschutz'; const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung'; const SETTINGS_LABEL = 'Datenschutz-Einstellungen'; @@ -91,13 +95,20 @@ function contactKind(a, ownHost) { return stripWww(host) === stripWww(own || '') ? null : 'out'; } -function readConsent(storage, now) { +// Banner-Fassung je Variante: 'eigen' fuer WEBklars eigene Websites, alles andere ist die Kunden-Fassung. +function bannerFor(variant) { + return variant === 'eigen' + ? { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN } + : { version: BANNER_VERSION, text: BANNER_TEXT }; +} + +function readConsent(storage, now, version = BANNER_VERSION) { 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; - if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen + if (c.bv !== version) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen const age = now - c.t; if (age < 0 || age >= CONSENT_MAX_AGE) return null; return { v: 1, a: c.a, t: c.t, bv: c.bv }; @@ -134,6 +145,8 @@ function main() { if (!/^[a-z0-9]{16,40}$/.test(site)) return; const colorAttr = String(SELF.getAttribute('data-color') || ''); const accent = contrastOk(colorAttr) ? colorAttr : '#111111'; + // data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter) + const banner = bannerFor(String(SELF.getAttribute('data-variant') || '')); const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, ''); const base = src.replace(/[^/]*$/, ''); const endpoint = base + 'e'; @@ -173,12 +186,12 @@ function main() { const dnt = (v) => v === '1' || v === 'yes'; const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks - const stored = readConsent(storage, Date.now()); + const stored = readConsent(storage, Date.now(), banner.version); let memChoice = null; let tracking = false; // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) function choiceNow() { - const c = readConsent(storage, Date.now()); + const c = readConsent(storage, Date.now(), banner.version); return c ? c.a : (memChoice ? memChoice.a : null); } const counting = () => choiceNow() !== 'reject'; @@ -312,7 +325,7 @@ function main() { function choose(a) { memChoice = null; - try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } + try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } send({ t: 'bn', site, a }); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); closeBanner(); @@ -352,7 +365,7 @@ function main() { const root = bannerHost.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, CSS)); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); - const p = el('p', { class: 't' }, BANNER_TEXT); + const p = el('p', { class: 't' }, banner.text); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); const row = el('div', { class: 'r' }); const no = el('button', { type: 'button' }, 'Ablehnen'); @@ -433,7 +446,7 @@ function main() { // 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()); + const c = readConsent(storage, Date.now(), banner.version); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (c) closeBanner(); })); diff --git a/analytics/client/w.js b/analytics/client/w.js index d1b004e..d66925d 100644 --- a/analytics/client/w.js +++ b/analytics/client/w.js @@ -1,7 +1,7 @@ // WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites. // Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen; // nach Ablehnen oder bei GPC/DNT geht nichts mehr raus. -import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js'; +import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, bannerFor, CONSENT_KEY, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js'; function main() { const DOC = document; @@ -15,6 +15,8 @@ function main() { if (!/^[a-z0-9]{16,40}$/.test(site)) return; const colorAttr = String(SELF.getAttribute('data-color') || ''); const accent = contrastOk(colorAttr) ? colorAttr : '#111111'; + // data-variant="eigen": Banner-Fassung fuer WEBklars eigene Websites (Sie-Form, ohne Auftragsverarbeiter) + const banner = bannerFor(String(SELF.getAttribute('data-variant') || '')); const src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, ''); const base = src.replace(/[^/]*$/, ''); const endpoint = base + 'e'; @@ -54,12 +56,12 @@ function main() { const dnt = (v) => v === '1' || v === 'yes'; const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack); if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks - const stored = readConsent(storage, Date.now()); + const stored = readConsent(storage, Date.now(), banner.version); let memChoice = null; let tracking = false; // Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt) function choiceNow() { - const c = readConsent(storage, Date.now()); + const c = readConsent(storage, Date.now(), banner.version); return c ? c.a : (memChoice ? memChoice.a : null); } const counting = () => choiceNow() !== 'reject'; @@ -193,7 +195,7 @@ function main() { function choose(a) { memChoice = null; - try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } + try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: banner.version })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ } send({ t: 'bn', site, a }); if (a === 'accept') { startTracking(); onAccept(); } else stopTracking(); closeBanner(); @@ -233,7 +235,7 @@ function main() { const root = bannerHost.attachShadow({ mode: 'open' }); root.appendChild(el('style', {}, CSS)); const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA }); - const p = el('p', { class: 't' }, BANNER_TEXT); + const p = el('p', { class: 't' }, banner.text); p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL)); const row = el('div', { class: 'r' }); const no = el('button', { type: 'button' }, 'Ablehnen'); @@ -314,7 +316,7 @@ function main() { // 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()); + const c = readConsent(storage, Date.now(), banner.version); if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking(); if (c) closeBanner(); })); diff --git a/analytics/test/client.test.js b/analytics/test/client.test.js index e438971..a14fbce 100644 --- a/analytics/test/client.test.js +++ b/analytics/test/client.test.js @@ -7,7 +7,7 @@ import { dirname, join } from 'node:path'; import { vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml, } from '../client/core.js'; -import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js'; +import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL, BANNER_VERSION_EIGEN, BANNER_TEXT_EIGEN, bannerFor } from '../client/core.js'; import { buildAll } from '../client/build.js'; const BV = '2026-10-02'; @@ -170,12 +170,12 @@ function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0]; const settingsBtn = (h) => findAll(h.body, (n) => n.tagName === 'BUTTON' && n.attrs['aria-label'] === SETTINGS_LABEL)[0]; -function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) { +function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad, variant } = {}) { const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000; if (stored) store['wk-consent'] = JSON.stringify(stored); const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; 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 script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); if (variant !== undefined) script.setAttribute('data-variant', variant); script.src = src; const docL = {}; const winL = {}; const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', ''); const injected = []; @@ -641,3 +641,50 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => { g.ck(lnk('/kontakt')); assert.equal(enCount(g), 0); }); + +// ---------- Variante "eigen" (WEBklars eigene Websites, z. B. webklar.com) ---------- +test('core: bannerFor und readConsent je Fassung', () => { + assert.deepEqual(bannerFor('eigen'), { version: BANNER_VERSION_EIGEN, text: BANNER_TEXT_EIGEN }); + for (const v of ['', 'kunde', 'EIGEN', 'x', undefined]) assert.deepEqual(bannerFor(v), { version: BANNER_VERSION, text: BANNER_TEXT }); + assert.equal(BANNER_VERSION_EIGEN, '2026-10-17'); + assert.notEqual(BANNER_VERSION_EIGEN, BANNER_VERSION); + const now = 1_700_000_000_000; + const st = (o) => ({ getItem: () => JSON.stringify(o) }); + assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BV, t: now }), now, BANNER_VERSION_EIGEN), null, 'Kunden-Fassung gilt nicht fuer eigen'); + assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now, BANNER_VERSION_EIGEN).a, 'accept'); + assert.equal(readConsent(st({ v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: now }), now), null, 'ohne Fassung gilt die Kunden-Fassung'); +}); + +test('core: Banner-Text eigen (Sie-Form, ohne Auftragsverarbeiter)', () => { + assert.match(BANNER_TEXT_EIGEN, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./); + assert.match(BANNER_TEXT_EIGEN, /Wir messen selbst auf unserem Server in Deutschland, ohne Cookies und ohne Werbung\./); + assert.match(BANNER_TEXT_EIGEN, /Nach „Ablehnen“ zählen wir nur noch, dass Sie abgelehnt haben\./); + assert.match(BANNER_TEXT_EIGEN, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/); + assert.doesNotMatch(BANNER_TEXT_EIGEN, /\b(Du|Dein|Deine|Deiner|Dich|Dir)\b/); + assert.doesNotMatch(BANNER_TEXT_EIGEN, /betreibt für uns/); +}); + +test('w.js: data-variant="eigen" => Sie-Text, eigene Fassung im Speicher', () => { + const h = harness({ variant: 'eigen' }); + const host = hostEl(h); + const p = findAll(host.shadow, (n) => n.tagName === 'P')[0]; + assert.ok(p.textContent.startsWith(BANNER_TEXT_EIGEN)); + assert.ok(!p.textContent.startsWith(BANNER_TEXT)); + btn(host.shadow, 'Annehmen').click(); + assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 }); + assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']); +}); + +test('w.js: eigen fragt neu bei Wahl aus der Kunden-Fassung, gilt mit eigener Fassung', () => { + const alt = harness({ variant: 'eigen', stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } }); + assert.ok(hostEl(alt), 'Banner erscheint erneut'); + const ok = harness({ variant: 'eigen', stored: { v: 1, a: 'reject', bv: BANNER_VERSION_EIGEN, t: 1_700_000_000_000 - 1000 } }); + assert.equal(hostEl(ok), undefined); + assert.deepEqual(ok.types(), []); +}); + +test('w.js: unbekannte Variante => Kunden-Fassung', () => { + const h = harness({ variant: 'sonstwas' }); + const p = findAll(hostEl(h).shadow, (n) => n.tagName === 'P')[0]; + assert.ok(p.textContent.startsWith(BANNER_TEXT)); +}); diff --git a/server/config.js b/server/config.js index 426d262..ff4baa6 100644 --- a/server/config.js +++ b/server/config.js @@ -1058,6 +1058,16 @@ export const config = { adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '', heatSecret: process.env.ANALYTICS_HEAT_SECRET || '', avvVersion: '2026-10-02', + // WEBklars eigene Website (kein Kundenprojekt, kein Abo): fester Eintrag im Messdienst, Auswertung im WOMS + // (server/services/analyseEigen.js). Der Schluessel ist nicht geheim, er steht im Skript-Tag von webklar.com. + // aktiv: false schaltet die Messung ab (Messdienst liefert dann das leere Skript und verwirft Ereignisse). + eigeneWebsite: { + aktiv: true, + siteKey: 'yiy992rvq7vchfd7bqtqivah', + projectId: 'webklar-eigene-website', + domains: ['webklar.com', 'www.webklar.com'], + host: 'webklar.com', + }, }, // Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur diff --git a/server/index.js b/server/index.js index f1913a7..79fa936 100644 --- a/server/index.js +++ b/server/index.js @@ -33,6 +33,7 @@ import { purgeViewLog } from './services/customerViewLog.js' import { purgeUnclaimedNewsImages } from './services/newsImages.js' import { runActivation } from './services/siteFeatures.js' import { runAboCheck, runAnalyseReconcile } from './services/analyse.js' +import { ensureEigeneWebsite } from './services/analyseEigen.js' import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js' import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.js' import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js' @@ -54,6 +55,7 @@ import publicShopRoutes from './routes/publicShop.js' import publicUnterkunftRoutes from './routes/publicUnterkunft.js' import publicZeitenRoutes from './routes/publicZeiten.js' import adminAnalyseRoutes from './routes/admin/analyse.js' +import adminAnalyseEigenRoutes from './routes/admin/analyseEigen.js' import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' import adminProjektkontenRoutes from './routes/admin/projektkonten.js' @@ -121,6 +123,8 @@ app.use('/api/admin/customers', adminCustomersRoutes) app.use('/api/admin/employees', adminEmployeesRoutes) app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes) app.use('/api/admin/analyse', adminAnalyseRoutes) +// Website-Analyse der eigenen Website webklar.com (WOMS-Seite Website-Statistik) +app.use('/api/admin/analyse-eigen', adminAnalyseEigenRoutes) // Projektkonten und Auftraege (Admin): vor dem Finanzen-Router, dessen requireAppwriteStaff sonst jede Anfrage darunter zuerst prueft app.use('/api/admin/finanzen/projektkonten', adminProjektkontenRoutes) app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes) @@ -241,6 +245,9 @@ const server = app.listen(config.port, '0.0.0.0', () => { .then(() => runAnalyseReconcile({ now: new Date() })) .then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`)) .catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message)) + // eigene Website webklar.com: Eintrag im Messdienst nachziehen (ohne Kunde und Abo, config.analyse.eigeneWebsite) + .then(() => ensureEigeneWebsite()) + .catch((err) => console.warn('[analyse] Eintrag für webklar.com fehlgeschlagen:', err.message)) setTimeout(analyseAboCheck, 2 * 60 * 1000).unref() setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref() diff --git a/server/routes/admin/analyseEigen.js b/server/routes/admin/analyseEigen.js new file mode 100644 index 0000000..6462042 --- /dev/null +++ b/server/routes/admin/analyseEigen.js @@ -0,0 +1,34 @@ +// Admin-API (WOMS): Auswertung der Website-Analyse fuer WEBklars eigene Website (webklar.com). Jeder Mitarbeiter darf lesen. +import { Router } from 'express' +import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js' +import { eigeneAuswertung, eigenerHeatmapLink, gueltigerPfad } from '../../services/analyseEigen.js' + +const router = Router() +router.use(requireAppwriteTeam) + +router.get('/', async (req, res) => { + const range = String(req.query.range || '30') + if (!['7', '30', '90'].includes(range)) return res.status(400).json({ error: 'Ungültiger Zeitraum (7, 30 oder 90 Tage)' }) + try { + res.json(await eigeneAuswertung(Number(range))) + } catch (err) { + // 404: Messdienst kennt die Website noch nicht (Eintrag entsteht beim Start bzw. im Tagesjob) + if (err.status === 404) return res.status(409).json({ error: 'Die Messung für webklar.com ist noch nicht eingerichtet.' }) + console.error('[admin-analyse-eigen] Auswertung:', err.status || '', err.message) + res.status(503).json({ error: 'Auswertung gerade nicht erreichbar' }) + } +}) + +router.post('/heatmap-link', (req, res) => { + const { path, vp } = req.body || {} + if (!gueltigerPfad(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' }) + try { + res.json({ url: eigenerHeatmapLink({ path, vp }) }) + } catch (err) { + console.error('[admin-analyse-eigen] Heatmap-Link:', err.status || '', err.message) + res.status(503).json({ error: 'Der Heatmap-Link konnte gerade nicht erstellt werden.' }) + } +}) + +export default router diff --git a/server/services/analyseEigen.js b/server/services/analyseEigen.js new file mode 100644 index 0000000..b3d6bd3 --- /dev/null +++ b/server/services/analyseEigen.js @@ -0,0 +1,43 @@ +// Website-Analyse fuer WEBklars eigene Website (webklar.com): ohne Kunde, Abo, Funktionsdokument und Tickets. +// Der Messdienst kennt die Website ueber den festen Eintrag config.analyse.eigeneWebsite; das WOMS liest die +// Auswertung ueber /api/admin/analyse-eigen. Rechtlich ist WEBklar hier selbst Verantwortlicher (keine AVV). +import { config } from '../config.js' +import { analyticsClient } from './analyticsClient.js' + +// Tests ersetzen den Client +export const eigenDeps = { client: analyticsClient } + +const website = () => config.analyse.eigeneWebsite + +/** Gleiche Pruefung wie beim Heatmap-Link der Kunden (routes/funktionen.js): Pfad ohne Abfrage, Anker und Steuerzeichen. */ +export function gueltigerPfad(path) { + return typeof path === 'string' && path.startsWith('/') && path.length <= 300 && !/[?#\\\s\u0000-\u001f\u007f]/.test(path) +} + +/** + * Eintrag im Messdienst anlegen bzw. nachziehen (idempotent), bei aktiv: false abschalten. + * Laeuft taeglich und beim Start; ein Fehler wird geworfen und vom Aufrufer geloggt. + */ +export async function ensureEigeneWebsite() { + const w = website() + if (!w.aktiv) { + await eigenDeps.client.stopSite(w.siteKey) + return { aktiv: false } + } + await eigenDeps.client.putSite(w.siteKey, { projectId: w.projectId, domains: w.domains, active: true }) + return { aktiv: true } +} + +/** Kennzahlen fuer 7, 30 oder 90 Tage (Form wie analytics/src/stats.js), dazu Adresse und Zustand fuer die Anzeige. */ +export async function eigeneAuswertung(range) { + const w = website() + const stats = await eigenDeps.client.getStats(w.siteKey, range) + return { website: { host: w.host, aktiv: w.aktiv }, stats } +} + +/** Heatmap-Link fuer eine Seite von webklar.com (neuer Tab; webklar.com laesst sich nicht in fremde Seiten einbetten). */ +export function eigenerHeatmapLink({ path, vp }) { + const w = website() + const token = eigenDeps.client.heatToken({ siteKey: w.siteKey, path, vp }) + return `https://${w.host}${path}?__wk_heat=${encodeURIComponent(token)}` +} diff --git a/test/analyseEigen.test.js b/test/analyseEigen.test.js new file mode 100644 index 0000000..70e6879 --- /dev/null +++ b/test/analyseEigen.test.js @@ -0,0 +1,96 @@ +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' + +// Website-Analyse der eigenen Website webklar.com: fester Messdienst-Eintrag und Auswertung fuers WOMS (/api/admin/analyse-eigen). +const aw = await startAppwriteMock() +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, rawGet, post } = await import('./support/testApp.js') +const { default: routes } = await import('../server/routes/admin/analyseEigen.js') +const eigen = await import('../server/services/analyseEigen.js') + +const app = await startTestApp({ '/api/admin/analyse-eigen': routes }) +const vorher = { ...config.analyse.eigeneWebsite } +after(() => { + Object.assign(config.analyse.eigeneWebsite, vorher) + return Promise.all([app.close(), aw.close(), st.close()]) +}) + +const aufrufe = [] +let statsFehler = null +eigen.eigenDeps.client = { + putSite: async (siteKey, body) => aufrufe.push({ op: 'put', siteKey, ...body }), + stopSite: async (siteKey) => aufrufe.push({ op: 'stop', siteKey }), + getStats: async (siteKey, range) => { + if (statsFehler) throw statsFehler + aufrufe.push({ op: 'stats', siteKey, range }) + return { range, totals: { pv: 12 } } + }, + heatToken: ({ siteKey, path, vp }) => `tok:${siteKey}:${path}:${vp}`, +} +const staff = { headers: { Authorization: 'Bearer jwt-staff' } } +const W = config.analyse.eigeneWebsite + +beforeEach(() => { + aufrufe.length = 0 + statsFehler = null + Object.assign(config.analyse.eigeneWebsite, vorher) +}) + +test('Konfiguration: gueltiger Schluessel und Domains, wie sie der Messdienst annimmt', () => { + assert.match(W.siteKey, /^[a-z0-9]{16,40}$/) + assert.equal(W.aktiv, true) + assert.equal(W.host, 'webklar.com') + assert.deepEqual(W.domains, ['webklar.com', 'www.webklar.com']) + assert.ok(W.projectId.length > 0 && W.projectId.length <= 100) +}) + +test('ensureEigeneWebsite: legt den Eintrag an bzw. zieht ihn nach; aktiv false schaltet ab', async () => { + assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: true }) + assert.deepEqual(aufrufe, [{ op: 'put', siteKey: W.siteKey, projectId: W.projectId, domains: W.domains, active: true }]) + aufrufe.length = 0 + config.analyse.eigeneWebsite.aktiv = false + assert.deepEqual(await eigen.ensureEigeneWebsite(), { aktiv: false }) + assert.deepEqual(aufrufe, [{ op: 'stop', siteKey: W.siteKey }]) +}) + +test('GET: nur fuer Mitarbeiter', async () => { + assert.equal((await rawGet('/api/admin/analyse-eigen')).status, 401) + assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-kunde' } })).status, 403) + assert.equal((await rawGet('/api/admin/analyse-eigen', { headers: { Authorization: 'Bearer jwt-admin' } })).status, 200) + assert.deepEqual(aufrufe.map((a) => a.op), ['stats']) +}) + +test('GET: Auswertung mit Zeitraum, Adresse und Zustand', async () => { + const r = await rawGet('/api/admin/analyse-eigen?range=7', staff) + assert.equal(r.status, 200) + assert.deepEqual(r.body, { website: { host: 'webklar.com', aktiv: true }, stats: { range: 7, totals: { pv: 12 } } }) + assert.deepEqual(aufrufe, [{ op: 'stats', siteKey: W.siteKey, range: 7 }]) + assert.equal((await rawGet('/api/admin/analyse-eigen', staff)).body.stats.range, 30, 'Vorgabe 30 Tage') + for (const bad of ['1', '31', 'abc', '30x']) assert.equal((await rawGet(`/api/admin/analyse-eigen?range=${bad}`, staff)).status, 400) +}) + +test('GET: Messdienst ohne Eintrag => 409, nicht erreichbar => 503 ohne Details', async () => { + statsFehler = Object.assign(new Error('Auswertung antwortet mit Fehler 404'), { status: 404 }) + const a = await rawGet('/api/admin/analyse-eigen', staff) + assert.equal(a.status, 409) + assert.match(a.body.error, /noch nicht eingerichtet/) + statsFehler = Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) + const b = await rawGet('/api/admin/analyse-eigen', staff) + assert.deepEqual([b.status, b.body.error], [503, 'Auswertung gerade nicht erreichbar']) +}) + +test('POST heatmap-link: Link auf webklar.com mit Token; Pfad und Ansicht werden geprueft', async () => { + const r = await post('/api/admin/analyse-eigen/heatmap-link', { path: '/wartungs-abo', vp: 'm' }, staff) + assert.equal(r.status, 200) + assert.equal(r.body.url, `https://webklar.com/wartungs-abo?__wk_heat=${encodeURIComponent(`tok:${W.siteKey}:/wartungs-abo:m`)}`) + for (const path of ['wartungs-abo', '/a?b=1', '/a#x', '/a b', '/a\\b', `/${'x'.repeat(300)}`, 5, null]) { + assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path, vp: 'd' }, staff)).status, 400, String(path)) + } + assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'x' }, staff)).status, 400) + assert.equal((await post('/api/admin/analyse-eigen/heatmap-link', { path: '/', vp: 'd' })).status, 401) +})