Analytics: Geruest, Datenbank, Website-Register

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-01 09:23:27 +00:00
1 parent a1ce46672a
commit f7da79e4b3
6 files changed
+318

No files matched your search

+10
View File
@@ -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"
}
}
+11
View File
@@ -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',
};
}
+54
View File
@@ -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;
}
+10
View File
@@ -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}`));
+95
View File
@@ -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 });
}
});
}
+138
View File
@@ -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();
});