From f7da79e4b30fed119e0817162913fa35dc722428 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 09:23:27 +0000 Subject: [PATCH] Analytics: Geruest, Datenbank, Website-Register Co-Authored-By: Claude Sonnet 5.5 --- analytics/package.json | 10 +++ analytics/src/config.js | 11 +++ analytics/src/db.js | 54 ++++++++++++++ analytics/src/index.js | 10 +++ analytics/src/server.js | 95 ++++++++++++++++++++++++ analytics/test/sites.test.js | 138 +++++++++++++++++++++++++++++++++++ 6 files changed, 318 insertions(+) create mode 100644 analytics/package.json create mode 100644 analytics/src/config.js create mode 100644 analytics/src/db.js create mode 100644 analytics/src/index.js create mode 100644 analytics/src/server.js create mode 100644 analytics/test/sites.test.js diff --git a/analytics/package.json b/analytics/package.json new file mode 100644 index 0000000..22a63f6 --- /dev/null +++ b/analytics/package.json @@ -0,0 +1,10 @@ +{ + "name": "webklar-analytics", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "test": "node --test test/*.test.js" + } +} diff --git a/analytics/src/config.js b/analytics/src/config.js new file mode 100644 index 0000000..c53532f --- /dev/null +++ b/analytics/src/config.js @@ -0,0 +1,11 @@ +// Konfiguration aus der Umgebung; leerer adminToken sperrt alle Admin-Routen. +export function loadConfig(env = process.env) { + return { + port: Number(env.ANALYTICS_PORT) || 8090, + dataDir: env.ANALYTICS_DATA_DIR || '/srv/webklar-analytics', + adminToken: env.ANALYTICS_ADMIN_TOKEN || '', + heatSecret: env.ANALYTICS_HEAT_SECRET || '', + geoFile: env.ANALYTICS_GEO_FILE || '', + baseHost: env.ANALYTICS_BASE_HOST || 'project.webklar.com', + }; +} diff --git a/analytics/src/db.js b/analytics/src/db.js new file mode 100644 index 0000000..21406e1 --- /dev/null +++ b/analytics/src/db.js @@ -0,0 +1,54 @@ +import { DatabaseSync } from 'node:sqlite'; + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS sites ( + siteKey TEXT PRIMARY KEY, + projectId TEXT NOT NULL, + domains TEXT NOT NULL, + active INTEGER NOT NULL DEFAULT 1, + offSince TEXT +); +CREATE TABLE IF NOT EXISTS events_raw ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site TEXT NOT NULL, + ts INTEGER NOT NULL, + type TEXT NOT NULL, + path TEXT, + vp TEXT, + ref TEXT, + country TEXT, + device TEXT, + visitor TEXT, + sec INTEGER, + scroll INTEGER, + kind TEXT, + host TEXT +); +CREATE INDEX IF NOT EXISTS idx_events_site_ts ON events_raw(site, ts); +CREATE TABLE IF NOT EXISTS clicks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site TEXT NOT NULL, + ts INTEGER NOT NULL, + path TEXT NOT NULL, + vp TEXT NOT NULL, + x REAL NOT NULL, + y REAL NOT NULL, + sel TEXT +); +CREATE INDEX IF NOT EXISTS idx_clicks_site_path_vp_ts ON clicks(site, path, vp, ts); +CREATE TABLE IF NOT EXISTS daily ( + site TEXT NOT NULL, + day TEXT NOT NULL, + dim TEXT NOT NULL, + key TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (site, day, dim, key) +); +`; + +export function openDb(path) { + const db = new DatabaseSync(path); + if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL'); + db.exec(SCHEMA); + return db; +} diff --git a/analytics/src/index.js b/analytics/src/index.js new file mode 100644 index 0000000..1249bd7 --- /dev/null +++ b/analytics/src/index.js @@ -0,0 +1,10 @@ +import { mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadConfig } from './config.js'; +import { openDb } from './db.js'; +import { createServer } from './server.js'; + +const config = loadConfig(process.env); +mkdirSync(config.dataDir, { recursive: true }); +const db = openDb(join(config.dataDir, 'analytics.db')); +createServer({ config, db }).listen(config.port, () => console.log(`Analytics-Dienst auf Port ${config.port}`)); diff --git a/analytics/src/server.js b/analytics/src/server.js new file mode 100644 index 0000000..7324fd1 --- /dev/null +++ b/analytics/src/server.js @@ -0,0 +1,95 @@ +import http from 'node:http'; +import crypto from 'node:crypto'; + +const MAX_ADMIN_BODY = 64 * 1024; +const SITE_KEY = /^[a-z0-9]{16,40}$/; +const HOSTNAME = /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/; + +function tokenOk(header, token) { + if (!token) return false; + const m = /^Bearer (.+)$/.exec(header || ''); + if (!m) return false; + const a = crypto.createHash('sha256').update(m[1]).digest(); + const b = crypto.createHash('sha256').update(token).digest(); + return crypto.timingSafeEqual(a, b); +} + +function send(res, status, obj) { + const body = JSON.stringify(obj); + res.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }); + res.end(body); +} + +function readJson(req, max) { + return new Promise((resolve, reject) => { + const chunks = []; + let size = 0; + let tooBig = false; + req.on('data', (c) => { + if (tooBig) return; // Rest verwerfen, damit die Antwort noch ausgeliefert wird + size += c.length; + if (size > max) { tooBig = true; chunks.length = 0; return; } + chunks.push(c); + }); + req.on('end', () => { + if (tooBig) return reject(Object.assign(new Error('Anfrage zu groß'), { status: 413 })); + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { resolve(JSON.parse(raw)); } catch { reject(Object.assign(new Error('Ungültiges JSON'), { status: 400 })); } + }); + req.on('error', reject); + }); +} + +function validateSite(b) { + if (!b || typeof b !== 'object' || Array.isArray(b)) return { error: 'Ungültige Anfrage' }; + if (typeof b.projectId !== 'string' || !b.projectId || b.projectId.length > 100) return { error: 'Ungültige Projekt-ID' }; + if (!Array.isArray(b.domains) || b.domains.length < 1 || b.domains.length > 5 + || !b.domains.every((d) => typeof d === 'string' && d.length <= 253 && HOSTNAME.test(d))) { + return { error: 'Ungültige Domains (1 bis 5 Hostnamen, klein geschrieben, ohne Port und Pfad)' }; + } + if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' }; + return { site: { projectId: b.projectId, domains: b.domains, active: b.active } }; +} + +export function createServer({ config, db }) { + return http.createServer(async (req, res) => { + try { + const url = new URL(req.url, 'http://x'); + const parts = url.pathname.split('/').filter(Boolean); + const method = req.method; + + if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true }); + + if (parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4 + && (method === 'PUT' || method === 'DELETE')) { + if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' }); + const key = parts[3]; + if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' }); + + if (method === 'DELETE') { + db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key); + db.prepare('DELETE FROM events_raw WHERE site = ?').run(key); + db.prepare('DELETE FROM clicks WHERE site = ?').run(key); + db.prepare('DELETE FROM daily WHERE site = ?').run(key); + return send(res, 200, { ok: true }); + } + const v = validateSite(await readJson(req, MAX_ADMIN_BODY)); + if (v.error) return send(res, 400, { error: v.error }); + const { projectId, domains, active } = v.site; + const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key); + const offSince = active ? null : (old && old.offSince) || new Date().toISOString(); + db.prepare(`INSERT INTO sites (siteKey, projectId, domains, active, offSince) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(siteKey) DO UPDATE SET projectId = excluded.projectId, domains = excluded.domains, + active = excluded.active, offSince = excluded.offSince`) + .run(key, projectId, JSON.stringify(domains), active ? 1 : 0, offSince); + return send(res, 200, { ok: true }); + } + return send(res, 404, { error: 'Nicht gefunden' }); + } catch (e) { + const status = e.status || 500; + if (status >= 500) console.error('Analytics-Fehler:', e); + if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message }); + } + }); +} diff --git a/analytics/test/sites.test.js b/analytics/test/sites.test.js new file mode 100644 index 0000000..d57a884 --- /dev/null +++ b/analytics/test/sites.test.js @@ -0,0 +1,138 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { loadConfig } from '../src/config.js'; +import { openDb } from '../src/db.js'; +import { createServer } from '../src/server.js'; + +const KEY = 'abcdef0123456789abcd'; + +async function setup(adminToken = 'geheim') { + const db = openDb(':memory:'); + const server = createServer({ config: { adminToken }, db }); + await new Promise((r) => server.listen(0, '127.0.0.1', r)); + const base = `http://127.0.0.1:${server.address().port}`; + const call = async (method, path, body, token = 'geheim', raw) => { + const headers = {}; + if (token !== null) headers.Authorization = `Bearer ${token}`; + if (body !== undefined || raw !== undefined) headers['Content-Type'] = 'application/json'; + const res = await fetch(base + path, { method, headers, body: raw ?? (body === undefined ? undefined : JSON.stringify(body)) }); + return { status: res.status, body: await res.json() }; + }; + return { db, call, close: () => server.close() }; +} +const row = (db, k = KEY) => db.prepare('SELECT * FROM sites WHERE siteKey = ?').get(k); +const valid = { projectId: 'p1', domains: ['abc.project.webklar.com'], active: true }; + +test('loadConfig: Vorgaben und Umgebung', () => { + const c = loadConfig({}); + assert.deepEqual(c, { port: 8090, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' }); + const d = loadConfig({ ANALYTICS_PORT: '9000', ANALYTICS_ADMIN_TOKEN: 't', ANALYTICS_HEAT_SECRET: 'h' }); + assert.equal(d.port, 9000); + assert.equal(d.adminToken, 't'); + assert.equal(d.heatSecret, 'h'); +}); + +test('openDb ist idempotent und legt Tabellen an', () => { + const db = openDb(':memory:'); + const names = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map((r) => r.name); + for (const n of ['sites', 'events_raw', 'clicks', 'daily']) assert.ok(names.includes(n), n); +}); + +test('GET /a/health ohne Token', async () => { + const t = await setup(); + const r = await t.call('GET', '/a/health', undefined, null); + assert.equal(r.status, 200); + assert.deepEqual(r.body, { ok: true }); + t.close(); +}); + +test('Admin: 401 ohne, mit falschem Token und bei leerem adminToken', async () => { + const t = await setup(); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, null)).status, 401); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid, 'falsch')).status, 401); + assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`, undefined, 'falsch')).status, 401); + assert.equal(row(t.db), undefined); + t.close(); + const e = await setup(''); + assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, '')).status, 401); + assert.equal((await e.call('PUT', `/a/admin/sites/${KEY}`, valid, 'x')).status, 401); + e.close(); +}); + +test('PUT legt an und ändert', async () => { + const t = await setup(); + const r = await t.call('PUT', `/a/admin/sites/${KEY}`, valid); + assert.equal(r.status, 200); + assert.equal(r.body.ok, true); + let s = row(t.db); + assert.equal(s.projectId, 'p1'); + assert.deepEqual(JSON.parse(s.domains), ['abc.project.webklar.com']); + assert.equal(s.active, 1); + assert.equal(s.offSince, null); + await t.call('PUT', `/a/admin/sites/${KEY}`, { projectId: 'p2', domains: ['a.de', 'b.de'], active: true }); + s = row(t.db); + assert.equal(s.projectId, 'p2'); + assert.deepEqual(JSON.parse(s.domains), ['a.de', 'b.de']); + assert.equal(t.db.prepare('SELECT COUNT(*) c FROM sites').get().c, 1); + t.close(); +}); + +test('active=false setzt offSince einmalig, active=true löscht es', async () => { + const t = await setup(); + await t.call('PUT', `/a/admin/sites/${KEY}`, valid); + await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false }); + const s1 = row(t.db); + assert.equal(s1.active, 0); + assert.ok(!Number.isNaN(Date.parse(s1.offSince))); + assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/); + t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY); + await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: false }); + assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z'); + await t.call('PUT', `/a/admin/sites/${KEY}`, valid); + assert.equal(row(t.db).offSince, null); + t.close(); +}); + +test('PUT: ungültiger siteKey 400', async () => { + const t = await setup(); + for (const k of ['kurz', 'ABCDEF0123456789ABCD', 'abcdef0123456789abc!', 'a'.repeat(41)]) { + assert.equal((await t.call('PUT', `/a/admin/sites/${k}`, valid)).status, 400, k); + } + assert.equal((await t.call('DELETE', '/a/admin/sites/kurz')).status, 400); + t.close(); +}); + +test('PUT: Domains nur Hostnamen, max 5; projectId/active geprüft', async () => { + const t = await setup(); + const bad = [ + ['a.de:8080'], ['a.de/pfad'], ['https://a.de'], ['A.de'], ['localhost'], [''], ['-a.de'], [1], 'a.de', [], + ['a.de', 'b.de', 'c.de', 'd.de', 'e.de', 'f.de'], + ]; + for (const domains of bad) { + const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains }); + assert.equal(r.status, 400, JSON.stringify(domains)); + assert.equal(typeof r.body.error, 'string'); + } + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: '' })).status, 400); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, active: 'ja' })).status, 400); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, undefined, 'geheim', '{kaputt')).status, 400); + assert.equal(row(t.db), undefined); + assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'b.de', 'c.de', 'd.de', 'e.de'] })).status, 200); + t.close(); +}); + +test('PUT: zu großer Body 413', async () => { + const t = await setup(); + const r = await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, projectId: 'x'.repeat(70000) }); + assert.equal(r.status, 413); + t.close(); +}); + +test('DELETE entfernt Website, unbekannte Route 404', async () => { + const t = await setup(); + await t.call('PUT', `/a/admin/sites/${KEY}`, valid); + assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 200); + assert.equal(row(t.db), undefined); + assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404); + t.close(); +});