From 9e66f55c2c06dde8480244d35b5ce3433498921b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:07:05 +0000 Subject: [PATCH] Analyse: Schema, Konfiguration, Client Co-Authored-By: Claude Sonnet 5.5 --- scripts/analyse-setup.mjs | 97 ++++++++++++++++++++++++++++++ server/config.js | 8 +++ server/services/analyticsClient.js | 66 ++++++++++++++++++++ test/analyseSetup.test.js | 55 +++++++++++++++++ test/analyticsClient.test.js | 94 +++++++++++++++++++++++++++++ 5 files changed, 320 insertions(+) create mode 100644 scripts/analyse-setup.mjs create mode 100644 server/services/analyticsClient.js create mode 100644 test/analyseSetup.test.js create mode 100644 test/analyticsClient.test.js diff --git a/scripts/analyse-setup.mjs b/scripts/analyse-setup.mjs new file mode 100644 index 0000000..d949b99 --- /dev/null +++ b/scripts/analyse-setup.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an. + * Idempotent: vorhandene Attribute werden uebersprungen. + * Usage: + * node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen) + * node scripts/analyse-setup.mjs (fehlende Attribute anlegen) + */ +import { pathToFileURL } from 'node:url' +import { config } from '../server/config.js' + +export const ANALYSE_ATTRIBUTES = [ + { key: 'accentColor', type: 'string', size: 7, required: false }, + { key: 'avvVersion', type: 'string', size: 20, required: false }, + { key: 'avvAcceptedAt', type: 'string', size: 40, required: false }, + { key: 'avvAcceptedBy', type: 'string', size: 100, required: false }, + { key: 'activatedAt', type: 'string', size: 40, required: false }, + { key: 'offSince', type: 'string', size: 40, required: false }, + { key: 'privacyTicketId', type: 'string', size: 40, required: false }, +] + +/** Echter Appwrite-Client (REST) fuer die Collection portalFeatures. */ +export function createAppwriteClient() { + const { endpoint, projectId, databaseId, apiKey } = config.appwrite + const collectionId = config.collections.portalFeatures + const base = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections/${collectionId}` + + async function aw(path, { method = 'GET', body } = {}) { + const response = await fetch(`${base}${path}`, { + method, + headers: { + 'Content-Type': 'application/json', + 'X-Appwrite-Project': projectId, + 'X-Appwrite-Key': apiKey, + }, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw new Error(data.message || `Appwrite ${response.status}`) + return data + } + + return { + async listAttributes() { + // Ohne limit liefert Appwrite nur 25 Attribute - die Sammlung hat mehr + const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] })) + const data = await aw(`/attributes?queries[]=${q}`) + return data.attributes || [] + }, + async createAttribute({ type, ...body }) { + await aw(`/attributes/${type}`, { method: 'POST', body }) + }, + async waitForAttribute(key, maxSec = 30) { + for (let i = 0; i < maxSec; i += 1) { + const attr = await aw(`/attributes/${key}`).catch(() => null) + if (attr?.status === 'available') return + await new Promise((r) => setTimeout(r, 1000)) + } + throw new Error(`Attribut ${key} wurde nicht verfuegbar`) + }, + } +} + +export async function main({ check = false, client, log = console.log } = {}) { + const api = client || createAppwriteClient() + const existing = new Set((await api.listAttributes()).map((a) => a.key)) + const missing = ANALYSE_ATTRIBUTES.filter((a) => !existing.has(a.key)) + + for (const attr of ANALYSE_ATTRIBUTES) { + if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`) + else if (check) log(`- Attribut ${attr.key} FEHLT`) + } + + if (check) { + log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.') + return { missing } + } + + for (const attr of missing) { + await api.createAttribute(attr) + log(`+ Attribut ${attr.key}`) + } + for (const attr of missing) await api.waitForAttribute(attr.key) + log('Fertig.') + return { missing } +} + +const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href +if (invokedDirectly) { + const check = process.argv.includes('--check') + main({ check }) + .then(({ missing }) => process.exit(check && missing.length ? 1 : 0)) + .catch((e) => { + console.error(e.message) + process.exit(1) + }) +} diff --git a/server/config.js b/server/config.js index 6560cb1..4d5b1b1 100644 --- a/server/config.js +++ b/server/config.js @@ -310,6 +310,14 @@ export const config = { logDir: process.env.PREVIEW_LOG_DIR || path.join(process.cwd(), 'upload-data', 'preview-logs'), }, + analyse: { + enabled: process.env.ANALYSE_ENABLED === 'on', + url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091', + adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '', + heatSecret: process.env.ANALYTICS_HEAT_SECRET || '', + avvVersion: '2026-10-01', + }, + invoiceNinja: { url: (process.env.INVOICE_NINJA_URL || 'https://invoice.webklar.com').replace(/\/$/, ''), apiToken: process.env.INVOICE_NINJA_API_TOKEN || '', diff --git a/server/services/analyticsClient.js b/server/services/analyticsClient.js new file mode 100644 index 0000000..ccbefe6 --- /dev/null +++ b/server/services/analyticsClient.js @@ -0,0 +1,66 @@ +// Client fuer die Admin-API des Analytics-Collectors (interner Listener, Port 8091). +// Token und Secret erscheinen nie in Fehlermeldungen oder Logs. +import crypto from 'node:crypto' +import { config } from '../config.js' + +const HEAT_TTL_SECONDS = 30 * 60 + +function fail(message, status) { + const err = new Error(message) + err.status = status + return err +} + +export function createAnalyticsClient(opts = {}) { + const cfg = { ...config.analyse, ...opts } + const timeoutMs = opts.timeoutMs || 10000 + + async function request(method, path, body) { + if (!cfg.adminToken) throw fail('Auswertung nicht eingerichtet', 503) + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${String(cfg.url).replace(/\/$/, '')}${path}`, { + method, + headers: { + Authorization: `Bearer ${cfg.adminToken}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }) + const data = await response.json().catch(() => ({})) + if (!response.ok) throw fail(`Auswertung antwortet mit Fehler ${response.status}`, response.status) + return data + } catch (e) { + if (e.status) throw e + if (e.name === 'AbortError') throw fail('Auswertung antwortet nicht (Zeitueberschreitung)', 504) + throw fail('Auswertung nicht erreichbar', 502) + } finally { + clearTimeout(timer) + } + } + + const sitePath = (siteKey) => `/a/admin/sites/${encodeURIComponent(siteKey)}` + + return { + putSite(siteKey, { projectId, domains, active }) { + return request('PUT', sitePath(siteKey), { projectId, domains, active }) + }, + deleteSite(siteKey) { + return request('DELETE', sitePath(siteKey)) + }, + getStats(siteKey, range) { + return request('GET', `${sitePath(siteKey)}/stats?range=${encodeURIComponent(range)}`) + }, + // Format (R3): ., byte-gleich zu analytics/src/heat.js + heatToken({ siteKey, path, vp, now = Date.now() }) { + if (!cfg.heatSecret) throw fail('Auswertung nicht eingerichtet', 503) + const exp = Math.floor(now / 1000) + HEAT_TTL_SECONDS + const part = Buffer.from(JSON.stringify({ s: siteKey, p: path, v: vp, e: exp })).toString('base64url') + return `${part}.${crypto.createHmac('sha256', cfg.heatSecret).update(part).digest('hex')}` + }, + } +} + +export const analyticsClient = createAnalyticsClient() diff --git a/test/analyseSetup.test.js b/test/analyseSetup.test.js new file mode 100644 index 0000000..3876068 --- /dev/null +++ b/test/analyseSetup.test.js @@ -0,0 +1,55 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { ANALYSE_ATTRIBUTES, main } from '../scripts/analyse-setup.mjs' +import { config } from '../server/config.js' + +function mockClient(existing = []) { + const created = [] + return { + created, + async listAttributes() { + return existing.map((key) => ({ key })) + }, + async createAttribute(attr) { + created.push(attr.key) + }, + async waitForAttribute() {}, + } +} + +test('Schema: 7 Attribute mit Groessen, alle optional', () => { + const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.map((a) => [a.key, a.size])) + assert.deepEqual(sizes, { + accentColor: 7, + avvVersion: 20, + avvAcceptedAt: 40, + avvAcceptedBy: 100, + activatedAt: 40, + offSince: 40, + privacyTicketId: 40, + }) + assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false && a.type === 'string')) +}) + +test('main legt nur fehlende Attribute an', async () => { + const client = mockClient(['accentColor']) + const result = await main({ client, log: () => {} }) + assert.equal(client.created.length, 6) + assert.ok(!client.created.includes('accentColor')) + assert.equal(result.missing.length, 6) +}) + +test('main --check legt nichts an', async () => { + const client = mockClient(['accentColor']) + const result = await main({ check: true, client, log: () => {} }) + assert.equal(client.created.length, 0) + assert.equal(result.missing.length, 6) +}) + +test('Konfiguration: analyse ist standardmaessig aus', () => { + assert.equal(config.analyse.enabled, false) + assert.equal(config.analyse.url, 'http://webklar-analytics:8091') + assert.equal(config.analyse.adminToken, '') + assert.equal(config.analyse.heatSecret, '') + assert.equal(config.analyse.avvVersion, '2026-10-01') +}) diff --git a/test/analyticsClient.test.js b/test/analyticsClient.test.js new file mode 100644 index 0000000..8ce56d4 --- /dev/null +++ b/test/analyticsClient.test.js @@ -0,0 +1,94 @@ +import { test, before, after } from 'node:test' +import assert from 'node:assert/strict' +import http from 'node:http' +import { createAnalyticsClient } from '../server/services/analyticsClient.js' +import { verifyHeatToken } from '../analytics/src/heat.js' + +let server +let url +let calls +let respond + +before(async () => { + server = http.createServer((req, res) => { + let body = '' + req.on('data', (c) => { body += c }) + req.on('end', () => { + calls.push({ method: req.method, url: req.url, auth: req.headers.authorization, body }) + const r = respond(req) + res.writeHead(r.status, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(r.body ?? {})) + }) + }) + await new Promise((r) => server.listen(0, '127.0.0.1', r)) + url = `http://127.0.0.1:${server.address().port}` +}) +after(() => server.close()) + +const mk = (over = {}) => createAnalyticsClient({ url, adminToken: 'tok-secret', heatSecret: 'heat-secret', ...over }) +const reset = (r = () => ({ status: 200, body: {} })) => { calls = []; respond = r } + +test('putSite: PUT mit Bearer und JSON', async () => { + reset() + await mk().putSite('abcdefghij123456', { projectId: 'p1', domains: ['a.project.webklar.com'], active: true }) + assert.equal(calls[0].method, 'PUT') + assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456') + assert.equal(calls[0].auth, 'Bearer tok-secret') + assert.deepEqual(JSON.parse(calls[0].body), { projectId: 'p1', domains: ['a.project.webklar.com'], active: true }) +}) + +test('deleteSite und getStats', async () => { + reset(() => ({ status: 200, body: { pv: 3 } })) + const c = mk() + await c.deleteSite('abcdefghij123456') + assert.equal(calls[0].method, 'DELETE') + const s = await c.getStats('abcdefghij123456', '7d') + assert.deepEqual(s, { pv: 3 }) + assert.equal(calls[1].url, '/a/admin/sites/abcdefghij123456/stats?range=7d') +}) + +test('Fehler tragen status, ohne Token in der Meldung', async () => { + reset(() => ({ status: 500, body: { error: 'kaputt' } })) + await assert.rejects(mk().getStats('abcdefghij123456', '7d'), (e) => { + assert.equal(e.status, 500) + assert.ok(!e.message.includes('tok-secret')) + return true + }) +}) + +test('fehlender adminToken => 503', async () => { + reset() + await assert.rejects(mk({ adminToken: '' }).putSite('abcdefghij123456', {}), (e) => { + assert.equal(e.status, 503) + assert.equal(e.message, 'Auswertung nicht eingerichtet') + return true + }) + assert.equal(calls.length, 0) +}) + +test('Timeout wird als Fehler gemeldet', async () => { + reset(() => ({ status: 200 })) + const slow = http.createServer(() => {}) + await new Promise((r) => slow.listen(0, '127.0.0.1', r)) + const c = mk({ url: `http://127.0.0.1:${slow.address().port}`, timeoutMs: 50 }) + await assert.rejects(c.getStats('abcdefghij123456', '7d'), (e) => { + assert.ok(e.status >= 500) + assert.ok(!e.message.includes('tok-secret')) + return true + }) + slow.closeAllConnections?.() + slow.close() +}) + +test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', () => { + const now = Date.UTC(2026, 9, 1, 12, 0, 0) + const token = mk().heatToken({ siteKey: 'abcdefghij123456', path: '/preise', vp: 'm', now }) + const v = verifyHeatToken(token, 'heat-secret', now) + assert.deepEqual(v, { siteKey: 'abcdefghij123456', path: '/preise', vp: 'm', exp: now / 1000 + 1800 }) + assert.equal(verifyHeatToken(token, 'heat-secret', now + 1801 * 1000), null) + assert.equal(verifyHeatToken(token, 'anderes', now), null) +}) + +test('heatToken ohne heatSecret => 503', () => { + assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503) +})