Compare commits
16
Commits
f3f8ac3777
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2873638b1c | ||
|
|
594aa9196a | ||
|
|
3b5e1d559a | ||
|
|
c6e3c47be7 | ||
|
|
b73cd340bc | ||
|
|
53ae593860 | ||
|
|
fd5994c243 | ||
|
|
5c49f63616 | ||
|
|
23ab43f9fe | ||
|
|
b3ca84a8e5 | ||
|
|
6a25df22fb | ||
|
|
e554071739 | ||
|
|
0fbeadd0c1 | ||
|
|
dd062b1f15 | ||
|
|
07a4e153bf | ||
|
|
9ed295946c |
No files matched your search
@@ -5,6 +5,10 @@ export const CONSENT_KEY = 'wk-consent';
|
|||||||
export const CONSENT_MAX_AGE = 183 * 86400000;
|
export const CONSENT_MAX_AGE = 183 * 86400000;
|
||||||
export const BANNER_VERSION = '2026-10-02';
|
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: ';
|
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_LINK_LABEL = 'Datenschutz';
|
||||||
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||||
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||||
@@ -88,13 +92,20 @@ export function contactKind(a, ownHost) {
|
|||||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
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 {
|
try {
|
||||||
const raw = storage.getItem(CONSENT_KEY);
|
const raw = storage.getItem(CONSENT_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const c = JSON.parse(raw);
|
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 || 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;
|
const age = now - c.t;
|
||||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||||||
|
|||||||
Vendored
+13
-2
@@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent';
|
|||||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||||
const BANNER_VERSION = '2026-10-02';
|
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: ';
|
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_LINK_LABEL = 'Datenschutz';
|
||||||
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||||
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||||
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
|
|||||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
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 {
|
try {
|
||||||
const raw = storage.getItem(CONSENT_KEY);
|
const raw = storage.getItem(CONSENT_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const c = JSON.parse(raw);
|
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 || 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;
|
const age = now - c.t;
|
||||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||||||
|
|||||||
Vendored
+24
-8
@@ -8,6 +8,10 @@ const CONSENT_KEY = 'wk-consent';
|
|||||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||||
const BANNER_VERSION = '2026-10-02';
|
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: ';
|
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_LINK_LABEL = 'Datenschutz';
|
||||||
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||||
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||||
@@ -91,13 +95,20 @@ function contactKind(a, ownHost) {
|
|||||||
return stripWww(host) === stripWww(own || '') ? null : 'out';
|
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 {
|
try {
|
||||||
const raw = storage.getItem(CONSENT_KEY);
|
const raw = storage.getItem(CONSENT_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const c = JSON.parse(raw);
|
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 || 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;
|
const age = now - c.t;
|
||||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
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;
|
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
||||||
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
||||||
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
|
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 src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
|
||||||
const base = src.replace(/[^/]*$/, '');
|
const base = src.replace(/[^/]*$/, '');
|
||||||
const endpoint = base + 'e';
|
const endpoint = base + 'e';
|
||||||
@@ -173,12 +186,12 @@ function main() {
|
|||||||
const dnt = (v) => v === '1' || v === 'yes';
|
const dnt = (v) => v === '1' || v === 'yes';
|
||||||
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
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
|
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 memChoice = null;
|
||||||
let tracking = false;
|
let tracking = false;
|
||||||
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
||||||
function choiceNow() {
|
function choiceNow() {
|
||||||
const c = readConsent(storage, Date.now());
|
const c = readConsent(storage, Date.now(), banner.version);
|
||||||
return c ? c.a : (memChoice ? memChoice.a : null);
|
return c ? c.a : (memChoice ? memChoice.a : null);
|
||||||
}
|
}
|
||||||
const counting = () => choiceNow() !== 'reject';
|
const counting = () => choiceNow() !== 'reject';
|
||||||
@@ -207,7 +220,10 @@ function main() {
|
|||||||
if (navTimer || !counting()) return;
|
if (navTimer || !counting()) return;
|
||||||
navTimer = setTimeout(safe(function () {
|
navTimer = setTimeout(safe(function () {
|
||||||
navTimer = null;
|
navTimer = null;
|
||||||
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
|
||||||
|
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
|
||||||
|
sendEn();
|
||||||
|
sendPv(false);
|
||||||
}), 300);
|
}), 300);
|
||||||
}
|
}
|
||||||
let hooksOn = false;
|
let hooksOn = false;
|
||||||
@@ -312,7 +328,7 @@ function main() {
|
|||||||
|
|
||||||
function choose(a) {
|
function choose(a) {
|
||||||
memChoice = null;
|
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 });
|
send({ t: 'bn', site, a });
|
||||||
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||||
closeBanner();
|
closeBanner();
|
||||||
@@ -352,7 +368,7 @@ function main() {
|
|||||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||||
root.appendChild(el('style', {}, CSS));
|
root.appendChild(el('style', {}, CSS));
|
||||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
|
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));
|
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
|
||||||
const row = el('div', { class: 'r' });
|
const row = el('div', { class: 'r' });
|
||||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||||
@@ -433,7 +449,7 @@ function main() {
|
|||||||
// Einwilligung in anderem Tab geaendert
|
// Einwilligung in anderem Tab geaendert
|
||||||
WIN.addEventListener('storage', safe(function (e) {
|
WIN.addEventListener('storage', safe(function (e) {
|
||||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
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 (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||||
if (c) closeBanner();
|
if (c) closeBanner();
|
||||||
}));
|
}));
|
||||||
|
|||||||
+12
-7
@@ -1,7 +1,7 @@
|
|||||||
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
// 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;
|
// 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.
|
// 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() {
|
function main() {
|
||||||
const DOC = document;
|
const DOC = document;
|
||||||
@@ -15,6 +15,8 @@ function main() {
|
|||||||
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
if (!/^[a-z0-9]{16,40}$/.test(site)) return;
|
||||||
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
const colorAttr = String(SELF.getAttribute('data-color') || '');
|
||||||
const accent = contrastOk(colorAttr) ? colorAttr : '#111111';
|
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 src = String(SELF.src || SELF.getAttribute('src') || '').replace(/[?#].*$/, '');
|
||||||
const base = src.replace(/[^/]*$/, '');
|
const base = src.replace(/[^/]*$/, '');
|
||||||
const endpoint = base + 'e';
|
const endpoint = base + 'e';
|
||||||
@@ -54,12 +56,12 @@ function main() {
|
|||||||
const dnt = (v) => v === '1' || v === 'yes';
|
const dnt = (v) => v === '1' || v === 'yes';
|
||||||
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
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
|
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 memChoice = null;
|
||||||
let tracking = false;
|
let tracking = false;
|
||||||
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
||||||
function choiceNow() {
|
function choiceNow() {
|
||||||
const c = readConsent(storage, Date.now());
|
const c = readConsent(storage, Date.now(), banner.version);
|
||||||
return c ? c.a : (memChoice ? memChoice.a : null);
|
return c ? c.a : (memChoice ? memChoice.a : null);
|
||||||
}
|
}
|
||||||
const counting = () => choiceNow() !== 'reject';
|
const counting = () => choiceNow() !== 'reject';
|
||||||
@@ -88,7 +90,10 @@ function main() {
|
|||||||
if (navTimer || !counting()) return;
|
if (navTimer || !counting()) return;
|
||||||
navTimer = setTimeout(safe(function () {
|
navTimer = setTimeout(safe(function () {
|
||||||
navTimer = null;
|
navTimer = null;
|
||||||
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
if (!counting() || cleanPath(WIN.location.pathname) === lastPath) return;
|
||||||
|
// SPA-Seitenwechsel (Router mit preventDefault, onLinkClick greift dann nicht): en der vorigen Seite zuerst
|
||||||
|
sendEn();
|
||||||
|
sendPv(false);
|
||||||
}), 300);
|
}), 300);
|
||||||
}
|
}
|
||||||
let hooksOn = false;
|
let hooksOn = false;
|
||||||
@@ -193,7 +198,7 @@ function main() {
|
|||||||
|
|
||||||
function choose(a) {
|
function choose(a) {
|
||||||
memChoice = null;
|
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 });
|
send({ t: 'bn', site, a });
|
||||||
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||||
closeBanner();
|
closeBanner();
|
||||||
@@ -233,7 +238,7 @@ function main() {
|
|||||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||||
root.appendChild(el('style', {}, CSS));
|
root.appendChild(el('style', {}, CSS));
|
||||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
|
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));
|
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
|
||||||
const row = el('div', { class: 'r' });
|
const row = el('div', { class: 'r' });
|
||||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||||
@@ -314,7 +319,7 @@ function main() {
|
|||||||
// Einwilligung in anderem Tab geaendert
|
// Einwilligung in anderem Tab geaendert
|
||||||
WIN.addEventListener('storage', safe(function (e) {
|
WIN.addEventListener('storage', safe(function (e) {
|
||||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
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 (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||||
if (c) closeBanner();
|
if (c) closeBanner();
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { dirname, join } from 'node:path';
|
|||||||
import {
|
import {
|
||||||
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
|
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
|
||||||
} from '../client/core.js';
|
} 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';
|
import { buildAll } from '../client/build.js';
|
||||||
|
|
||||||
const BV = '2026-10-02';
|
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 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];
|
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;
|
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);
|
if (stored) store['wk-consent'] = JSON.stringify(stored);
|
||||||
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
|
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
|
||||||
const timers = [];
|
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 docL = {}; const winL = {};
|
||||||
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
|
const link = withConsentLink ? makeEl('a') : null; if (link) link.setAttribute('data-webklar-consent', '');
|
||||||
const injected = [];
|
const injected = [];
|
||||||
@@ -565,7 +565,9 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
|
|||||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||||
h.win.location.pathname = '/zwei';
|
h.win.location.pathname = '/zwei';
|
||||||
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
|
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
|
||||||
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
|
// en der vorigen Seite vor dem pv der neuen (Router-Links verhindern den Standard-Klick, onLinkClick greift nicht)
|
||||||
|
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
|
||||||
|
assert.equal(h.sent[1].d.scroll, 55);
|
||||||
h.ck(target());
|
h.ck(target());
|
||||||
assert.equal(h.sent.at(-1).d.path, '/zwei');
|
assert.equal(h.sent.at(-1).d.path, '/zwei');
|
||||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||||
@@ -641,3 +643,67 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
|
|||||||
g.ck(lnk('/kontakt'));
|
g.ck(lnk('/kontakt'));
|
||||||
assert.equal(enCount(g), 0);
|
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));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('w.js: SPA-Wechsel ohne Einwilligung sendet nur pv, kein en', () => {
|
||||||
|
const h = harness();
|
||||||
|
h.win.location.pathname = '/zwei';
|
||||||
|
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
|
||||||
|
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'pv']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('w.js: en nach SPA-Wechsel nur einmal je Seite (Link-Klick und Router)', () => {
|
||||||
|
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||||
|
h.leave();
|
||||||
|
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.t}:${s.d.path}`), ['pv:/start', 'en:/start', 'pv:/zwei']);
|
||||||
|
h.leave();
|
||||||
|
assert.deepEqual(h.sent.map((s) => `${s.d.t}:${s.d.path}`).slice(-1), ['en:/zwei']);
|
||||||
|
});
|
||||||
Binary file not shown.
@@ -1 +1 @@
|
|||||||
8facae22cea59e23f6a9350374e03f0c42d0cba2e3adfda6062034d47081617a
|
21d406e6836a7d0c1afdb99dc259e4174fb3fe499afcbdac30c8a45a1aa8d605
|
||||||
@@ -8,6 +8,14 @@ Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Clie
|
|||||||
|
|
||||||
Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt.
|
Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt.
|
||||||
|
|
||||||
|
## Eigene Website webklar.com
|
||||||
|
|
||||||
|
Auf webklar.com läuft dieselbe Messung mit WEBklar als Verantwortlichem (keine AVV). Das Skript-Tag trägt `data-variant="eigen"`; der Banner hat dann eine eigene Sie-Fassung (`BANNER_VERSION_EIGEN`), die Zusatzseite liegt im Repository der Website (`public/datenschutz-analyse.html`), die Kopie hier ist byte-gleich zu der ausgelieferten Fassung. Der Abschnitt 4a der Datenschutzerklärung fasst sie zusammen.
|
||||||
|
|
||||||
|
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 2026-10-17 | offen bis Livegang | [banner-eigen-2026-10-17.md](banner-eigen-2026-10-17.md) (`BANNER_VERSION_EIGEN` 2026-10-17) | [datenschutz-analyse-webklar-com-2026-10-17.html](datenschutz-analyse-webklar-com-2026-10-17.html) |
|
||||||
|
|
||||||
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
|
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
|
||||||
|
|
||||||
## Terminplaner
|
## Terminplaner
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Banner-Text Fassung 2026-10-17 (eigene Websites der WEBklar GbR)
|
||||||
|
|
||||||
|
Quelle: `analytics/client/core.js` (Konstanten `BANNER_TEXT_EIGEN`, `BANNER_VERSION_EIGEN`). Gilt nur, wenn das Skript-Tag `data-variant="eigen"` trägt, derzeit nur auf webklar.com. Kunden-Websites behalten die Fassung 2026-10-02 ([banner-2026-10-02.md](banner-2026-10-02.md)).
|
||||||
|
|
||||||
|
- `BANNER_VERSION_EIGEN`: 2026-10-17
|
||||||
|
|
||||||
|
## Fließtext (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: [Datenschutz](/datenschutz-analyse.html)
|
||||||
|
|
||||||
|
## aria-label, Schaltflächen, Einstellungs-Link
|
||||||
|
|
||||||
|
Wie in der Kunden-Fassung: „Einwilligung zur Reichweitenmessung“; „Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst); Keks-Symbol „Datenschutz-Einstellungen“ unten rechts.
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<!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 bei der Reichweitenmessung – WEBklar</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
|
<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 12rem; }
|
||||||
|
.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; }
|
||||||
|
p, li { margin: 6px 0; }
|
||||||
|
ul { padding-left: 24px; }
|
||||||
|
.meta { color: #6b6b70; font-size: 14px; }
|
||||||
|
.box { background: #fff; border: 2px solid #6d1fd1; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||||
|
a { color: #6d1fd1; }
|
||||||
|
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<nav class="top"><a href="/">← Zur Startseite</a> · <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a> · <a href="/impressum">Impressum</a></nav>
|
||||||
|
<div class="brand">WEBKLAR</div>
|
||||||
|
<h1>Datenschutz bei der Reichweitenmessung</h1>
|
||||||
|
<p class="meta">Ergänzung zu unserer Datenschutzerklärung (Abschnitt 4a) · gilt für webklar.com · Stand 17.10.2026</p>
|
||||||
|
<p>Diese Seite beschreibt nur die Reichweitenmessung auf webklar.com. Alle übrigen Hinweise finden Sie in unserer <a href="https://project.webklar.com/datenschutz.html">Datenschutzerklärung</a>.</p>
|
||||||
|
|
||||||
|
<h2>Verantwortlicher</h2>
|
||||||
|
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler<br>
|
||||||
|
E-Mail: <a href="mailto:support@webklar.com">support@webklar.com</a></p>
|
||||||
|
<p>Einen Datenschutzbeauftragten haben wir nicht benannt, weil wir dazu gesetzlich nicht verpflichtet sind.</p>
|
||||||
|
|
||||||
|
<h2>Worum es geht</h2>
|
||||||
|
<p>Wir möchten verstehen, wie unsere Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir unsere eigene Website-Analyse, dieselbe, die wir auch unseren Kunden anbieten. Sie kommt ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken aus. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf unserem Server in Deutschland.</p>
|
||||||
|
<p>Damit die Messung arbeiten kann, lädt Ihr Browser bei jedem Seitenaufruf ein Skript von unserem Server (project.webklar.com). Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Ihre IP-Adresse und Ihr Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Sie ablehnen oder „Global Privacy Control“ aktiviert haben. Beim Abruf des Skripts und bei der Messung speichern und protokollieren wir beides nicht.</p>
|
||||||
|
|
||||||
|
<h2>Solange Sie im Banner nichts ausgewählt haben</h2>
|
||||||
|
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
|
||||||
|
<ul>
|
||||||
|
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
|
||||||
|
<li>das Land, das wir aus Ihrer IP-Adresse ableiten,</li>
|
||||||
|
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
|
||||||
|
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
|
||||||
|
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
|
||||||
|
</ul>
|
||||||
|
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Ihrer IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Sie wiedererkennen könnten, speichern wir ohne Ihre Einwilligung nicht.</p>
|
||||||
|
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
|
||||||
|
|
||||||
|
<h2>Wenn Sie „Ablehnen“ wählen</h2>
|
||||||
|
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Ihr Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
|
||||||
|
|
||||||
|
<h2>Wenn Sie „Annehmen“ wählen</h2>
|
||||||
|
<p>Dann messen wir zusätzlich:</p>
|
||||||
|
<ul>
|
||||||
|
<li>von welcher Website Sie gekommen sind (nur der Domainname),</li>
|
||||||
|
<li>die Größenklasse Ihres Browserfensters,</li>
|
||||||
|
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Ihrer IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
|
||||||
|
<li>wie lange Sie auf einer Seite bleiben (ab dem Laden der Seite) und wie weit Sie scrollen,</li>
|
||||||
|
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
|
||||||
|
<li>ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
|
||||||
|
<li>einen Zähler dafür, dass Sie „Annehmen“ gewählt haben.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||||
|
|
||||||
|
<h2>Was in Ihrem Browser gespeichert wird</h2>
|
||||||
|
<p>Wir speichern Ihre Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Ihres Browsers (Eintrag „wk-consent“), damit wir Sie nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Sie können den Eintrag jederzeit selbst löschen, indem Sie die Website-Daten in Ihrem Browser löschen.</p>
|
||||||
|
|
||||||
|
<h2>Speicherdauer</h2>
|
||||||
|
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten. Beenden wir die Messung, löschen wir alle Messdaten spätestens rund 60 Tage danach. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
|
||||||
|
|
||||||
|
<h2>Empfänger</h2>
|
||||||
|
<p>Die Messung betreiben wir selbst. Die Daten sehen nur unsere Mitarbeiter. Der Server steht bei der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, in Deutschland; Hetzner ist für uns als Auftragsverarbeiter tätig. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||||
|
|
||||||
|
<h2>Freiwilligkeit</h2>
|
||||||
|
<p>Sie sind weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Sie ablehnen oder Ihre Einwilligung widerrufen, können Sie unsere Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
|
||||||
|
|
||||||
|
<div class="box">
|
||||||
|
<h2 style="border:0;margin-top:0;padding-top:0">Einwilligung widerrufen</h2>
|
||||||
|
<p>Sie können Ihre Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicken Sie auf „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts auf jeder Seite) und wählen Sie „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
|
||||||
|
<h2 style="border:0;padding-top:0">Widerspruchsrecht</h2>
|
||||||
|
<p>Sie können der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Ihrer besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählen Sie im Banner oder über „Datenschutz-Einstellungen“ (das Keks-Symbol) „Ablehnen“ oder aktivieren in Ihrem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Sie können Ihren Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Ihre Rechte</h2>
|
||||||
|
<p>Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Ihnen zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle unseres Webservers noch vorhanden sind (Abschnitt 3 der Datenschutzerklärung). Wenn Sie uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Sie möchten, Ihre IP-Adresse nennen, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Ihnen das mit.</p>
|
||||||
|
<p>Mit Ihren Anliegen können Sie sich jederzeit an die oben genannte Adresse wenden. Außerdem haben Sie das Recht, sich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO), für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz.</p>
|
||||||
|
</main>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -6,18 +6,18 @@ Diese Liste ändert die Datenschutzerklärung nicht. `public/datenschutz.html` b
|
|||||||
|
|
||||||
Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei.
|
Je Prüfpunkt: **Fundstelle**, was dort steht, was sich ändert, und die Frage an den Inhaber oder die Kanzlei.
|
||||||
|
|
||||||
**D1** · Fundstelle `public/datenschutz.html:120`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
|
**D1** · Fundstelle `public/datenschutz.html:127`. Nachweis bei der Bestellung einer Funktion. Dort steht der „Klick auf ‚Zahlungspflichtig bestellen‘“, die Stripe-Sitzung und eine Funktionsliste nur mit News-Pinnwand und Terminplaner. Künftig löst das Einschalten einer Funktion im Abo keine Zahlung aus und hat keine Stripe-Sitzung; gespeichert werden der Wortlaut der angezeigten Zeilen, die Fassungen, Zeitpunkt des Klicks auf „Einschalten“, IP-Adresse, Browserkennung, Konto, angemeldete Person und die Kundenart (Fassungen: Abo-Bedingungen 2026-10-16-v6, Funktionsunterlagen 2026-10-16). Die Funktionsliste muss alle Funktionen nennen (News-Pinnwand, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten live, Website-Analyse). Frage: Neuer Absatz „Funktion einschalten“ neben dem Absatz für Bestellungen, damit der Bestand (Funktionen, die einzeln gekauft wurden) unverändert beschrieben bleibt?
|
||||||
|
|
||||||
**D2** · Fundstelle `public/datenschutz.html:119`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
|
**D2** · Fundstelle `public/datenschutz.html:126`. Nachweis bei Abo, Wechsel und Zusatzspeicher. Dort stehen „gegebenenfalls Ihre Angaben zum Leistungsbeginn“ sowie Zeitpunkt, Version, IP-Adresse und Browserkennung. Künftig kommen der Wortlaut der angezeigten Zeilen (Linkzeile, Häkchen für Privatkunden) und die Kundenart zum Klickzeitpunkt hinzu (§ 2 Abs. 6 der Abo-Bedingungen Fassung 6). Frage: Genügt es, die Kundenart in den Aufzählungssatz aufzunehmen, oder braucht sie wegen D3 einen eigenen Hinweis?
|
||||||
|
|
||||||
**D3** · Fundstelle `public/datenschutz.html:81`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
|
**D3** · Fundstelle `public/datenschutz.html:88`. Abschnitt 6 „Kundenportal“, Kundenkonto-Absatz in der Zeile darunter. Die Kundenart („Betrieb“ oder „privat“) ist ein neues Datum im Kundenkonto. Das Team legt sie nach den Angaben des Kunden fest (Entscheidung E3); ist sie leer, gilt der Kunde als privat (E4). Zu ergänzen sind Zweck (Bestellseiten richten sich danach, Nachweis), Rechtsgrundlage (Art. 6 Abs. 1 lit. b und f DSGVO), Quelle (Angaben des Kunden an das Team) und Dauer (wie das Kundenkonto; im Nachweis wie in D1). Frage: Eigener Satz im Kundenkonto-Absatz oder in Abschnitt 7?
|
||||||
|
|
||||||
**D4** · Fundstelle `public/datenschutz.html:121`, ergänzend `public/datenschutz.html:108` und `public/datenschutz.html:114`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
|
**D4** · Fundstelle `public/datenschutz.html:128`, ergänzend `public/datenschutz.html:115` und `public/datenschutz.html:121`. Der Stripe-Absatz beschreibt Zahlungen für Abos und Zusatzspeicher; das Einschalten einer Funktion im Abo löst keine Zahlung und keine Übermittlung an Stripe aus. Die Abschnitte zu Stripe Connect beim Webshop und beim Buchungskalender (Zahlungen der Käufer und Gäste auf das eigene Stripe-Konto des Kunden) bleiben inhaltlich wie sie sind. Frage: Genügt ein Satz, dass Funktionen im Abo ohne Zahlung eingeschaltet werden, oder sollen die Abschnitte nur an der Einleitung („bestellen“ statt „einschalten“) angepasst werden?
|
||||||
|
|
||||||
**D5** · Fundstelle `public/datenschutz.html:95`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
|
**D5** · Fundstelle `public/datenschutz.html:102`. Abschnitt zur Website-Analyse: „Kunden mit Wartungs-Abo“ können die Reichweitenmessung einschalten. Künftig steckt die Website-Analyse im Hosting-Abo und im Wartungs-Abo (Leistungsbeschreibung Website-Analyse Nr. 1 Abs. 1). Der Absatz muss beide Abos nennen und beschreiben, dass die Funktion mit dem Abo endet (Löschfrist 60 Tage, AVV Website-Analyse Nr. 9). Frage: Reicht eine Anpassung der Einleitung?
|
||||||
|
|
||||||
**D6** · Fundstellen `public/datenschutz.html:98` (Terminplaner), `public/datenschutz.html:106` (Webshop), `public/datenschutz.html:112` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
|
**D6** · Fundstellen `public/datenschutz.html:105` (Terminplaner), `public/datenschutz.html:113` (Webshop), `public/datenschutz.html:119` (Buchungskalender), außerdem der Abschnitt zur News-Pinnwand und zu Öffnungszeiten live. Die Funktionsabschnitte sprechen von „Kunden mit Wartungs-Abo“, vom „Freischalten“ und vom Ende mit dem Vertrag. Künftig gilt: Funktionen sind Teil des Wartungs-Abos, enden mit dem Abo (Terminplaner, News-Pinnwand, Webshop und Buchungskalender spätestens am Folgetag, Öffnungszeiten live nach 30 Tagen Weiterlauf) und werden nach den Fristen der Leistungsbeschreibungen gelöscht (30 Tage Terminplaner, News-Pinnwand und Öffnungszeiten live; 90 Tage Webshop und Buchungskalender). Frage: Die Fristen in den Abschnitten und in Abschnitt 7 (Speicherdauer) müssen übereinstimmen; soll Abschnitt 7 die Fristen künftig als Tabelle führen?
|
||||||
|
|
||||||
**D7** · Fundstelle `public/datenschutz.html:131`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
|
**D7** · Fundstelle `public/datenschutz.html:138`, ergänzend die Beschreibung des Stundenkontingents im Kundenkonto-Absatz. Das Stundenkonto und der Vorschuss für die Einrichtung (§ 4 Abs. 8 der Abo-Bedingungen Fassung 6) sind Abrechnungsdaten: Arbeitszeit, Verbrauch, Vorschuss, Ablaufdaten. Sie stehen heute unter „gegebenenfalls Ihr Stundenkontingent“. Frage: Ist der Vorschuss als Abrechnungsdatum mit demselben Zweck, derselben Rechtsgrundlage (Art. 6 Abs. 1 lit. b DSGVO) und derselben Dauer ausreichend beschrieben, oder braucht er einen eigenen Satz?
|
||||||
|
|
||||||
**D8** · Fundstelle `public/datenschutz.html:123`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
|
**D8** · Fundstelle `public/datenschutz.html:130`. Abschnitt zu den Seiten Kündigen und Widerrufen. Die Seiten erscheinen in der neuen Kundenansicht in der du-Form (Entscheidung R16) und zeigen angemeldeten Kunden die laufenden Widerrufsfristen aus bereits vorhandenen Daten (Sammelabfrage der Fristen im Kundenportal, `GET /api/fristen`). Das ist ein Lesen vorhandener Daten ohne neues Datum und ohne neuen Zweck; der Absatz bleibt inhaltlich unverändert. Frage: Genügt die Feststellung, dass kein neuer Absatz nötig ist, oder soll die Anzeige der Fristen im Konto erwähnt werden?
|
||||||
+48
-11
@@ -44,9 +44,9 @@
|
|||||||
|
|
||||||
<div class="brand">WEBKLAR</div>
|
<div class="brand">WEBKLAR</div>
|
||||||
<h1>Datenschutzerklärung</h1>
|
<h1>Datenschutzerklärung</h1>
|
||||||
<p class="meta">Stand 07.10.2026</p>
|
<p class="meta">Stand 22.10.2026</p>
|
||||||
|
|
||||||
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.</p>
|
<p>Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen, mit uns einen Vertrag schließen oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten, und welche Rechte Sie haben.</p>
|
||||||
|
|
||||||
<h2>1. Verantwortlicher</h2>
|
<h2>1. Verantwortlicher</h2>
|
||||||
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br>
|
<p>WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein<br>
|
||||||
@@ -55,6 +55,7 @@
|
|||||||
|
|
||||||
<h2>2. Geltungsbereich</h2>
|
<h2>2. Geltungsbereich</h2>
|
||||||
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p>
|
<p>Diese Erklärung gilt für die Website webklar.com, für das Kundenportal project.webklar.com einschließlich der Vorschauseiten unter *.project.webklar.com und für unsere Leistungen, insbesondere unsere Abos (Hosting-Abo und Wartungs-Abo).</p>
|
||||||
|
<p>Die Erklärung gilt auch für Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen und auf deren Anfrage wir antworten (Abschnitt 11). Das können auch Privatpersonen sein, zum Beispiel in Frage-Foren.</p>
|
||||||
<p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p>
|
<p>Websites, die wir im Auftrag unserer Kunden betreiben, verantwortet der jeweilige Kunde. Dort verarbeiten wir Daten als Auftragsverarbeiter nach den Weisungen des Kunden. Informationen dazu finden Sie in der Datenschutzerklärung der jeweiligen Website.</p>
|
||||||
|
|
||||||
<h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2>
|
<h2>3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit</h2>
|
||||||
@@ -65,17 +66,24 @@
|
|||||||
<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>
|
<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>
|
<h2>4. Cookies und Speicher im Browser</h2>
|
||||||
<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>
|
<p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten (webklar.com und dem Kundenportal) 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>
|
<table>
|
||||||
<tr><th>Name</th><th>Zweck</th><th>Speicherdauer</th></tr>
|
<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>
|
<tr><td>webklar_portal_session (Cookie)</td><td>hält Sie im Kundenportal angemeldet</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
|
||||||
<tr><td>preview_session (Cookie)</td><td>Zugang zu Ihren Vorschauseiten unter *.project.webklar.com</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
|
<tr><td>preview_session (Cookie)</td><td>Zugang zu Ihren Vorschauseiten unter *.project.webklar.com</td><td>7 Tage bzw. bis zur Abmeldung</td></tr>
|
||||||
<tr><td>theme (lokaler Speicher, webklar.com)</td><td>merkt sich die von Ihnen gewählte helle oder dunkle Darstellung</td><td>bis Sie ihn im Browser löschen</td></tr>
|
<tr><td>theme (lokaler Speicher, webklar.com)</td><td>merkt sich die von Ihnen gewählte helle oder dunkle Darstellung</td><td>bis Sie ihn im Browser löschen</td></tr>
|
||||||
|
<tr><td>wk-consent (lokaler Speicher, webklar.com)</td><td>merkt sich Ihre Auswahl im Banner der Reichweitenmessung („Annehmen“ oder „Ablehnen“) mit Datum und Fassung des Banner-Textes (Abschnitt 4a)</td><td>183 Tage, danach fragen wir erneut</td></tr>
|
||||||
</table>
|
</table>
|
||||||
<p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung).</p>
|
<p>Diese Speicherungen sind unbedingt erforderlich, damit wir die von Ihnen gewünschten Funktionen bereitstellen können (§ 25 Abs. 2 Nr. 2 TDDDG). Eine Einwilligung ist dafür nicht nötig. Die Verarbeitung stützt sich auf Art. 6 Abs. 1 lit. b DSGVO (Portal) bzw. lit. f DSGVO (Darstellung, Auswahl im Banner).</p>
|
||||||
|
|
||||||
|
<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>
|
||||||
|
<p>Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie unsere Website genutzt wird, um sie zu verbessern. Es ist dieselbe Messung, die wir unseren Kunden anbieten (Abschnitt 6b); für webklar.com sind wir selbst Verantwortlicher. Die Messung läuft auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3), ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Beim ersten Aufruf fragt ein Banner nach Ihrer Zustimmung.</p>
|
||||||
|
<p>Solange Sie im Banner nichts ausgewählt haben, zählen wir nur den Seitenaufruf: Gespeichert werden der Seitenpfad, das Land, das wir aus Ihrer IP-Adresse ableiten, die Geräteklasse, die wir aus dem Browser-Kennzeichen ableiten, der Zeitpunkt, auf die volle Stunde gerundet, und ein Zähler dafür, dass das Banner angezeigt wurde. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen. Wählen Sie „Ablehnen“, zählen wir einmal die Ablehnung und danach nichts mehr. Sendet Ihr Browser „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner nicht an und zählen nichts.</p>
|
||||||
|
<p>Wählen Sie „Annehmen“, messen wir zusätzlich, von welcher Website Sie gekommen sind (nur der Domainname), die Größenklasse Ihres Browserfensters, wie lange Sie auf einer Seite bleiben und wie weit Sie scrollen, wo Sie klicken (Klickposition und Aufbau des angeklickten Elements, ohne Texte und Formulareingaben, für eine „Heatmap“), ob Sie auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klicken (dann nur mit deren Domainname), und eine Besucher-Kennung, die täglich wechselt. Rechtsgrundlage ist Ihre Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO. Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen: über „Datenschutz-Einstellungen“ (das Keks-Symbol unten rechts) und „Ablehnen“.</p>
|
||||||
|
<p>IP-Adressen und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher und speichern sie in den Messdaten nicht. Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen, zusammengefasste Tageswerte ohne Besucher-Kennung nach 25 Monaten. Alle Einzelheiten, auch zum Widerspruch und zu Ihren Rechten, finden Sie auf unserer Zusatzseite <a href="https://webklar.com/datenschutz-analyse.html">webklar.com/datenschutz-analyse.html</a>.</p>
|
||||||
|
|
||||||
<h2>5. Kontaktaufnahme</h2>
|
<h2>5. Kontaktaufnahme</h2>
|
||||||
<p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p>
|
<p>Über das Kontaktformular auf webklar.com erheben wir Name, E-Mail-Adresse, Firma (freiwillig) und Ihre Nachricht. Die Angaben speichern wir in unserer eigenen Datenbank auf unserem Server und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8), damit wir von Ihrer Anfrage erfahren. Schreiben Sie uns per E-Mail oder rufen Sie an, verarbeiten wir die Angaben, die Sie uns dabei mitteilen.</p>
|
||||||
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
|
<p>Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.</p>
|
||||||
|
|
||||||
<h2>6. Kundenportal</h2>
|
<h2>6. Kundenportal</h2>
|
||||||
@@ -134,14 +142,43 @@
|
|||||||
<h2>10. Domains unserer Kunden</h2>
|
<h2>10. Domains unserer Kunden</h2>
|
||||||
<p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
|
<p>Registrieren wir für Sie eine Domain, geben wir die Daten des Domaininhabers (Name, Anschrift, E-Mail-Adresse, Telefonnummer) an den Domain-Registrar, die Hetzner Online GmbH, und an die zuständige Vergabestelle weiter, bei .de-Domains an die DENIC eG in Frankfurt am Main. Sie verarbeiten die Daten als eigene Verantwortliche. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.</p>
|
||||||
|
|
||||||
<h2>11. Interessenten</h2>
|
<a id="interessenten"></a>
|
||||||
<p>Sprechen wir Unternehmen als mögliche Kunden an, verarbeiten wir öffentlich verfügbare Geschäftsdaten: Firmenname, Ansprechpartner, geschäftliche Kontaktdaten und Angaben zur bestehenden Website. Quellen sind etwa die Website des Unternehmens und öffentliche Branchenverzeichnisse. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse liegt in der Anbahnung von Geschäftsbeziehungen. Besteht kein Interesse oder widersprechen Sie, löschen wir die Daten. Ohne Kontakt löschen wir sie spätestens nach zwölf Monaten.</p>
|
<h2>11. Interessenten: Antworten auf öffentliche Anfragen</h2>
|
||||||
|
<p>Manche Menschen und Betriebe suchen öffentlich im Netz jemanden, der ihnen eine Website baut, zum Beispiel in einer Kleinanzeige, auf einem Portal für Freelancer oder in einem Frage-Forum. Auf solche Gesuche melden wir uns von uns aus nur einmal und bieten unsere Leistungen an. Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten). Weil wir Ihre Daten dabei nicht bei Ihnen erheben, sondern aus Ihrem öffentlichen Beitrag, informieren wir Sie hier und mit einem Hinweis am Ende unserer Antwort darüber (Art. 14 DSGVO).</p>
|
||||||
|
<p>So läuft es ab: Ein KI-Assistent (das Sprachmodell Claude von Anthropic) bewertet, ob die Anfrage zu unseren Leistungen passt, und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten). Ein Mitarbeiter von WEBklar liest den Entwurf, ändert ihn bei Bedarf und sendet ihn einmal von Hand: über die Plattform, auf der Sie gefragt haben, oder per E-Mail, wenn Ihr Beitrag darum bittet. Automatische E-Mails verschicken wir nicht. Für E-Mails gilt Abschnitt 8. Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund (das ist der Tag, an dem wir Ihre Anfrage gefunden haben; siehe unten).</p>
|
||||||
|
<p>Am Ende unserer Antwort steht ein Hinweis. Er sagt, dass eine KI-Assistentin von WEBklar antwortet, dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen, und er verweist auf diesen Abschnitt. Außerdem nennt er Ihr Widerspruchsrecht: Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz; sobald wir Ihre Antwort lesen, schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen). Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen.</p>
|
||||||
|
<p>Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht.</p>
|
||||||
|
<p>Die Gesuche finden wir auf zwei Wegen. Erstens schicken uns Plattformen auf unseren Suchauftrag Benachrichtigungs-E-Mails (derzeit Kleinanzeigen, freelancermap und freelance.de); diese Plattformen rufen wir nicht selbst ab. Zweitens findet ein KI-Assistent mit einer Websuche öffentliche Beiträge im Netz und in Frage-Foren, derzeit in den Foren gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org sowie auf anderen öffentlichen Webseiten. Die Suche und den Abruf der Seiten führt Anthropic aus. Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen. Wir suchen nach Gesuchen, nicht nach bestimmten Personen.</p>
|
||||||
|
<p>Wir speichern den Titel des Beitrags, den Beitragstext (höchstens 4000 Zeichen), den Link, das Datum, Ort und Postleitzahl, wenn sie genannt sind, die Plattform, eine Punktzahl mit kurzer Begründung und, wenn der Beitrag um eine Antwort per E-Mail bittet, die E-Mail-Adresse aus dem Beitrag. Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit. Telefonnummern entfernen wir beim Speichern automatisch aus dem Text; das folgt einer Faustregel und erkennt nicht jede Schreibweise. Dazu kommen der Entwurf unserer Antwort, die gesendete Nachricht mit Text und Zeitpunkt und ein Protokoll der einzelnen Arbeitsschritte, zum Beispiel „Neue Anfrage: Titel des Beitrags“.</p>
|
||||||
|
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, auf öffentliche Gesuche nach Website-Leistungen zu antworten. Wir melden uns je Anfrage von uns aus nur einmal, und Sie können der Verarbeitung jederzeit widersprechen.</p>
|
||||||
|
<p>Wir speichern die Daten auf unserem Server bei der Hetzner Online GmbH in Deutschland (Abschnitt 3). Für die Bewertung, den Entwurf und die Websuche setzen wir das Sprachmodell Claude der Anthropic Ireland, Limited, Dublin, Irland, als Auftragsverarbeiter ein. Anthropic erhält dafür den Titel, den Text und den Ort des Beitrags sowie bei der Websuche unsere Suchaufträge und die gefundenen Seiten. Dabei können Daten in die USA übermittelt werden, abgesichert durch Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com).</p>
|
||||||
|
<p>Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14).</p>
|
||||||
|
<p>Wie lange wir die Daten speichern: Wir kürzen Ihre Daten in unserer Datenbank</p>
|
||||||
|
<ul>
|
||||||
|
<li>spätestens 90 Tage nach dem Fund;</li>
|
||||||
|
<li>spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;</li>
|
||||||
|
<li>innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;</li>
|
||||||
|
<li>sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.</li>
|
||||||
|
</ul>
|
||||||
|
<p>Kürzen heißt: Wir löschen Titel, Beitragstext, Link, E-Mail-Adresse, Entwurf und unsere Nachricht an Sie und bereinigen die zugehörigen Einträge im Protokoll. Übrig bleiben nur pseudonymisierte Prüfwerte (Hash-Werte) des Beitrags, Ihrer E-Mail-Adresse und, wenn es eine Firmenadresse ist (also nicht bei Anbietern wie gmail.com oder web.de), der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de. Dazu bleiben Ort, Plattform, Punktzahl, Status und die Zeitpunkte von Beitrag und Fund, jedoch ohne Titel, Text, Link und Kontaktdaten.</p>
|
||||||
|
<p>Ein Hash-Wert ist ein Prüfwert, aus dem sich der ursprüngliche Inhalt nicht ablesen lässt. Wir erkennen damit nur, wenn derselbe Beitrag, dieselbe E-Mail-Adresse oder, bei einer Firmenadresse, eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf. Diese Prüfwerte bewahren wir so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen. Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen.</p>
|
||||||
|
<p>Haben Sie uns geantwortet und übernehmen wir Ihre Angaben als Kontakt oder Kunde in unsere Kundenverwaltung, gelten für diese Daten stattdessen Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9.</p>
|
||||||
|
<p>Für Kopien außerhalb der Datenbank gilt Folgendes. Die Benachrichtigungs-E-Mails der Plattformen enthalten die Anzeigentexte und gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8). Unser Dienst liest dort nur den Posteingang. Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort. Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang, auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben.</p>
|
||||||
|
<p>Eine E-Mail, die ein Mitarbeiter Ihnen sendet, liegt außerdem in unserem Postfach; dafür gilt Abschnitt 8 und, soweit die E-Mail ein Geschäftsbrief ist, die Frist aus Abschnitt 9. Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform.</p>
|
||||||
|
<div class="box widerspruch">
|
||||||
|
<strong>Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)</strong>
|
||||||
|
<p>Sie können der Verarbeitung Ihrer Daten jederzeit widersprechen, ohne Gründe anzugeben. Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com.</p>
|
||||||
|
<p>Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse, schreiben Ihnen dazu nicht mehr und kürzen Ihre Daten wie oben beschrieben. Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma. Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain: Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid.</p>
|
||||||
|
<p>Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben). Weitere Rechte (Art. 15 bis 20 DSGVO) und das Beschwerderecht beschreibt Abschnitt 13.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2>12. Empfänger im Überblick</h2>
|
<h2>12. Empfänger im Überblick</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li>Hetzner Online GmbH: Hosting, E-Mail und Datensicherung (Auftragsverarbeiter)</li>
|
<li>Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)</li>
|
||||||
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
|
<li>UptimeRobot s. r. o.: Überwachung der Erreichbarkeit, Daten auch außerhalb der EU (insbesondere USA), Auftragsverarbeiter (Abschnitt 3)</li>
|
||||||
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li>
|
<li>Stripe: Zahlungsabwicklung und Eröffnung des Stripe-Kontos für den Webshop und den Buchungskalender unserer Kunden (Abschnitte 6d, 6f und 7)</li>
|
||||||
|
<li>Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)</li>
|
||||||
|
<li>Plattformen, auf denen wir Ihre Anfrage beantworten, zum Beispiel Kleinanzeigen: Unsere Antwort läuft über deren Dienst (Abschnitt 11)</li>
|
||||||
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
|
<li>Porkbun LLC: bisheriger E-Mail-Dienst, nur noch bis zur Löschung älterer E-Mails (Abschnitt 8)</li>
|
||||||
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
|
<li>Domain-Registrar und Vergabestellen, sofern wir eine Domain für Sie registrieren (Abschnitt 10)</li>
|
||||||
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
|
<li>Steuerberatung und Behörden, soweit gesetzlich erforderlich</li>
|
||||||
@@ -149,15 +186,15 @@
|
|||||||
<p>Wir verkaufen keine Daten und geben sie nicht zu Werbezwecken weiter.</p>
|
<p>Wir verkaufen keine Daten und geben sie nicht zu Werbezwecken weiter.</p>
|
||||||
|
|
||||||
<h2>13. Ihre Rechte</h2>
|
<h2>13. Ihre Rechte</h2>
|
||||||
<p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.</p>
|
<p>Sie haben das Recht auf Auskunft (Art. 15 DSGVO), Berichtigung (Art. 16), Löschung (Art. 17), Einschränkung der Verarbeitung (Art. 18) und Datenübertragbarkeit (Art. 20). Eine Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen (Art. 7 Abs. 3). Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).</p>
|
||||||
<div class="box widerspruch">
|
<div class="box widerspruch">
|
||||||
<strong>Widerspruchsrecht (Art. 21 DSGVO)</strong>
|
<strong>Widerspruchsrecht (Art. 21 DSGVO)</strong>
|
||||||
<p>Verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit widersprechen. Verarbeiten wir Ihre Daten zur Direktwerbung, können Sie dem jederzeit ohne Angabe von Gründen widersprechen. Dann nutzen wir Ihre Daten nicht mehr für diesen Zweck. Ein formloser Hinweis an die Kontaktdaten in Abschnitt 1 genügt.</p>
|
<p>Verarbeiten wir Ihre Daten auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO, können Sie aus Gründen, die sich aus Ihrer besonderen Situation ergeben, jederzeit widersprechen. Verarbeiten wir Ihre Daten zur Direktwerbung, können Sie dem jederzeit ohne Angabe von Gründen widersprechen. Dann nutzen wir Ihre Daten nicht mehr für diesen Zweck. Ein formloser Hinweis an die Kontaktdaten in Abschnitt 1 genügt.</p>
|
||||||
</div>
|
</div>
|
||||||
<p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p>
|
<p>Sie können sich außerdem bei einer Datenschutz-Aufsichtsbehörde beschweren. Für uns zuständig ist der Landesbeauftragte für den Datenschutz und die Informationsfreiheit Rheinland-Pfalz, Hintere Bleiche 34, 55116 Mainz, poststelle@datenschutz.rlp.de.</p>
|
||||||
|
|
||||||
<h2>14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen</h2>
|
<h2>14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO</h2>
|
||||||
<p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Automatisierte Entscheidungen einschließlich Profiling im Sinne von Art. 22 DSGVO finden nicht statt.</p>
|
<p>Für einen Vertrag mit uns brauchen wir die dafür nötigen Angaben. Ohne sie können wir den Vertrag nicht schließen. Darüber hinaus sind Sie nicht verpflichtet, uns Daten bereitzustellen. Entscheidungen, die Ihnen gegenüber rechtliche Wirkung entfalten oder Sie in ähnlicher Weise erheblich beeinträchtigen (Art. 22 DSGVO), treffen wir nicht automatisiert. Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.</p>
|
||||||
|
|
||||||
<h2>15. Änderungen</h2>
|
<h2>15. Änderungen</h2>
|
||||||
<p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p>
|
<p>Wir passen diese Erklärung an, wenn sich unsere Verarbeitung oder die Rechtslage ändert. Es gilt die jeweils hier veröffentlichte Fassung.</p>
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Lädt die Datenschutzerklärung (PDF und HTML) nach der Freigabe als neue Version in WOMS „Rechtliches“ hoch (Karte datenschutz, Bucket
|
||||||
|
// legal-files, Collection legalVersions): neue Fassung "current", die bisherige gültige wird "superseded" mit validTo. Nur mit
|
||||||
|
// --freigabe=JJJJ-MM-TT; dieser Tag muss der „Stand TT.MM.JJJJ“ in public/datenschutz.html entsprechen. Schon hochgeladene Stände (gleiche source,
|
||||||
|
// also gleicher Commit) werden übersprungen. Den Ablauf teilt sich das Skript mit rechtliches-hochladen.mjs (Funktion hochladen).
|
||||||
|
// Zwei Sperren gegen eine unfertige Fassung (vor dem Schreiben): (1) steht im Text noch eine „[OFFEN: …]“-Markierung (offene Tatsache im
|
||||||
|
// Faktenblatt), (2) passt die Prüfsumme neben dem PDF (docs/WEBklar-Datenschutzerklaerung.pdf.sha256) nicht zur HTML, ist das PDF also nicht aus
|
||||||
|
// dieser HTML gedruckt (scripts/build-legal-pdfs.sh datenschutz), dann Abbruch.
|
||||||
|
//
|
||||||
|
// node scripts/datenschutz-hochladen.mjs --probe nur lesen: Schlüssel, Bucket, Karte datenschutz und ihre Versionen
|
||||||
|
// node scripts/datenschutz-hochladen.mjs --freigabe=JJJJ-MM-TT lädt die Datenschutzerklärung hoch (validFrom = Freigabedatum); ohne Karte,
|
||||||
|
// ohne beide Dateien, mit nicht committetem Stand oder mit abweichendem
|
||||||
|
// „Stand“ Abbruch vor dem Schreiben
|
||||||
|
//
|
||||||
|
// Schlüssel: kurzlebiger Appwrite-Schlüssel mit files.read/files.write/documents.read/documents.write in /root/.appwrite-rechtliches.key (chmod 600).
|
||||||
|
// Der Import dieser Datei liest weder den Schlüssel noch nutzt er das Netz; beides geschieht nur in main (deps.schluessel, deps.fetch).
|
||||||
|
// Dieses Skript wird nur geschrieben und mit Nachbau-deps getestet, nie von einem Agenten ausgeführt (auch nicht mit --probe).
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { BUCKET, KEY_DATEI, alleDokumente, client, ddmmjjjj, hochladen, istKalendertag, versionen } from './rechtliches-hochladen.mjs'
|
||||||
|
|
||||||
|
const DOKUMENT_KEY = 'datenschutz'
|
||||||
|
/** [PDF, HTML]: in dieser Reihenfolge hochgeladen. */
|
||||||
|
const DATEIEN = Object.freeze(['docs/WEBklar-Datenschutzerklaerung.pdf', 'public/datenschutz.html'])
|
||||||
|
const QUELLE = 'public/datenschutz.html'
|
||||||
|
/** Neben dem PDF: SHA-256 der HTML, aus der es gedruckt wurde (scripts/build-legal-pdfs.sh datenschutz). */
|
||||||
|
const PRUEFSUMME = 'docs/WEBklar-Datenschutzerklaerung.pdf.sha256'
|
||||||
|
/** Für Kunden sichtbar (WOMS „Rechtliches“): darum weder „Workers“ noch „Hustler“. */
|
||||||
|
const AENDERUNG = 'Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)'
|
||||||
|
|
||||||
|
const USAGE = 'Aufruf: node scripts/datenschutz-hochladen.mjs --probe | --freigabe=JJJJ-MM-TT'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kommandozeile prüfen: --probe allein oder genau eine --freigabe=JJJJ-MM-TT (ein echter Kalendertag).
|
||||||
|
* @returns {{ modus: 'probe'|'hochladen', freigabe: string|null }}
|
||||||
|
*/
|
||||||
|
export function parseArgs(argv) {
|
||||||
|
const fremde = argv.filter((a) => a !== '--probe' && !a.startsWith('--freigabe='))
|
||||||
|
if (fremde.length > 0) throw new Error(`${USAGE}\nUnbekanntes Argument: ${fremde[0].slice(0, 40)}`)
|
||||||
|
const probe = argv.filter((a) => a === '--probe')
|
||||||
|
const freigaben = argv.filter((a) => a.startsWith('--freigabe='))
|
||||||
|
if (probe.length > 0) {
|
||||||
|
if (probe.length > 1 || freigaben.length > 0) throw new Error(`${USAGE}\n--probe steht allein.`)
|
||||||
|
return { modus: 'probe', freigabe: null }
|
||||||
|
}
|
||||||
|
if (freigaben.length !== 1) throw new Error(`${USAGE}\nFreigabe fehlt: genau eine --freigabe=JJJJ-MM-TT ist beim Hochladen Pflicht.`)
|
||||||
|
const freigabe = freigaben[0].slice('--freigabe='.length)
|
||||||
|
if (!istKalendertag(freigabe)) throw new Error(`Freigabe ungültig (erwartet JJJJ-MM-TT, ein echter Kalendertag): ${freigabe.slice(0, 20)}`)
|
||||||
|
return { modus: 'hochladen', freigabe }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Der Tag hinter „Stand“ in der Kopfzeile der HTML (TT.MM.JJJJ), sonst null. */
|
||||||
|
const standIn = (html) => /<p class="meta">\s*Stand (\d{2}\.\d{2}\.\d{4})\s*<\/p>/.exec(html)?.[1] ?? null
|
||||||
|
|
||||||
|
/** Einstieg: deps = { fetch, schluessel, log, git, dateien } wie bei rechtliches-hochladen.mjs. @returns {Promise<number>} Exit-Code (0 oder 1) */
|
||||||
|
export async function main(argv, deps = {}) {
|
||||||
|
const log = deps.log ?? console.log
|
||||||
|
let args
|
||||||
|
try {
|
||||||
|
args = parseArgs(argv)
|
||||||
|
} catch (err) {
|
||||||
|
log(err.message)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const fetchFn = deps.fetch ?? globalThis.fetch
|
||||||
|
const api = client(fetchFn, (deps.schluessel ?? (() => readFileSync(KEY_DATEI, 'utf8').trim()))())
|
||||||
|
if (args.modus === 'probe') return await probe(api, log)
|
||||||
|
const { freigabe } = args
|
||||||
|
return await hochladen(api, log, {
|
||||||
|
documentKey: DOKUMENT_KEY,
|
||||||
|
dateien: DATEIEN,
|
||||||
|
quelle: QUELLE,
|
||||||
|
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: ${AENDERUNG}`,
|
||||||
|
freigabe,
|
||||||
|
kartenHinweis: '(die Karte gehört zu WOMS „Rechtliches“; dieses Skript legt keine Karten an)',
|
||||||
|
pruefeInhalt: (lies) => {
|
||||||
|
const html = lies(QUELLE)
|
||||||
|
const stand = standIn(html.toString('utf8'))
|
||||||
|
if (!stand) return `Abbruch: „Stand TT.MM.JJJJ“ fehlt in ${QUELLE}`
|
||||||
|
if (stand !== ddmmjjjj(freigabe)) return `Abbruch: Stand ${stand} in ${QUELLE} passt nicht zur Freigabe ${ddmmjjjj(freigabe)}`
|
||||||
|
// offene Tatsachen sind kein veröffentlichungsfähiger Text: die Markierung zuerst durch die geklärte Angabe ersetzen
|
||||||
|
const offen = [...new Set(html.toString('utf8').replace(/<!--[\s\S]*?-->/g, '').match(/\[OFFEN[^\]]*\]/g) ?? [])]
|
||||||
|
if (offen.length > 0) return `Abbruch: offene Markierung im Text von ${QUELLE}: ${offen.join(', ')} (erst klären und ersetzen, dann Stand/Pin setzen, PDF drucken, pushen)`
|
||||||
|
// das PDF muss aus genau dieser HTML gedruckt sein
|
||||||
|
let summe = ''
|
||||||
|
try {
|
||||||
|
summe = lies(PRUEFSUMME).toString('utf8').trim()
|
||||||
|
} catch {
|
||||||
|
return `Abbruch: ${PRUEFSUMME} fehlt oder ist nicht lesbar (PDF mit scripts/build-legal-pdfs.sh datenschutz drucken)`
|
||||||
|
}
|
||||||
|
if (summe !== createHash('sha256').update(html).digest('hex')) return `Abbruch: das PDF ist nicht aus dieser HTML gedruckt (Prüfsumme in ${PRUEFSUMME} passt nicht zu ${QUELLE}); scripts/build-legal-pdfs.sh datenschutz`
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
}, deps)
|
||||||
|
} catch (err) {
|
||||||
|
log(`Fehler: ${err.message}`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** --probe: nur lesen. Eine fehlende Karte ist ein Stopp. */
|
||||||
|
async function probe(api, log) {
|
||||||
|
await api('GET', `/storage/buckets/${BUCKET}/files`)
|
||||||
|
const karten = await alleDokumente(api, 'legalDocuments')
|
||||||
|
log(`${DOKUMENT_KEY}: ${(await versionen(api, DOKUMENT_KEY)).map((v) => `${v.version}:${v.status}`).join(' ') || '—'}`)
|
||||||
|
if (!karten.some((k) => k.key === DOKUMENT_KEY)) {
|
||||||
|
log(`STOPP: Karte fehlt: ${DOKUMENT_KEY}`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
log(`Schlüssel ok (files.read + documents.read), Karte ${DOKUMENT_KEY} vorhanden.`)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
|
||||||
|
main(process.argv.slice(2)).then((code) => {
|
||||||
|
process.exitCode = code
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -23,8 +23,8 @@ import { FUNKTIONEN_UNTERLAGEN_V6 } from '../server/services/products.js'
|
|||||||
const ENDPOINT = 'https://ticket.webklar.com/v1'
|
const ENDPOINT = 'https://ticket.webklar.com/v1'
|
||||||
const PROJECT = '6a1058610003c5a13a05'
|
const PROJECT = '6a1058610003c5a13a05'
|
||||||
const DB = 'woms-database'
|
const DB = 'woms-database'
|
||||||
const BUCKET = 'legal-files'
|
export const BUCKET = 'legal-files'
|
||||||
const KEY_DATEI = '/root/.appwrite-rechtliches.key'
|
export const KEY_DATEI = '/root/.appwrite-rechtliches.key'
|
||||||
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
const ROOT = fileURLToPath(new URL('..', import.meta.url))
|
||||||
const LIVE = 'https://project.webklar.com/'
|
const LIVE = 'https://project.webklar.com/'
|
||||||
|
|
||||||
@@ -81,7 +81,7 @@ export function dokumentFuer(documentKey) {
|
|||||||
|
|
||||||
const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' }
|
const MODI = { '--probe': 'probe', '--karten-anlegen': 'karten', '--pruefen': 'pruefen' }
|
||||||
|
|
||||||
const istKalendertag = (iso) => {
|
export const istKalendertag = (iso) => {
|
||||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(iso)) return false
|
||||||
const d = new Date(`${iso}T12:00:00Z`)
|
const d = new Date(`${iso}T12:00:00Z`)
|
||||||
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso
|
return !Number.isNaN(d.getTime()) && d.toISOString().slice(0, 10) === iso
|
||||||
@@ -165,7 +165,7 @@ const q = (o) => encodeURIComponent(JSON.stringify(o))
|
|||||||
const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20)
|
const neueId = () => Array.from(crypto.getRandomValues(new Uint8Array(10)), (b) => b.toString(16).padStart(2, '0')).join('').slice(0, 20)
|
||||||
|
|
||||||
/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */
|
/** Kleiner Appwrite-Client über deps.fetch; Fehler werden zu Error mit Methode, Pfad und Status. */
|
||||||
function client(fetchFn, schluessel) {
|
export function client(fetchFn, schluessel) {
|
||||||
const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel }
|
const kopf = { 'X-Appwrite-Project': PROJECT, 'X-Appwrite-Key': schluessel }
|
||||||
return async function api(method, pfad, body) {
|
return async function api(method, pfad, body) {
|
||||||
const formular = typeof FormData !== 'undefined' && body instanceof FormData
|
const formular = typeof FormData !== 'undefined' && body instanceof FormData
|
||||||
@@ -180,7 +180,7 @@ function client(fetchFn, schluessel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function alleDokumente(api, sammlung) {
|
export async function alleDokumente(api, sammlung) {
|
||||||
const aus = []
|
const aus = []
|
||||||
for (let offset = 0; ; offset += 100) {
|
for (let offset = 0; ; offset += 100) {
|
||||||
const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`)
|
const r = await api('GET', `/databases/${DB}/collections/${sammlung}/documents?queries[]=${q({ method: 'limit', values: [100] })}&queries[]=${q({ method: 'offset', values: [offset] })}`)
|
||||||
@@ -189,12 +189,12 @@ async function alleDokumente(api, sammlung) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function versionen(api, documentKey) {
|
export async function versionen(api, documentKey) {
|
||||||
const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`)
|
const r = await api('GET', `/databases/${DB}/collections/legalVersions/documents?queries[]=${q({ method: 'equal', attribute: 'documentKey', values: [documentKey] })}&queries[]=${q({ method: 'limit', values: [100] })}`)
|
||||||
return r.documents
|
return r.documents
|
||||||
}
|
}
|
||||||
|
|
||||||
const ddmmjjjj = (iso) => iso.split('-').reverse().join('.')
|
export const ddmmjjjj = (iso) => iso.split('-').reverse().join('.')
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------------------------------- Aufruf
|
// ---------------------------------------------------------------------------------------------------------- Aufruf
|
||||||
|
|
||||||
@@ -218,7 +218,14 @@ export async function main(argv, deps = {}) {
|
|||||||
if (args.modus === 'probe') return await probe(api, log)
|
if (args.modus === 'probe') return await probe(api, log)
|
||||||
if (args.modus === 'karten') return await kartenAnlegen(api, log)
|
if (args.modus === 'karten') return await kartenAnlegen(api, log)
|
||||||
if (args.modus === 'pruefen') return await pruefen(api, log)
|
if (args.modus === 'pruefen') return await pruefen(api, log)
|
||||||
return await hochladen(api, log, args, deps)
|
const d = dokumentFuer(args.documentKey)
|
||||||
|
return await hochladen(api, log, {
|
||||||
|
documentKey: args.documentKey,
|
||||||
|
dateien: d.dateien,
|
||||||
|
quelle: d.quelle,
|
||||||
|
changeNote: `Fassung vom ${ddmmjjjj(args.freigabe)}: Kundenansicht neu (Fassung 6)`,
|
||||||
|
freigabe: args.freigabe,
|
||||||
|
}, deps)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log(`Fehler: ${err.message}`)
|
log(`Fehler: ${err.message}`)
|
||||||
return 1
|
return 1
|
||||||
@@ -263,28 +270,42 @@ async function pruefen(api, log) {
|
|||||||
return fehlt.length > 0 ? 1 : 0
|
return fehlt.length > 0 ? 1 : 0
|
||||||
}
|
}
|
||||||
|
|
||||||
/** <documentKey> --freigabe=…: genau ein Dokument hochladen. Als Erstes die Kartenprüfung, dann Git- und Dateiprüfung, erst dann schreiben. */
|
/**
|
||||||
async function hochladen(api, log, { documentKey, freigabe }, deps) {
|
* Genau ein Dokument als neue Version hochladen (auch vom Datenschutz-Skript genutzt). Als Erstes die Kartenprüfung, dann Datei- und Git-Prüfung
|
||||||
const d = dokumentFuer(documentKey)
|
* und die Inhaltsprüfung des Aufrufers, erst dann schreiben: erst alle Dateien in den Bucket, dann das Versionsdokument, zuletzt die bisherige
|
||||||
|
* gültige Version ablösen. deps = { git, dateien } wie bei main.
|
||||||
|
* @param {object} api Client aus client()
|
||||||
|
* @param {Function} log
|
||||||
|
* @param {object} dok documentKey; dateien = [PDF, HTML] (Pfade im Projekt, in dieser Reihenfolge hochgeladen); quelle = Pfad in source
|
||||||
|
* (kundenbereich@<Commit>:<quelle>); changeNote und freigabe (JJJJ-MM-TT = validFrom) fertig vom Aufrufer; kartenHinweis hängt an der Meldung
|
||||||
|
* „Karte fehlt“; pruefeInhalt(lies) darf nach der Git-Prüfung mit lies(pfad) → Buffer den Inhalt prüfen und gibt eine Abbruchmeldung oder null zurück
|
||||||
|
* @returns {Promise<number>} Exit-Code (0 oder 1)
|
||||||
|
*/
|
||||||
|
export async function hochladen(api, log, { documentKey, dateien, quelle, changeNote, freigabe, kartenHinweis = '(zuerst --karten-anlegen)', pruefeInhalt }, deps) {
|
||||||
const karten = await alleDokumente(api, 'legalDocuments')
|
const karten = await alleDokumente(api, 'legalDocuments')
|
||||||
if (!karten.some((k) => k.key === documentKey)) {
|
if (!karten.some((k) => k.key === documentKey)) {
|
||||||
log(`Karte fehlt: ${documentKey} (zuerst --karten-anlegen)`)
|
log(`Karte fehlt: ${documentKey} ${kartenHinweis}`)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }))
|
const git = deps.git ?? ((args) => execFileSync('git', args, { cwd: ROOT, encoding: 'utf8' }))
|
||||||
const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) }
|
const dateisystem = deps.dateien ?? { existsSync: (rel) => existsSync(path.join(ROOT, rel)), readFileSync: (rel) => readFileSync(path.join(ROOT, rel)) }
|
||||||
for (const rel of d.dateien) {
|
for (const rel of dateien) {
|
||||||
if (!dateisystem.existsSync(rel)) {
|
if (!dateisystem.existsSync(rel)) {
|
||||||
log(`Datei fehlt: ${rel}`)
|
log(`Datei fehlt: ${rel}`)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (git(['status', '--porcelain', '--', ...d.dateien]).trim() !== '') {
|
if (git(['status', '--porcelain', '--', ...dateien]).trim() !== '') {
|
||||||
log(`Abbruch: nicht committeter Stand in ${d.dateien.join(' und ')} (git status --porcelain ist nicht leer)`)
|
log(`Abbruch: nicht committeter Stand in ${dateien.join(' und ')} (git status --porcelain ist nicht leer)`)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
const inhaltsfehler = pruefeInhalt?.((rel) => dateisystem.readFileSync(rel))
|
||||||
|
if (inhaltsfehler) {
|
||||||
|
log(inhaltsfehler)
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
const commit = git(['rev-parse', '--short', 'HEAD']).trim()
|
const commit = git(['rev-parse', '--short', 'HEAD']).trim()
|
||||||
const source = `kundenbereich@${commit}:${d.quelle}`
|
const source = `kundenbereich@${commit}:${quelle}`
|
||||||
const alle = await versionen(api, documentKey)
|
const alle = await versionen(api, documentKey)
|
||||||
const plan = planVersion({ alle, source, validFrom: freigabe })
|
const plan = planVersion({ alle, source, validFrom: freigabe })
|
||||||
if (plan.schon) {
|
if (plan.schon) {
|
||||||
@@ -292,7 +313,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
const fileIds = []
|
const fileIds = []
|
||||||
for (const rel of d.dateien) {
|
for (const rel of dateien) {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('fileId', neueId())
|
form.append('fileId', neueId())
|
||||||
form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel))
|
form.append('file', new Blob([dateisystem.readFileSync(rel)], { type: rel.endsWith('.pdf') ? 'application/pdf' : 'text/html' }), path.basename(rel))
|
||||||
@@ -306,7 +327,7 @@ async function hochladen(api, log, { documentKey, freigabe }, deps) {
|
|||||||
validFrom: freigabe,
|
validFrom: freigabe,
|
||||||
validTo: null,
|
validTo: null,
|
||||||
status: 'current',
|
status: 'current',
|
||||||
changeNote: `Fassung vom ${ddmmjjjj(freigabe)}: Kundenansicht neu (Fassung 6)`,
|
changeNote,
|
||||||
fileIds,
|
fileIds,
|
||||||
source,
|
source,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Geheimer Vorschau-Link (2026-10-26): legt websiteProjects.shareToken an.
|
||||||
|
* Idempotent: ein vorhandenes Attribut wird uebersprungen.
|
||||||
|
* Usage:
|
||||||
|
* node scripts/vorschau-link-setup.mjs --check (nur melden, nichts anlegen)
|
||||||
|
* node scripts/vorschau-link-setup.mjs (fehlendes Attribut anlegen)
|
||||||
|
*/
|
||||||
|
import { pathToFileURL } from 'node:url'
|
||||||
|
import { createAppwriteClient } from './preview-runner-setup.mjs'
|
||||||
|
|
||||||
|
export const VORSCHAU_LINK_ATTRIBUTES = [{ key: 'shareToken', type: 'string', size: 64, required: false }]
|
||||||
|
|
||||||
|
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 = VORSCHAU_LINK_ATTRIBUTES.filter((a) => !existing.has(a.key))
|
||||||
|
for (const attr of VORSCHAU_LINK_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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1058,6 +1058,16 @@ export const config = {
|
|||||||
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
|
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
|
||||||
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
|
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
|
||||||
avvVersion: '2026-10-02',
|
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
|
// Terminplaner (Funktionsschluessel 'termine'). Wird bei jedem Aufruf gelesen (Tests aendern die Werte zur
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import { purgeViewLog } from './services/customerViewLog.js'
|
|||||||
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
||||||
import { runActivation } from './services/siteFeatures.js'
|
import { runActivation } from './services/siteFeatures.js'
|
||||||
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
||||||
|
import { ensureEigeneWebsite } from './services/analyseEigen.js'
|
||||||
import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js'
|
import { aboFunktionenTagesJob } from './services/aboFunktionenAbgleich.js'
|
||||||
import { fuenfMinutenJob, minutenJob, tagesbericht, taeglicherJob } from './services/termine/jobs.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'
|
import { minutenJob as unterkunftMinutenJob, viertelstundenJob as unterkunftViertelstundenJob, taeglicherJob as unterkunftTaeglicherJob } from './services/unterkunft/jobs.js'
|
||||||
@@ -40,6 +41,7 @@ import { minutenJob as shopMinutenJob, stuendlicherJob as shopStuendlicherJob, t
|
|||||||
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
|
import { zeitenAboJob, zeitenErinnerungsJob } from './services/zeiten/jobs.js'
|
||||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||||
import pushTimeRoutes from './routes/pushTime.js'
|
import pushTimeRoutes from './routes/pushTime.js'
|
||||||
|
import { vorschauLinkSeite, vorschauLinkPruefung } from './routes/vorschauLink.js'
|
||||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||||
import adminPreviewRoutes from './routes/admin/preview.js'
|
import adminPreviewRoutes from './routes/admin/preview.js'
|
||||||
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
import adminWebsiteProjectsRoutes from './routes/admin/websiteProjects.js'
|
||||||
@@ -54,6 +56,7 @@ import publicShopRoutes from './routes/publicShop.js'
|
|||||||
import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
|
import publicUnterkunftRoutes from './routes/publicUnterkunft.js'
|
||||||
import publicZeitenRoutes from './routes/publicZeiten.js'
|
import publicZeitenRoutes from './routes/publicZeiten.js'
|
||||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||||
|
import adminAnalyseEigenRoutes from './routes/admin/analyseEigen.js'
|
||||||
import { legalRouter } from './services/legalDocs.js'
|
import { legalRouter } from './services/legalDocs.js'
|
||||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||||
import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
|
import adminProjektkontenRoutes from './routes/admin/projektkonten.js'
|
||||||
@@ -121,6 +124,8 @@ app.use('/api/admin/customers', adminCustomersRoutes)
|
|||||||
app.use('/api/admin/employees', adminEmployeesRoutes)
|
app.use('/api/admin/employees', adminEmployeesRoutes)
|
||||||
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
app.use('/api/admin/invoice-clients', adminInvoiceClientsRoutes)
|
||||||
app.use('/api/admin/analyse', adminAnalyseRoutes)
|
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
|
// 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/projektkonten', adminProjektkontenRoutes)
|
||||||
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
|
app.use('/api/admin/finanzen/auftraege', adminAuftraegeRoutes)
|
||||||
@@ -128,6 +133,9 @@ app.use('/api/admin/finanzen', adminFinanzenRoutes)
|
|||||||
app.use('/webhook', giteaWebhookRoutes)
|
app.use('/webhook', giteaWebhookRoutes)
|
||||||
// Push-Zeitmail: Zeit eines Git-Pushes per Link aus der Mail eintragen (ohne Login, signierter Token)
|
// 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)
|
app.use('/zeit', express.urlencoded({ extended: false, limit: '4kb' }), pushTimeRoutes)
|
||||||
|
// Geheimer Vorschau-Link: Seite ohne Login (Cookie + Weiterleitung) und Pruefung fuer die Preview-Anmeldung
|
||||||
|
app.use('/vorschau', vorschauLinkSeite())
|
||||||
|
app.use('/api/vorschau-link', vorschauLinkPruefung())
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
const dbAccess = await verifyDatabaseAccess()
|
const dbAccess = await verifyDatabaseAccess()
|
||||||
@@ -241,6 +249,9 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
|||||||
.then(() => runAnalyseReconcile({ now: new Date() }))
|
.then(() => runAnalyseReconcile({ now: new Date() }))
|
||||||
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
|
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
|
||||||
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
|
.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()
|
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
|
||||||
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
import { assignProject, reconcileProject } from '../../services/projectAssignment.js'
|
||||||
import { completeAssignment } from '../../services/projectKindAssign.js'
|
import { completeAssignment } from '../../services/projectKindAssign.js'
|
||||||
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
import { requireAppwriteTeam } from '../../middleware/appwriteStaff.js'
|
||||||
|
import { renewShareLink, removeShareLink } from '../../services/vorschauLink.js'
|
||||||
|
|
||||||
const router = Router()
|
const router = Router()
|
||||||
|
|
||||||
@@ -127,4 +128,21 @@ router.post('/:id/pushes', async (req, res) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Geheimer Vorschau-Link: neu erzeugen (alter Link wird ungueltig) oder abschalten
|
||||||
|
router.post('/:id/vorschau-link', async (req, res) => {
|
||||||
|
try {
|
||||||
|
return res.json({ ok: true, ...(await renewShareLink(req.params.id)) })
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status || 500).json({ error: err.message || 'Link konnte nicht erzeugt werden' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
router.delete('/:id/vorschau-link', async (req, res) => {
|
||||||
|
try {
|
||||||
|
return res.json({ ok: true, ...(await removeShareLink(req.params.id)) })
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status || 500).json({ error: err.message || 'Link konnte nicht abgeschaltet werden' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { checkShare, cookieName, previewUrl, shareCookieOptions } from '../services/vorschauLink.js'
|
||||||
|
|
||||||
|
// Geheimer Vorschau-Link, siehe services/vorschauLink.js
|
||||||
|
|
||||||
|
function seite(res, status, titel, text) {
|
||||||
|
res
|
||||||
|
.status(status)
|
||||||
|
.set('Cache-Control', 'no-store')
|
||||||
|
.set('X-Robots-Tag', 'noindex, nofollow')
|
||||||
|
.type('html')
|
||||||
|
.send(`<!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, nofollow"><title>${titel}</title>
|
||||||
|
<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f6f7fb;color:#1a1f36;margin:0;display:grid;place-items:center;min-height:100vh;padding:16px;box-sizing:border-box}
|
||||||
|
main{background:#fff;border-radius:12px;padding:32px;max-width:420px;box-shadow:0 1px 3px rgba(0,0,0,.08)}h1{font-size:20px;margin:0 0 12px}p{margin:0;line-height:1.5;color:#4f566b}</style>
|
||||||
|
</head><body><main><h1>${titel}</h1><p>${text}</p></main></body></html>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Seite: GET /vorschau/:subdomain/:token -> Cookie setzen und auf die Preview weiterleiten */
|
||||||
|
export function vorschauLinkSeite() {
|
||||||
|
const router = Router()
|
||||||
|
router.get('/:subdomain/:token', async (req, res) => {
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = await checkShare(req.params.subdomain, req.params.token)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vorschau-link] Pruefung fehlgeschlagen:', err.message)
|
||||||
|
return seite(res, 500, 'Vorschau nicht erreichbar', 'Bitte in ein paar Minuten noch einmal versuchen.')
|
||||||
|
}
|
||||||
|
if (result.reason === 'not_ready') {
|
||||||
|
return seite(res, 503, 'Vorschau wird vorbereitet', 'Die Seite wird gerade gebaut. Bitte in ein paar Minuten noch einmal versuchen.')
|
||||||
|
}
|
||||||
|
if (!result.allowed) {
|
||||||
|
return seite(res, 404, 'Link nicht gültig', 'Dieser Vorschau-Link ist nicht (mehr) gültig. Bitte frag bei WEBklar nach einem neuen Link.')
|
||||||
|
}
|
||||||
|
res.cookie(cookieName(result.subdomain), req.params.token, shareCookieOptions())
|
||||||
|
res.set('Cache-Control', 'no-store')
|
||||||
|
res.set('Referrer-Policy', 'no-referrer')
|
||||||
|
return res.redirect(302, previewUrl(result.subdomain))
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fuer die Login-Pruefung der Previews: GET /api/vorschau-link/pruefen?subdomain=..., Header x-vorschau-token */
|
||||||
|
export function vorschauLinkPruefung() {
|
||||||
|
const router = Router()
|
||||||
|
router.get('/pruefen', async (req, res) => {
|
||||||
|
res.set('Cache-Control', 'no-store')
|
||||||
|
try {
|
||||||
|
const result = await checkShare(req.query.subdomain, req.get('x-vorschau-token'))
|
||||||
|
return res.status(result.allowed ? 200 : 403).json({ allowed: result.allowed, reason: result.reason })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[vorschau-link] Pruefung fehlgeschlagen:', err.message)
|
||||||
|
return res.status(500).json({ allowed: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return router
|
||||||
|
}
|
||||||
@@ -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)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/**
|
||||||
|
* Geheimer Vorschau-Link (2026-10-26): Wer den Link kennt, sieht die Preview-Seite eines Projekts
|
||||||
|
* ohne Login. Der Link ist nirgends verlinkt, nur Mitarbeiter sehen ihn im Ticket.
|
||||||
|
*
|
||||||
|
* Ablauf:
|
||||||
|
* 1. WOMS erzeugt den Link (POST /api/admin/website-projects/:id/vorschau-link) -> websiteProjects.shareToken
|
||||||
|
* 2. Besucher oeffnet https://project.webklar.com/vorschau/<subdomain>/<token>
|
||||||
|
* -> Cookie wk_vorschau_<subdomain>=<token> fuer .project.webklar.com, Weiterleitung auf die Subdomain
|
||||||
|
* 3. Die Login-Pruefung der Previews (preview-portal /auth/verify) fragt bei jedem Aufruf
|
||||||
|
* GET /api/vorschau-link/pruefen?subdomain=... mit dem Cookie-Wert im Header x-vorschau-token
|
||||||
|
*
|
||||||
|
* Ein neuer Link oder "Abschalten" macht alte Links und Cookies sofort ungueltig (nach hoechstens CACHE_MS).
|
||||||
|
*/
|
||||||
|
import crypto from 'node:crypto'
|
||||||
|
import { config } from '../config.js'
|
||||||
|
import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||||
|
import { isPreviewProjectReady } from './previewReadiness.js'
|
||||||
|
|
||||||
|
export const COOKIE_PREFIX = 'wk_vorschau_'
|
||||||
|
export const COOKIE_MAX_AGE_SEC = 30 * 24 * 60 * 60
|
||||||
|
const CACHE_MS = 15 * 1000
|
||||||
|
const SUBDOMAIN = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/
|
||||||
|
const TOKEN = /^[A-Za-z0-9_-]{20,64}$/
|
||||||
|
|
||||||
|
export function newShareToken() {
|
||||||
|
return crypto.randomBytes(18).toString('base64url')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanSubdomain(value) {
|
||||||
|
const s = String(value || '').trim().toLowerCase()
|
||||||
|
return SUBDOMAIN.test(s) ? s : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cleanToken(value) {
|
||||||
|
const t = String(value || '').trim()
|
||||||
|
return TOKEN.test(t) ? t : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cookieName(subdomain) {
|
||||||
|
return `${COOKIE_PREFIX}${subdomain}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shareUrl(subdomain, token) {
|
||||||
|
return `${config.publicBaseUrl}/vorschau/${subdomain}/${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previewUrl(subdomain) {
|
||||||
|
return `https://${subdomain}.${config.preview.baseHost}/`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shareCookieOptions() {
|
||||||
|
return {
|
||||||
|
domain: config.preview.cookieDomain,
|
||||||
|
path: '/',
|
||||||
|
httpOnly: true,
|
||||||
|
secure: process.env.NODE_ENV === 'production',
|
||||||
|
sameSite: 'lax',
|
||||||
|
maxAge: COOKIE_MAX_AGE_SEC * 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameToken(a, b) {
|
||||||
|
const x = Buffer.from(String(a))
|
||||||
|
const y = Buffer.from(String(b))
|
||||||
|
return x.length === y.length && crypto.timingSafeEqual(x, y)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Die Pruefung laeuft bei jedem Bild/Skript der Seite: kurz merken statt jedes Mal Appwrite fragen
|
||||||
|
const cache = new Map()
|
||||||
|
|
||||||
|
export function clearShareCache() {
|
||||||
|
cache.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function projectBySubdomain(subdomain) {
|
||||||
|
const hit = cache.get(subdomain)
|
||||||
|
if (hit && hit.until > Date.now()) return hit.project
|
||||||
|
const docs = await listDocuments(config.collections.websiteProjects, [
|
||||||
|
Query.equal('subdomain', subdomain),
|
||||||
|
Query.limit(1),
|
||||||
|
])
|
||||||
|
const project = docs[0] || null
|
||||||
|
cache.set(subdomain, { project, until: Date.now() + CACHE_MS })
|
||||||
|
if (cache.size > 500) cache.delete(cache.keys().next().value)
|
||||||
|
return project
|
||||||
|
}
|
||||||
|
|
||||||
|
/** { allowed, reason } - reason: bad_request | no_link | not_ready */
|
||||||
|
export async function checkShare(subdomainRaw, tokenRaw) {
|
||||||
|
const subdomain = cleanSubdomain(subdomainRaw)
|
||||||
|
const token = cleanToken(tokenRaw)
|
||||||
|
if (!subdomain || !token) return { allowed: false, reason: 'bad_request' }
|
||||||
|
const project = await projectBySubdomain(subdomain)
|
||||||
|
if (!project || project.archived || !project.shareToken || !sameToken(project.shareToken, token)) {
|
||||||
|
return { allowed: false, reason: 'no_link' }
|
||||||
|
}
|
||||||
|
if (!isPreviewProjectReady(project)) return { allowed: false, reason: 'not_ready' }
|
||||||
|
return { allowed: true, subdomain }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Neuer Link (ersetzt einen alten). Wirft 404/409 mit status. */
|
||||||
|
export async function renewShareLink(projectId) {
|
||||||
|
const project = await getDocument(config.collections.websiteProjects, projectId).catch(() => null)
|
||||||
|
if (!project) throw Object.assign(new Error('Projekt nicht gefunden'), { status: 404 })
|
||||||
|
const subdomain = cleanSubdomain(project.subdomain)
|
||||||
|
if (!subdomain) throw Object.assign(new Error('Das Projekt hat keine Preview-Subdomain.'), { status: 409 })
|
||||||
|
const token = newShareToken()
|
||||||
|
await updateDocument(config.collections.websiteProjects, projectId, { shareToken: token })
|
||||||
|
clearShareCache()
|
||||||
|
return { shareToken: token, url: shareUrl(subdomain, token) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeShareLink(projectId) {
|
||||||
|
const project = await getDocument(config.collections.websiteProjects, projectId).catch(() => null)
|
||||||
|
if (!project) throw Object.assign(new Error('Projekt nicht gefunden'), { status: 404 })
|
||||||
|
await updateDocument(config.collections.websiteProjects, projectId, { shareToken: '' })
|
||||||
|
clearShareCache()
|
||||||
|
return { shareToken: '' }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -76,3 +76,14 @@ test('Banner-Archiv: Text und aria-label wortgleich zu core.js, README nennt BAN
|
|||||||
readFileSync(root('runner/src/templates/datenschutz-analyse.html'))
|
readFileSync(root('runner/src/templates/datenschutz-analyse.html'))
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('Banner-Archiv eigene Website: Sie-Fassung wortgleich zu core.js, README nennt BANNER_VERSION_EIGEN', () => {
|
||||||
|
const v = core.BANNER_VERSION_EIGEN
|
||||||
|
const md = readFileSync(root(`docs/rechtliches/archiv/banner-eigen-${v}.md`), 'utf8')
|
||||||
|
assert.ok(md.includes(core.BANNER_TEXT_EIGEN))
|
||||||
|
assert.ok(md.includes(`BANNER_VERSION_EIGEN\`: ${v}`))
|
||||||
|
const readme = readFileSync(root('docs/rechtliches/archiv/README.md'), 'utf8')
|
||||||
|
assert.ok(readme.includes('BANNER_VERSION_EIGEN') && readme.includes(`datenschutz-analyse-webklar-com-${v}.html`))
|
||||||
|
const seite = readFileSync(root(`docs/rechtliches/archiv/datenschutz-analyse-webklar-com-${v}.html`), 'utf8')
|
||||||
|
assert.ok(seite.includes(`Stand ${v.slice(8, 10)}.${v.slice(5, 7)}.${v.slice(0, 4)}`), 'Stand der Zusatzseite = Fassung')
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { test } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
|
|
||||||
|
// Hochladen der Datenschutzerklärung nach WOMS "Rechtliches" (scripts/datenschutz-hochladen.mjs): die Tests rufen main nur mit Nachbau-deps
|
||||||
|
// auf, nie mit dem echten Schlüssel oder dem Netz. Das globale fetch wirft und zählt, damit ein Weg am Nachbau vorbei auffällt.
|
||||||
|
let netzzugriffe = 0
|
||||||
|
globalThis.fetch = () => {
|
||||||
|
netzzugriffe += 1
|
||||||
|
throw new Error('Netzzugriff im Test')
|
||||||
|
}
|
||||||
|
const m = await import('../scripts/datenschutz-hochladen.mjs') // der Import liest weder Schlüssel noch Netz
|
||||||
|
|
||||||
|
const frueher = ['v1', 'v2', 'v3', 'v4', 'v5'].map((v, i) => ({ $id: `s${i}`, version: v, validFrom: `2026-09-0${i + 1}`, status: 'superseded', source: `x${i}` }))
|
||||||
|
const AKTUELL = { $id: 'alt', version: 'v6', validFrom: '2026-10-05', status: 'current', source: 'kundenbereich@aaaaaaa:public/datenschutz.html' }
|
||||||
|
// stand = das Datum, das im Nachbau der HTML-Datei hinter "Stand" steht (die PDF-Datei ist beliebiger Inhalt)
|
||||||
|
// zusatz = zusätzlicher Inhalt der HTML (zum Beispiel eine offene Markierung); summe = Inhalt der .sha256 (null = passend zur HTML, 'fehlt' = nicht lesbar)
|
||||||
|
function backend({ versionen = [...frueher, AKTUELL], karten = [{ $id: 'datenschutz', key: 'datenschutz' }], gitStatus = '', stand = '20.10.2026', vorhanden = () => true, zusatz = '', summe = null } = {}) {
|
||||||
|
const aufrufe = [], log = []
|
||||||
|
const antwort = (obj) => ({ ok: true, status: 200, text: async () => JSON.stringify(obj) })
|
||||||
|
const fetch = async (url, opt = {}) => {
|
||||||
|
const methode = opt.method ?? 'GET', pfad = new URL(url).pathname.replace(/^\/v1/, '')
|
||||||
|
aufrufe.push({ methode, pfad, body: opt.body })
|
||||||
|
if (methode === 'GET' && pfad.endsWith('/collections/legalDocuments/documents')) return antwort({ total: karten.length, documents: karten })
|
||||||
|
if (methode === 'GET' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ total: versionen.length, documents: versionen })
|
||||||
|
if (methode === 'GET' && pfad.startsWith('/storage/buckets/legal-files/files')) return antwort({ total: 0, files: [] })
|
||||||
|
if (methode === 'POST' && pfad === '/storage/buckets/legal-files/files') return antwort({ $id: `f${aufrufe.length}` })
|
||||||
|
if (methode === 'POST' && pfad.endsWith('/collections/legalVersions/documents')) return antwort({ $id: 'v-neu' })
|
||||||
|
if (methode === 'PATCH' && pfad.includes('/collections/legalVersions/documents/')) return antwort({})
|
||||||
|
throw new Error(`unerwartet: ${methode} ${pfad}`)
|
||||||
|
}
|
||||||
|
const html = () => Buffer.from((stand === null ? '<p class="meta">ohne Datum</p>' : `<p class="meta">Stand ${stand}</p>`) + zusatz)
|
||||||
|
const pruefsumme = () => {
|
||||||
|
if (summe === 'fehlt') throw new Error('ENOENT')
|
||||||
|
return Buffer.from(`${summe ?? createHash('sha256').update(html()).digest('hex')}\n`)
|
||||||
|
}
|
||||||
|
return { aufrufe, log, deps: { fetch, schluessel: () => 'test-schluessel', log: (z) => log.push(z), git: (args) => (args[0] === 'status' ? gitStatus : 'abc1234\n'), dateien: { existsSync: vorhanden, readFileSync: (rel) => (rel.endsWith('.html') ? html() : rel.endsWith('.sha256') ? pruefsumme() : Buffer.from('x')) } } }
|
||||||
|
}
|
||||||
|
const schreibend = (b) => b.aufrufe.filter((a) => a.methode !== 'GET')
|
||||||
|
|
||||||
|
test('parseArgs: --probe allein oder --freigabe=Tag, sonst Aufruf-Fehler', () => {
|
||||||
|
assert.deepEqual(m.parseArgs(['--probe']), { modus: 'probe', freigabe: null })
|
||||||
|
assert.deepEqual(m.parseArgs(['--freigabe=2026-10-20']), { modus: 'hochladen', freigabe: '2026-10-20' })
|
||||||
|
for (const bad of [[], ['--freigabe=20.10.2026'], ['--freigabe=2026-13-40'], ['--probe', '--freigabe=2026-10-20'], ['datenschutz']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/)
|
||||||
|
for (const bad of [['--freigabe=2026-10-20', '--freigabe=2026-10-21'], ['--probe', '--probe'], ['--freigabe=2026-10-20', '--karten-anlegen'], ['datenschutz', '--freigabe=2026-10-20']]) assert.throws(() => m.parseArgs(bad), /Aufruf|Freigabe/, JSON.stringify(bad))
|
||||||
|
})
|
||||||
|
test('Hochladen: Dateien zuerst (PDF, dann HTML), dann Version v7, dann die bisherige ablösen', async () => {
|
||||||
|
const b = backend()
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
|
||||||
|
assert.deepEqual(schreibend(b).map((a) => `${a.methode} ${a.pfad.split('/').slice(-1)[0]}`), ['POST files', 'POST files', 'POST documents', 'PATCH alt'])
|
||||||
|
const neu = JSON.parse(schreibend(b)[2].body).data
|
||||||
|
assert.deepEqual([neu.documentKey, neu.version, neu.validFrom, neu.status, neu.source, neu.fileIds.length], ['datenschutz', 'v7', '2026-10-20', 'current', 'kundenbereich@abc1234:public/datenschutz.html', 2])
|
||||||
|
assert.equal(neu.changeNote, 'Fassung vom 20.10.2026: Abschnitt 11 Interessenten (Antworten auf öffentliche Anfragen)')
|
||||||
|
assert.deepEqual(JSON.parse(schreibend(b)[3].body).data, { status: 'superseded', validTo: '2026-10-19' })
|
||||||
|
})
|
||||||
|
test('Hochladen: die beiden Dateien sind die PDF und die HTML der Datenschutzerklärung, mit Dateiname und Typ', async () => {
|
||||||
|
const b = backend()
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
|
||||||
|
const dateien = schreibend(b).slice(0, 2).map((a) => a.body.get('file'))
|
||||||
|
assert.deepEqual(dateien.map((f) => [f.name, f.type]), [['WEBklar-Datenschutzerklaerung.pdf', 'application/pdf'], ['datenschutz.html', 'text/html']])
|
||||||
|
assert.equal(await dateien[1].text(), '<p class="meta">Stand 20.10.2026</p>')
|
||||||
|
})
|
||||||
|
test('Changenote nennt weder Workers noch Hustler (sie kann für Kunden sichtbar werden)', async () => {
|
||||||
|
const b = backend()
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], b.deps), 0)
|
||||||
|
assert.doesNotMatch(JSON.parse(schreibend(b)[2].body).data.changeNote, /workers|hustler/i)
|
||||||
|
})
|
||||||
|
test('schon hochgeladen, fehlende Karte, nicht eingecheckter Stand und --probe schreiben nichts', async () => {
|
||||||
|
const schon = backend({ versionen: [{ ...AKTUELL, source: 'kundenbereich@abc1234:public/datenschutz.html' }] })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], schon.deps), 0)
|
||||||
|
const ohneKarte = backend({ karten: [] })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
|
||||||
|
const schmutzig = backend({ gitStatus: ' M public/datenschutz.html\n' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], schmutzig.deps), 1)
|
||||||
|
const probe = backend()
|
||||||
|
assert.equal(await m.main(['--probe'], probe.deps), 0)
|
||||||
|
for (const b of [schon, ohneKarte, schmutzig, probe]) assert.equal(schreibend(b).length, 0)
|
||||||
|
})
|
||||||
|
test('Fehlende Karte und fehlende Datei brechen mit Meldung ab, bevor etwas geschrieben wird', async () => {
|
||||||
|
const ohneKarte = backend({ karten: [] })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneKarte.deps), 1)
|
||||||
|
assert.ok(ohneKarte.log.some((z) => z.includes('Karte fehlt: datenschutz')))
|
||||||
|
assert.ok(!ohneKarte.log.some((z) => z.includes('--karten-anlegen')), 'dieses Skript legt keine Karten an')
|
||||||
|
const ohnePdf = backend({ vorhanden: (rel) => !rel.endsWith('.pdf') })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], ohnePdf.deps), 1)
|
||||||
|
assert.ok(ohnePdf.log.some((z) => z.includes('Datei fehlt: docs/WEBklar-Datenschutzerklaerung.pdf')))
|
||||||
|
assert.equal(schreibend(ohneKarte).length + schreibend(ohnePdf).length, 0)
|
||||||
|
})
|
||||||
|
test('Stand in der HTML muss der Freigabe entsprechen, sonst Abbruch vor jedem Schreiben', async () => {
|
||||||
|
const falsch = backend({ stand: '17.10.2026' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], falsch.deps), 1)
|
||||||
|
assert.ok(falsch.log.some((z) => z.includes('Stand 17.10.2026') && z.includes('20.10.2026')), falsch.log.join(' | '))
|
||||||
|
const ohneStand = backend({ stand: null })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], ohneStand.deps), 1)
|
||||||
|
assert.ok(ohneStand.log.some((z) => z.includes('Stand')), ohneStand.log.join(' | '))
|
||||||
|
for (const b of [falsch, ohneStand]) assert.equal(schreibend(b).length, 0)
|
||||||
|
const richtig = backend({ stand: '21.10.2026' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-21'], richtig.deps), 0, 'mit passender Freigabe geht derselbe Stand durch')
|
||||||
|
})
|
||||||
|
test('offene Markierung im Text: Abbruch vor jedem Schreiben, mit Nennung der Markierung; Kommentare zählen nicht', async () => {
|
||||||
|
const offen = backend({ zusatz: '<p>bei <span class="offen">[OFFEN: Anbieter Alarm-Postfach]</span></p>' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], offen.deps), 1)
|
||||||
|
assert.ok(offen.log.some((z) => z.includes('offene Markierung') && z.includes('[OFFEN: Anbieter Alarm-Postfach]')), offen.log.join(' | '))
|
||||||
|
const zweiMal = backend({ zusatz: '<p>[OFFEN: Aufbewahrung der Server-Sicherungen] und noch einmal [OFFEN: Aufbewahrung der Server-Sicherungen]</p>' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], zweiMal.deps), 1)
|
||||||
|
assert.equal(zweiMal.log.filter((z) => z.includes('offene Markierung'))[0].match(/Aufbewahrung der Server-Sicherungen/g).length, 1, 'jede Markierung nur einmal genannt')
|
||||||
|
for (const b of [offen, zweiMal]) assert.equal(schreibend(b).length, 0)
|
||||||
|
const kommentar = backend({ zusatz: '<!-- [OFFEN: nur eine Notiz im Quelltext] -->' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], kommentar.deps), 0, 'eine Markierung im HTML-Kommentar ist nicht sichtbar')
|
||||||
|
})
|
||||||
|
test('Prüfsumme neben dem PDF muss zur HTML passen, sonst Abbruch vor jedem Schreiben (veraltetes PDF)', async () => {
|
||||||
|
const veraltet = backend({ summe: 'a'.repeat(64) })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], veraltet.deps), 1)
|
||||||
|
assert.ok(veraltet.log.some((z) => z.includes('das PDF ist nicht aus dieser HTML gedruckt') && z.includes('build-legal-pdfs.sh datenschutz')), veraltet.log.join(' | '))
|
||||||
|
const fehlt = backend({ summe: 'fehlt' })
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], fehlt.deps), 1)
|
||||||
|
assert.ok(fehlt.log.some((z) => z.includes('WEBklar-Datenschutzerklaerung.pdf.sha256') && z.includes('fehlt')), fehlt.log.join(' | '))
|
||||||
|
for (const b of [veraltet, fehlt]) assert.equal(schreibend(b).length, 0)
|
||||||
|
// passende Prüfsumme (Standard im Nachbau): der Upload geht durch
|
||||||
|
const passt = backend()
|
||||||
|
assert.equal(await m.main(['--freigabe=2026-10-20'], passt.deps), 0)
|
||||||
|
assert.equal(schreibend(passt).length, 4)
|
||||||
|
})
|
||||||
|
test('--probe liest nur: Bucket, Karte und Versionen, meldet eine fehlende Karte als Stopp', async () => {
|
||||||
|
const b = backend()
|
||||||
|
assert.equal(await m.main(['--probe'], b.deps), 0)
|
||||||
|
assert.ok(b.log.includes('datenschutz: v1:superseded v2:superseded v3:superseded v4:superseded v5:superseded v6:current'), b.log.join(' | '))
|
||||||
|
assert.deepEqual(b.aufrufe.map((a) => a.pfad.replace(/^\/databases\/woms-database/, '')), ['/storage/buckets/legal-files/files', '/collections/legalDocuments/documents', '/collections/legalVersions/documents'])
|
||||||
|
const ohneKarte = backend({ karten: [] })
|
||||||
|
assert.equal(await m.main(['--probe'], ohneKarte.deps), 1)
|
||||||
|
assert.ok(ohneKarte.log.includes('STOPP: Karte fehlt: datenschutz'))
|
||||||
|
})
|
||||||
|
test('ungültige Aufrufe enden mit Exit 1 und Hinweis, ohne ein einziges Netz-Zeichen', async () => {
|
||||||
|
const b = backend()
|
||||||
|
assert.equal(await m.main([], b.deps), 1)
|
||||||
|
assert.match(b.log[0], /Aufruf/)
|
||||||
|
assert.equal(b.aufrufe.length, 0)
|
||||||
|
})
|
||||||
|
test('kein Test hat das echte fetch erreicht', () => {
|
||||||
|
assert.equal(netzzugriffe, 0)
|
||||||
|
})
|
||||||
@@ -39,5 +39,5 @@ test('Datenschutz-Prüfpunkte D1 bis D8 mit gültiger Fundstelle in public/daten
|
|||||||
const zeilen = lies('public/datenschutz.html').split('\n')
|
const zeilen = lies('public/datenschutz.html').split('\n')
|
||||||
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
|
const refs = [...dsg.matchAll(/public\/datenschutz\.html:(\d+)/g)].map((m) => Number(m[1]))
|
||||||
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
|
assert.ok(refs.length >= 8 && refs.every((n) => n >= 1 && n <= zeilen.length))
|
||||||
assert.ok(zeilen[119].includes('Zahlungspflichtig bestellen') && zeilen[118].includes('Leistungsbeginn') && zeilen[94].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
|
assert.ok(zeilen[126].includes('Zahlungspflichtig bestellen') && zeilen[125].includes('Leistungsbeginn') && zeilen[101].includes('Wartungs-Abo'), 'D1, D2, D5 zitieren die richtigen Zeilen')
|
||||||
})
|
})
|
||||||
+178
-3
@@ -558,7 +558,8 @@ test('legal_datenschutz_kundenansicht: Abschnitt 6 nennt Kundenansicht, Sperren,
|
|||||||
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
|
const terms = /^(\d{4})-(\d{2})-(\d{2})-v\d+$/.exec(config.abo.termsVersion)
|
||||||
assert.ok(terms)
|
assert.ok(terms)
|
||||||
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
|
assert.ok(standTag >= `${terms[1]}-${terms[2]}-${terms[3]}`, `Stand ${stand[0]} liegt vor termsVersion ${config.abo.termsVersion}`)
|
||||||
assert.ok(html.includes('<p class="meta">Stand 07.10.2026</p>'), 'Stand = Tag der Änderung (Abschnitt 6f Buchungskalender)')
|
// Stand = Tag der letzten Änderung der Erklärung; seit dem 17.10.2026 (Abschnitt 4a Reichweitenmessung auf webklar.com) nicht davor (der genaue Tag ist in legal_datenschutz_anfragen gepinnt)
|
||||||
|
assert.ok(html.includes(`<p class="meta">${stand[0]}</p>`) && standTag >= '2026-10-17', `Stand ${stand[0]} liegt vor der Reichweitenmessung (17.10.2026)`)
|
||||||
})
|
})
|
||||||
|
|
||||||
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
|
// ---------------------------------------------------------------- Abschnitt 6c und Funktionsbestellung (TP2 F12a)
|
||||||
@@ -603,8 +604,8 @@ test('legal_datenschutz_termine: Abschnitt 6c steht zwischen 6b und 7 und nennt
|
|||||||
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
|
const titel = [...html.matchAll(/<h2>(\d+)\. ([^<]*)<\/h2>/g)].map((m) => `${m[1]}. ${m[2]}`)
|
||||||
assert.deepEqual(titel, [
|
assert.deepEqual(titel, [
|
||||||
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
|
'1. Verantwortlicher', '2. Geltungsbereich', '3. Hosting, Server-Protokolle und Überwachung der Erreichbarkeit', '4. Cookies und Speicher im Browser', '5. Kontaktaufnahme', '6. Kundenportal',
|
||||||
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten', '12. Empfänger im Überblick', '13. Ihre Rechte',
|
'7. Abos und Zahlung über Stripe', '8. E-Mail', '9. Buchhaltung und Rechnungen', '10. Domains unserer Kunden', '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick', '13. Ihre Rechte',
|
||||||
'14. Pflicht zur Bereitstellung, keine automatisierten Entscheidungen', '15. Änderungen',
|
'14. Pflicht zur Bereitstellung, keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO', '15. Änderungen',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -708,3 +709,177 @@ test('datenschutz_pdf: das PDF der Datenschutzerklärung ist eingecheckt (> 20 K
|
|||||||
const skript = read('scripts/build-legal-pdfs.sh')
|
const skript = read('scripts/build-legal-pdfs.sh')
|
||||||
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
|
assert.match(skript, /\ndatenschutz\)[\s\S]*\n\s+pdf public\/datenschutz\.html docs\/WEBklar-Datenschutzerklaerung\.pdf\n[\s\S]*?;;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Reichweitenmessung auf webklar.com (eigene Website-Analyse)
|
||||||
|
|
||||||
|
test('legal_datenschutz_4a: eigene Website-Analyse auf webklar.com mit Rolle, Rechtsgrundlagen, Fristen und Zusatzseite', () => {
|
||||||
|
const html = read(DS)
|
||||||
|
const ds = strip(html)
|
||||||
|
// Der frühere Satz "keine Analyse-, Tracking- oder Marketing-Werkzeuge" gilt nur noch mit Ausnahme der eigenen Messung
|
||||||
|
assert.ok(ds.includes('Auf webklar.com messen wir mit unserer eigenen Website-Analyse, wie die Seite genutzt wird (Abschnitt 4a). Darüber hinaus setzen wir auf unseren eigenen Seiten'))
|
||||||
|
assert.ok(!ds.includes('Derzeit stützen wir keine Verarbeitung auf eine Einwilligung.'))
|
||||||
|
assert.ok(ds.includes('Auf eine Einwilligung stützen wir derzeit nur die erweiterte Reichweitenmessung auf webklar.com (Abschnitt 4a).'))
|
||||||
|
assert.ok(html.includes('<tr><td>wk-consent (lokaler Speicher, webklar.com)</td>'))
|
||||||
|
// 4a steht zwischen 4 und 5
|
||||||
|
const i4 = html.indexOf('<h2>4. Cookies und Speicher im Browser</h2>')
|
||||||
|
const i4a = html.indexOf('<h2 id="reichweitenmessung">4a. Reichweitenmessung auf webklar.com</h2>')
|
||||||
|
const i5 = html.indexOf('<h2>5. Kontaktaufnahme</h2>')
|
||||||
|
assert.ok(i4 > 0 && i4 < i4a && i4a < i5, 'Reihenfolge 4, 4a, 5')
|
||||||
|
const block = strip(html.slice(i4a, i5))
|
||||||
|
for (const s of ['für webklar.com sind wir selbst Verantwortlicher', 'Hetzner Online GmbH in Deutschland', 'ohne Cookies',
|
||||||
|
'Art. 6 Abs. 1 lit. f DSGVO', '§ 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO', 'Global Privacy Control', 'Keks-Symbol',
|
||||||
|
'nach 30 Tagen', 'nach 25 Monaten', 'täglich wechselt', 'webklar.com/datenschutz-analyse.html']) assert.ok(block.includes(s), s)
|
||||||
|
assert.ok(html.includes('<a href="https://webklar.com/datenschutz-analyse.html">'))
|
||||||
|
// Kontaktformular: seit 17.10.2026 auch per Mail an das eigene Postfach
|
||||||
|
assert.ok(ds.includes('und schicken sie zugleich per E-Mail an unser Postfach (Abschnitt 8)'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- Abschnitt 11 Interessenten, Weg B (nur der heute laufende Weg: öffentliche Anfragen)
|
||||||
|
|
||||||
|
test('legal_datenschutz_anfragen: Abschnitt 11 beschreibt nur den Anfrage-Weg (KI bewertet, Mitarbeiter sendet von Hand), mit Anker, Quelle, Daten, Rechtsgrundlage, Fristen und Widerspruch', () => {
|
||||||
|
const html = read(DS)
|
||||||
|
// Stand-Pin: der Tag der Fassung (das endgültige Datum setzt der Push, dann auch hier)
|
||||||
|
assert.ok(html.includes('<p class="meta">Stand 22.10.2026</p>'), 'Stand 22.10.2026')
|
||||||
|
assert.match(html, /<a id="interessenten"><\/a>\s*<h2>11\. Interessenten: Antworten auf öffentliche Anfragen<\/h2>/)
|
||||||
|
assert.equal(html.split('id="interessenten"').length - 1, 1, 'der Anker steht genau einmal da')
|
||||||
|
const s = between(strip(html), '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||||||
|
for (const t of ['Art. 14 DSGVO', 'KI-Assistent', 'Claude von Anthropic', 'von Hand', 'Automatische E-Mails verschicken wir nicht', 'Für E-Mails gilt Abschnitt 8',
|
||||||
|
// Quellen: Alarm-E-Mails und Websuche; Plattformen mit Bot-Verbot ausgenommen
|
||||||
|
'Benachrichtigungs-E-Mails', 'Kleinanzeigen, freelancermap und freelance.de', 'Websuche', 'Frage-Foren', 'Die Suche und den Abruf der Seiten führt Anthropic aus',
|
||||||
|
// feste Ausschlussliste (so wie BOT_VERBOT_DOMAINS im Dienst) statt eines Kriteriums
|
||||||
|
'Von der Websuche und vom Abruf ausgenommen sind die Plattformen Kleinanzeigen, freelancermap, freelance.de, Facebook, Instagram, LinkedIn, Xing, Reddit, Upwork, Fiverr und MyHammer sowie Seiten, deren Betreiber automatische Abrufe in der Datei robots.txt untersagen',
|
||||||
|
'nicht nach bestimmten Personen',
|
||||||
|
// Daten
|
||||||
|
'Titel des Beitrags', 'Beitragstext (höchstens 4000 Zeichen)', 'den Link', 'Ort und Postleitzahl', 'die Plattform', 'Punktzahl mit kurzer Begründung', 'E-Mail-Adresse aus dem Beitrag',
|
||||||
|
'Wir lesen Ihren Namen nicht gesondert aus; steht er im Titel oder im Text, speichern wir ihn damit', 'Telefonnummern entfernen wir beim Speichern automatisch', 'Faustregel',
|
||||||
|
// Rechtsgrundlage, Empfänger
|
||||||
|
'Art. 6 Abs. 1 lit. f DSGVO', 'Hetzner Online GmbH', 'Anthropic Ireland, Limited', 'als Auftragsverarbeiter', 'USA', 'Standardvertragsklauseln (Art. 46 Abs. 2 lit. c DSGVO); eine Kopie erhalten Sie bei uns (support@webklar.com)',
|
||||||
|
// keine automatisierte Einzelentscheidung: die KI sortiert aus, ein Mitarbeiter entscheidet über jede Antwort
|
||||||
|
'Die KI sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht', 'Ob und was wir einer passenden Anfrage antworten, entscheidet ein Mitarbeiter (Abschnitt 14)',
|
||||||
|
// Hinweis am Ende unserer Antwort (der Dienst setzt ihn in Claras Entwurf, src/anfragen/clara.js; die Sätze hier stehen dort wörtlich, test/clara.test.js).
|
||||||
|
// Ein Mitarbeiter kann den Entwurf bearbeiten: zugesagt ist deshalb nur die Arbeitsanweisung, den Hinweis stehen zu lassen.
|
||||||
|
'Am Ende unserer Antwort steht ein Hinweis', 'dass eine KI-Assistentin von WEBklar antwortet', 'dass wir die Angaben aus Ihrer Anfrage spätestens 90 Tage nach dem Tag, an dem wir Ihre Anfrage gefunden haben, in unserer Datenbank kürzen, das heißt, Titel, Text, Link und E-Mail-Adresse löschen',
|
||||||
|
'Wenn Sie keine weitere Nachricht von uns möchten oder der Speicherung widersprechen, antworten Sie uns bitte kurz',
|
||||||
|
'schreiben wir Ihnen zu dieser Anfrage nicht mehr und kürzen Ihre Daten (bei einem Widerspruch sofort, sonst innerhalb von 30 Tagen)',
|
||||||
|
'Unsere Mitarbeiter sind angewiesen, diesen Hinweis in jede Antwort aufzunehmen, auch in eine selbst geschriebene, und beim Bearbeiten eines Entwurfs nicht zu entfernen',
|
||||||
|
// Fristen und Reste: aussortierte Anfragen spätestens am Tag nach dem Fund (Entscheidung E2 vom 22.10.2026, Regel C im Dienst: erster täglicher Kürzlauf nach dem Berliner Kalendertag des Fundes),
|
||||||
|
// 30 Tage nur noch nach einer Absage, Absage und Widerspruch erst ab dem Lesen unserer Seite
|
||||||
|
'spätestens 90 Tage nach dem Fund', 'Passt Ihre Anfrage nach der Bewertung der KI nicht zu uns, schreiben wir Ihnen nicht und kürzen die Daten spätestens am Tag nach dem Fund',
|
||||||
|
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben', 'Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen', 'pseudonymisierte Prüfwerte (Hash-Werte)', 'Ort, Plattform, Punktzahl, Status',
|
||||||
|
'jedoch ohne Titel, Text, Link und Kontaktdaten', 'bereinigen die zugehörigen Einträge im Protokoll', 'Abschnitt 5 und, wenn ein Vertrag zustande kommt, die Abschnitte 7 und 9',
|
||||||
|
// Dauer der Reste (Art. 14 Abs. 2 lit. a: Kriterium)
|
||||||
|
'so lange auf, wie wir verhindern müssen, dass wir Sie wegen desselben Beitrags oder derselben E-Mail-Adresse (bei einer Firmenadresse: derselben Firma) erneut ansprechen',
|
||||||
|
// Kopien: Alarm-Postfach = support@webklar.com bei Hetzner (Abschnitt 8), Löschung der Plattform-Mails 90 Tage nach Eingang, Datensicherungen wie im übrigen Text
|
||||||
|
'gehen in unserem Postfach support@webklar.com bei der Hetzner Online GmbH ein (Abschnitt 8)', 'Unser Dienst liest dort nur den Posteingang',
|
||||||
|
// ehrlich: er lädt jede neue Mail des gemeinsamen Postfachs kurz (zur Prüfung des Absenders), gespeichert wird nur, was von einer Plattform kommt
|
||||||
|
'Er lädt dazu jede neue E-Mail dieses Postfachs kurz, um den Absender zu prüfen; übernommen und gespeichert werden nur die Benachrichtigungen der Plattformen, alle anderen E-Mails verwirft er sofort',
|
||||||
|
'Die Benachrichtigungs-E-Mails der Plattformen löschen wir im Postfach spätestens 90 Tage nach dem Eingang',
|
||||||
|
'Kürzen wir Daten, bleiben sie in unseren Datensicherungen noch bis zu sieben Tage erhalten und werden dann überschrieben', 'Geschäftsbrief',
|
||||||
|
// Widerspruch
|
||||||
|
'Ihr Widerspruchsrecht gegen diese Verarbeitung (Art. 21 DSGVO)', 'Antworten Sie dazu auf unsere Nachricht oder schreiben Sie an support@webklar.com',
|
||||||
|
'Sobald wir Ihre Nachricht lesen, sperren wir den Beitrag und, soweit wir sie gespeichert haben, Ihre E-Mail-Adresse',
|
||||||
|
'Wir erkennen Sie dabei nur am Beitrag, an Ihrer E-Mail-Adresse und, bei einer Firmenadresse, an deren Domain', 'Ein neues Gesuch, das Sie später einstellen, ist für uns ein neuer Beitrag, wenn es keine dieser Angaben enthält; sagen Sie uns dann bitte noch einmal Bescheid',
|
||||||
|
// Prüfwerte auch der Firmen-Domain, Sperre trifft bei einer Firmenadresse die ganze Firma (Register und widerspruch.mjs im Dienst: keys.js, suppress)
|
||||||
|
'der Domain dieser Adresse, das ist der Teil hinter dem @, zum Beispiel firma.de', 'also nicht bei Anbietern wie gmail.com oder web.de',
|
||||||
|
'eine andere Adresse derselben Firma noch einmal auftaucht; wir schreiben dann nicht noch einmal und nehmen den Beitrag nicht noch einmal auf',
|
||||||
|
'Ist es eine Firmenadresse und nicht die eines Anbieters wie gmail.com oder web.de, sperren wir auch deren Domain, also auch andere Adressen derselben Firma',
|
||||||
|
// Zweck: warum Ort, Plattform, Punktzahl, Status und Zeitpunkte bleiben (der frühere Zweck "Mitarbeiter sehen nach, ob die KI zu Unrecht aussortiert hat" entfällt mit E2)
|
||||||
|
'Ort, Plattform, Punktzahl, Status und die Zeitpunkte behalten wir für unsere Auswertung, damit unsere Zähler und der zeitliche Verlauf stimmen',
|
||||||
|
// Foren (Entscheidung E4 vom 22.10.2026): im Forum antworten wir nicht; kein Entwurf; ein Mitarbeiter meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich darum bittet (mit demselben Hinweis).
|
||||||
|
// Im Dienst: src/anfragen/foren.js, FOREN_ANWEISUNG "Forenbeitrag: nicht im Forum antworten. Nur per E-Mail melden, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; sonst Erledigt."
|
||||||
|
'Eine Ausnahme sind Frage-Foren: Dort antworten wir nicht im Forum (siehe unten)',
|
||||||
|
'und entwirft eine Antwort (für Beiträge aus Frage-Foren nicht, siehe unten)',
|
||||||
|
'Beiträge in Frage-Foren (derzeit gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org) beantworten wir nicht im Forum. Die KI entwirft dafür keine Antwort. Ein Mitarbeiter sieht den Beitrag an und meldet sich höchstens per E-Mail, wenn der Beitrag ausdrücklich um Angebote per E-Mail bittet; unsere Mitarbeiter sind angewiesen, in diese E-Mail am Ende den Hinweis aufzunehmen, der oben beschrieben ist. Bittet der Beitrag nicht darum, melden wir uns nicht',
|
||||||
|
// die Antwort bei der Plattform: ohne den Satz über öffentlich sichtbare Antworten in Foren (dort antworten wir nicht)
|
||||||
|
'Die Antwort, die wir auf einer Plattform abschicken, sehen auch deren Betreiber. Dafür gelten die Datenschutzhinweise der Plattform',
|
||||||
|
// Widerspruch und Absage wirken in der Datenbank; die Plattform-Mail im Postfach bleibt bis zur Löschung nach 90 Tagen (und das steht im Kasten)
|
||||||
|
'auch wenn wir die Anfrage in der Datenbank schon früher gekürzt haben',
|
||||||
|
'Die Benachrichtigung der Plattform in unserem Postfach erfasst das Kürzen in der Datenbank nicht; wir löschen sie dort spätestens 90 Tage nach dem Eingang (siehe oben)']) assert.ok(s.includes(t), t)
|
||||||
|
// der Widerspruch steht im Kasten (box widerspruch) im Abschnitt 11
|
||||||
|
const kasten = html.slice(html.indexOf('<a id="interessenten">'), html.indexOf('<h2>12. Empfänger im Überblick</h2>'))
|
||||||
|
assert.ok(kasten.includes('<div class="box widerspruch">'))
|
||||||
|
// Verständlichkeit: "Fund" ist erklärt, bevor es zum zweiten Mal vorkommt; die Fristen stehen als Liste; kein Absatz in Abschnitt 11 ist länger als 130 Wörter; "robots.txt" ist erklärt
|
||||||
|
const fund1 = s.indexOf('nach dem Fund')
|
||||||
|
const erklaert = s.indexOf('das ist der Tag, an dem wir Ihre Anfrage gefunden haben')
|
||||||
|
assert.ok(fund1 > 0 && erklaert > fund1 && erklaert < s.indexOf('spätestens 90 Tage nach dem Fund;'), '"Fund" wird beim ersten Gebrauch erklärt')
|
||||||
|
assert.ok(/<p>Wie lange wir die Daten speichern:[^<]*<\/p>\s*<ul>(?:\s*<li>[^<]+<\/li>){4}\s*<\/ul>/.test(kasten), 'die vier Fristen stehen als Liste')
|
||||||
|
// E2: die Liste nennt in dieser Reihenfolge 90 Tage, den Tag nach dem Fund (aussortiert), 30 Tage nach dem Lesen einer Absage (nur dort 30 Tage) und den Widerspruch
|
||||||
|
const fristen = [...kasten.matchAll(/<li>([^<]+)<\/li>/g)].map((m) => m[1])
|
||||||
|
assert.deepEqual(fristen, [
|
||||||
|
'spätestens 90 Tage nach dem Fund;',
|
||||||
|
'spätestens am Tag nach dem Fund, wenn Ihre Anfrage nicht zu uns passt;',
|
||||||
|
'innerhalb von 30 Tagen, nachdem wir Ihre Antwort gelesen haben, wenn Sie uns sagen, dass Sie kein Interesse haben;',
|
||||||
|
'sofort, wenn Sie widersprechen: Wir sperren und kürzen sie, sobald wir Ihren Widerspruch lesen.'])
|
||||||
|
assert.equal(fristen.filter((f) => f.includes('30 Tagen')).length, 1, '30 Tage nur noch für die Absage')
|
||||||
|
const absaetze = [...kasten.matchAll(/<p>([\s\S]*?)<\/p>/g)].map((m) => strip(m[1]).split(' ').length)
|
||||||
|
assert.ok(absaetze.length >= 15 && Math.max(...absaetze) <= 130, `längster Absatz in Abschnitt 11: ${Math.max(...absaetze)} Wörter`)
|
||||||
|
// die Zusagen und Auskünfte, die der Dienst nicht hält oder die unerklärt Fachsprache waren
|
||||||
|
for (const t of ['verarbeitet nur diese E-Mails', 'andere E-Mails in diesem Postfach übernimmt er nicht in seine Datenbank', 'Ein eigenes Feld für Ihren Namen gibt es nicht', 'deren robots.txt den Abruf sperrt',
|
||||||
|
'damit wir den Beitrag nicht noch einmal bewerten', 'Wir erkennen Sie dabei nur am Beitrag und an der E-Mail-Adresse', 'beim Bearbeiten des Entwurfs nicht zu entfernen', 'in unserer Datenbank spätestens 90 Tage nach dem Fund kürzen',
|
||||||
|
// E2: aussortierte Anfragen bleiben nicht mehr 30 Tage, und der Zweck "nachsehen, ob die KI zu Unrecht aussortiert hat" entfällt
|
||||||
|
'kürzen die Daten innerhalb von 30 Tagen nach dem Fund', 'innerhalb von 30 Tagen nach dem Fund, wenn Ihre Anfrage nicht zu uns passt', 'Bis dahin speichern wir sie', 'nachsehen können', 'zu Unrecht aussortiert',
|
||||||
|
// E4: kein Satz über öffentlich sichtbare Antworten in Frage-Foren mehr, denn dort antworten wir nicht
|
||||||
|
'in einem Frage-Forum ist sie in der Regel für alle Besucher der Seite sichtbar', 'für alle Besucher der Seite sichtbar',
|
||||||
|
// der Hinweis in der Forum-E-Mail ist eine Anweisung an die Mitarbeiter, keine Tatsache, die der Dienst erzwingt (sie ist im Pult-Eintrag nicht enthalten)
|
||||||
|
'in diese E-Mail nehmen unsere Mitarbeiter am Ende den Hinweis auf'])
|
||||||
|
assert.ok(!s.includes(t), `nicht mehr im Text: ${t}`)
|
||||||
|
// E4: die Forenliste in der Regel für Antworten und in der Quelle ist dieselbe (der Dienst erkennt diese vier Foren, platforms.js FINN_ALLOWED_DOMAINS, jeden Fund von Finn und zusätzlich jedes Forum, dessen Host oder erster Pfad-Abschnitt „forum“ oder „foren“ nennt, istForenfund; der Text verspricht nur „derzeit“ die vier)
|
||||||
|
assert.equal(s.split('gutefrage.net, forum-hilfe.de, wer-weiss-was.de und wpde.org').length - 1, 2, 'die vier Foren stehen in der Quelle und in der Regel für Antworten, beide Male gleich')
|
||||||
|
// die Regel für Foren steht nach dem Hinweis und vor den Quellen; es gibt keinen Hinweis auf eine Antwort im Forum
|
||||||
|
assert.ok(s.indexOf('Am Ende unserer Antwort steht ein Hinweis') < s.indexOf('beantworten wir nicht im Forum') && s.indexOf('beantworten wir nicht im Forum') < s.indexOf('Die Gesuche finden wir auf zwei Wegen'))
|
||||||
|
const imForum = [...s.matchAll(/im Forum/g)]
|
||||||
|
assert.ok(imForum.length >= 2 && imForum.every((m) => s.slice(m.index - 6, m.index) === 'nicht '), 'jedes "im Forum" in Abschnitt 11 steht hinter "nicht": wir antworten nirgends im Forum')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legal_datenschutz_anfragen: nichts aus dem Kaltmail-Weg (Stufe 2); keine [OFFEN]-Markierung und kein Platzhalter mehr im Text', () => {
|
||||||
|
const html = read(DS)
|
||||||
|
const ds = strip(html)
|
||||||
|
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||||||
|
for (const t of ['Gratis-Vorschau', 'höchstens zwei E-Mails', 'höchstens drei Betriebe', 'schreibt und versendet', 'Antworten sortiert', 'Place-ID', 'Impressum', 'OpenStreetMap', 'Overpass', 'Resend', 'Cloudflare',
|
||||||
|
'Abweichend von Abschnitt 8', 'Abmelde-Link', 'bei Heilberufen', 'Geschäftsdaten in geringem Umfang', 'sechs Monate nach der letzten Nachricht', 'zwölf Monaten', 'Branchenverzeichnisse', 'nur für diese Antwort',
|
||||||
|
'Postfach bei der Hetzner', 'In allen Fällen bleiben nur']) assert.ok(!s.includes(t), `noch da (Stufe 2 oder zu viel versprochen): ${t}`)
|
||||||
|
// Resend, Cloudflare und Overpass stehen nirgends in der Erklärung
|
||||||
|
for (const t of ['Resend', 'Cloudflare', 'Overpass']) assert.ok(!ds.includes(t), `${t} gehört nicht in die Weg-B-Fassung`)
|
||||||
|
// Beide früher offenen Tatsachen sind geklärt (Alarm-Postfach = support@webklar.com bei Hetzner; Datensicherungen bis zu sieben Tage). Jede [OFFEN]-Markierung macht diesen
|
||||||
|
// Test rot, auch im Quelltext-Kommentar und auch ohne Span. Zusätzlich bricht scripts/datenschutz-hochladen.mjs vor dem Schreiben ab, solange eine Markierung steht.
|
||||||
|
assert.ok(!/\[OFFEN/i.test(html), `[OFFEN]-Markierung im Text: ${(html.match(/\[OFFEN[^\]]*\]/gi) ?? []).join(', ')}`)
|
||||||
|
assert.ok(!html.includes('class="offen"'), 'kein gelber Platzhalter-Span mehr')
|
||||||
|
// Zusagen, die es nicht gibt: keine "Postfach-Regel" (die Löschung der Plattform-Mails ist eine eigene Technik des Dienstes), keine unbestimmten Sicherungen
|
||||||
|
for (const t of ['Postfach-Regel', 'eigenes E-Mail-Postfach', 'Datensicherungen unseres Servers', 'noch kurze Zeit']) assert.ok(!ds.includes(t), `nicht mehr im Text: ${t}`)
|
||||||
|
// keine anderen Platzhalter
|
||||||
|
assert.ok(!/\[Prüfen|TODO|XXX|\{[a-z]+\}/i.test(strip(html.replace(/<!--[\s\S]*?-->/g, ''))), 'keine anderen Platzhalter im sichtbaren Text')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legal_datenschutz_anfragen: Zusagen nur in der Reichweite des Dienstes (Sperre nur je Beitrag, E-Mail-Adresse und bei Firmenadressen Domain, Fristen ab dem Lesen), keine zu weiten Aussagen', () => {
|
||||||
|
const ds = strip(read(DS))
|
||||||
|
const s = between(ds, '11. Interessenten: Antworten auf öffentliche Anfragen', '12. Empfänger im Überblick')
|
||||||
|
// der Dienst sperrt nur denselben Beitrag, dieselbe Adresse (bei Firmenadressen auch die Domain) und erreicht keine Antwort auf einer Plattform: keine Zusage "nicht mehr schreiben" ohne Einschränkung
|
||||||
|
for (const t of ['schreiben Ihnen nicht mehr', 'melden uns nicht wieder', 'sperren Ihre Daten dann sofort', 'derzeit unter anderem Facebook', 'Plattformen, die automatisierte Zugriffe untersagen', 'entscheidet nicht allein', 'kurze Zeit enthalten',
|
||||||
|
// der Mitarbeiter kann den Entwurf bearbeiten: keine feste Zusage über den Inhalt der Nachricht
|
||||||
|
'In unserer ersten Nachricht steht'])
|
||||||
|
assert.ok(!ds.includes(t), `zu weit: ${t}`)
|
||||||
|
assert.ok(s.includes('schreiben Ihnen dazu nicht mehr'), 'die Sperre gilt "dazu", also für den Beitrag und die Adresse')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('legal_datenschutz_anfragen: Abschnitt 2, 12 und 14 passen zu Abschnitt 11 und es gibt keinen Widerspruch zu 8', () => {
|
||||||
|
const ds = strip(read(DS))
|
||||||
|
const s2 = between(ds, '2. Geltungsbereich', '3. Hosting')
|
||||||
|
assert.ok(s2.includes('Menschen und Betriebe, die im Netz öffentlich nach einer Website suchen') && s2.includes('Privatpersonen') && s2.includes('Frage-Foren') && s2.includes('(Abschnitt 11)'))
|
||||||
|
const emp = between(ds, '12. Empfänger im Überblick', '13. Ihre Rechte')
|
||||||
|
assert.ok(emp.includes('Anthropic Ireland, Limited: Bewertung öffentlicher Anfragen, Entwurf von Antworten sowie Websuche und Seitenabruf durch ein Sprachmodell (Auftragsverarbeiter, Daten auch in den USA, Abschnitt 11)'))
|
||||||
|
assert.ok(emp.includes('Plattformen, auf denen wir Ihre Anfrage beantworten'))
|
||||||
|
// das Alarm-Postfach liegt bei Hetzner: kein eigener Anbieter in der Liste, Hetzner nennt es
|
||||||
|
assert.ok(emp.includes('Hetzner Online GmbH: Hosting, E-Mail (auch das Postfach für die Benachrichtigungen der Plattformen, Abschnitt 11) und Datensicherung (Auftragsverarbeiter)'))
|
||||||
|
assert.ok(!emp.includes('E-Mail-Postfach für die Benachrichtigungen der Plattformen'), 'kein eigener Listenpunkt für das Alarm-Postfach')
|
||||||
|
assert.ok(!emp.includes('Betrieben') && !emp.includes('Website-Texte'), 'der Anthropic-Zweck nennt keine Website-Texte von Betrieben mehr')
|
||||||
|
const s14 = between(ds, '14. Pflicht zur Bereitstellung', '15. Änderungen')
|
||||||
|
assert.ok(s14.includes('keine automatisierten Einzelentscheidungen nach Art. 22 DSGVO'))
|
||||||
|
// E4: bei Beiträgen aus Frage-Foren entwirft die KI gar keine Antwort, daher "höchstens"
|
||||||
|
assert.ok(s14.includes('Die Bewertung öffentlicher Anfragen durch einen KI-Assistenten (Abschnitt 11) sortiert Anfragen aus, die nicht zu uns passen; auf diese schreiben wir nicht. Für alle anderen bereitet sie höchstens eine Antwort vor; ob und was wir antworten, entscheidet ein Mitarbeiter, der die Antwort von Hand abschickt.'))
|
||||||
|
assert.ok(!s14.includes('bereitet sie nur eine Antwort vor'))
|
||||||
|
assert.ok(!ds.includes('keine automatisierten Entscheidungen'), 'die zu weite alte Überschrift ist weg')
|
||||||
|
// Einleitung nennt die Antwort auf öffentliche Anfragen
|
||||||
|
assert.ok(ds.includes('oder wenn wir auf Ihre öffentliche Anfrage nach einer Website antworten'))
|
||||||
|
// Abschnitt 8 (E-Mail über Hetzner) bleibt unverändert gültig, Abschnitt 11 verweist darauf, statt davon abzuweichen
|
||||||
|
assert.ok(between(ds, '8. E-Mail', '9. Buchhaltung').includes('versenden und empfangen wir über den E-Mail-Dienst der Hetzner Online GmbH'))
|
||||||
|
})
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
// Geheimer Vorschau-Link: Link erzeugen, einloesen (Cookie + Weiterleitung), pruefen, abschalten
|
||||||
|
import { test, before, after } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import express from 'express'
|
||||||
|
import { startFakeAppwrite } from './helpers/fakeAppwrite.js'
|
||||||
|
|
||||||
|
const ready = { subdomain: 'kunde-a', projectType: 'preview', provisioningStatus: 'deployed', customerId: 'c1' }
|
||||||
|
|
||||||
|
let aw, server, baseUrl, svc
|
||||||
|
before(async () => {
|
||||||
|
aw = await startFakeAppwrite({
|
||||||
|
seed: {
|
||||||
|
websiteProjects: [
|
||||||
|
{ $id: 'p1', ...ready },
|
||||||
|
{ $id: 'p2', ...ready, subdomain: 'baut-noch', provisioningStatus: 'pending', shareToken: 'Pending_Token_123456789' },
|
||||||
|
{ $id: 'p3', projectName: 'ohne Subdomain', subdomain: '' },
|
||||||
|
{ $id: 'p4', ...ready, subdomain: 'archiv', archived: true, shareToken: 'Archiv_Token_1234567890' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
Object.assign(process.env, { APPWRITE_ENDPOINT: aw.endpoint, APPWRITE_API_KEY: 'test-key', PREVIEW_SESSION_SECRET: 'x'.repeat(40) })
|
||||||
|
svc = await import('../server/services/vorschauLink.js')
|
||||||
|
const { vorschauLinkSeite, vorschauLinkPruefung } = await import('../server/routes/vorschauLink.js')
|
||||||
|
const app = express()
|
||||||
|
app.use('/vorschau', vorschauLinkSeite())
|
||||||
|
app.use('/api/vorschau-link', vorschauLinkPruefung())
|
||||||
|
server = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)) })
|
||||||
|
baseUrl = `http://127.0.0.1:${server.address().port}`
|
||||||
|
})
|
||||||
|
after(async () => {
|
||||||
|
server.closeAllConnections()
|
||||||
|
await new Promise((r) => server.close(r))
|
||||||
|
await aw.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
const doc = (id) => aw.all('websiteProjects').find((d) => d.$id === id)
|
||||||
|
const pruefen = (sub, token) =>
|
||||||
|
fetch(`${baseUrl}/api/vorschau-link/pruefen?subdomain=${sub}`, { headers: token ? { 'x-vorschau-token': token } : {} })
|
||||||
|
|
||||||
|
test('Token: zufaellig, URL-tauglich, lang genug', () => {
|
||||||
|
const a = svc.newShareToken()
|
||||||
|
assert.match(a, /^[A-Za-z0-9_-]{24}$/)
|
||||||
|
assert.notEqual(a, svc.newShareToken())
|
||||||
|
assert.equal(svc.cleanToken(a), a)
|
||||||
|
assert.equal(svc.cleanToken('kurz'), '')
|
||||||
|
assert.equal(svc.cleanToken('../../etc/passwd/xxxxxxxxxxxxxxx'), '')
|
||||||
|
assert.equal(svc.cleanSubdomain('Kunde-A'), 'kunde-a')
|
||||||
|
assert.equal(svc.cleanSubdomain('-x'), '')
|
||||||
|
assert.equal(svc.cleanSubdomain('a.b'), '')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Link erzeugen speichert den Token und baut die URL', async () => {
|
||||||
|
const r = await svc.renewShareLink('p1')
|
||||||
|
assert.equal(doc('p1').shareToken, r.shareToken)
|
||||||
|
assert.equal(r.url, `https://project.webklar.com/vorschau/kunde-a/${r.shareToken}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Ohne Subdomain gibt es keinen Link (409), unbekanntes Projekt 404', async () => {
|
||||||
|
await assert.rejects(svc.renewShareLink('p3'), (e) => e.status === 409)
|
||||||
|
await assert.rejects(svc.renewShareLink('gibt-es-nicht'), (e) => e.status === 404)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Link einloesen: Cookie nur fuer diese Subdomain, Weiterleitung auf die Preview', async () => {
|
||||||
|
const { shareToken } = await svc.renewShareLink('p1')
|
||||||
|
const res = await fetch(`${baseUrl}/vorschau/kunde-a/${shareToken}`, { redirect: 'manual' })
|
||||||
|
assert.equal(res.status, 302)
|
||||||
|
assert.equal(res.headers.get('location'), 'https://kunde-a.project.webklar.com/')
|
||||||
|
const cookie = res.headers.get('set-cookie')
|
||||||
|
assert.match(cookie, new RegExp(`^wk_vorschau_kunde-a=${shareToken};`))
|
||||||
|
assert.match(cookie, /Domain=\.project\.webklar\.com/)
|
||||||
|
assert.match(cookie, /HttpOnly/)
|
||||||
|
assert.match(cookie, /SameSite=Lax/)
|
||||||
|
assert.equal(res.headers.get('cache-control'), 'no-store')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Falscher Token, fremde Subdomain oder Muell: 404-Seite ohne Cookie', async () => {
|
||||||
|
const { shareToken } = await svc.renewShareLink('p1')
|
||||||
|
for (const path of [
|
||||||
|
`/vorschau/kunde-a/${shareToken.slice(0, -1)}X`,
|
||||||
|
`/vorschau/archiv/${shareToken}`,
|
||||||
|
`/vorschau/kunde-a/kurz`,
|
||||||
|
`/vorschau/KUNDE-A.evil/${shareToken}`,
|
||||||
|
]) {
|
||||||
|
const res = await fetch(baseUrl + path, { redirect: 'manual' })
|
||||||
|
assert.equal(res.status, 404, path)
|
||||||
|
assert.equal(res.headers.get('set-cookie'), null, path)
|
||||||
|
assert.match(await res.text(), /nicht \(mehr\) gültig/)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Archiviertes Projekt: Link gilt nicht', async () => {
|
||||||
|
const res = await pruefen('archiv', 'Archiv_Token_1234567890')
|
||||||
|
assert.equal(res.status, 403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Preview noch nicht fertig: 503-Hinweis, Pruefung sagt not_ready', async () => {
|
||||||
|
const res = await fetch(`${baseUrl}/vorschau/baut-noch/Pending_Token_123456789`, { redirect: 'manual' })
|
||||||
|
assert.equal(res.status, 503)
|
||||||
|
assert.equal(res.headers.get('set-cookie'), null)
|
||||||
|
const p = await pruefen('baut-noch', 'Pending_Token_123456789')
|
||||||
|
assert.deepEqual(await p.json(), { allowed: false, reason: 'not_ready' })
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Pruefung: richtiger Token 200, sonst 403', async () => {
|
||||||
|
const { shareToken } = await svc.renewShareLink('p1')
|
||||||
|
let res = await pruefen('kunde-a', shareToken)
|
||||||
|
assert.equal(res.status, 200)
|
||||||
|
assert.equal((await res.json()).allowed, true)
|
||||||
|
res = await pruefen('kunde-a', svc.newShareToken())
|
||||||
|
assert.equal(res.status, 403)
|
||||||
|
res = await pruefen('kunde-a', '')
|
||||||
|
assert.equal(res.status, 403)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Neuer Link oder Abschalten macht den alten sofort ungueltig', async () => {
|
||||||
|
const alt = (await svc.renewShareLink('p1')).shareToken
|
||||||
|
assert.equal((await pruefen('kunde-a', alt)).status, 200)
|
||||||
|
const neu = (await svc.renewShareLink('p1')).shareToken
|
||||||
|
assert.equal((await pruefen('kunde-a', alt)).status, 403)
|
||||||
|
assert.equal((await pruefen('kunde-a', neu)).status, 200)
|
||||||
|
await svc.removeShareLink('p1')
|
||||||
|
assert.equal(doc('p1').shareToken, '')
|
||||||
|
assert.equal((await pruefen('kunde-a', neu)).status, 403)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user