Analytics: Geruest, Datenbank, Website-Register
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a1ce46672a
commit
f7da79e4b3
6 files changed
+318
No files matched your search
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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}`));
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user