Analytics: Admin-Routen nur auf internem Listener (R5), Transaktion, Duplikat-Check
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f7da79e4b3
commit
cd9aebe4b1
4 files changed
+67
-8
No files matched your search
@@ -2,6 +2,7 @@
|
|||||||
export function loadConfig(env = process.env) {
|
export function loadConfig(env = process.env) {
|
||||||
return {
|
return {
|
||||||
port: Number(env.ANALYTICS_PORT) || 8090,
|
port: Number(env.ANALYTICS_PORT) || 8090,
|
||||||
|
adminPort: Number(env.ANALYTICS_ADMIN_PORT) || 8091,
|
||||||
dataDir: env.ANALYTICS_DATA_DIR || '/srv/webklar-analytics',
|
dataDir: env.ANALYTICS_DATA_DIR || '/srv/webklar-analytics',
|
||||||
adminToken: env.ANALYTICS_ADMIN_TOKEN || '',
|
adminToken: env.ANALYTICS_ADMIN_TOKEN || '',
|
||||||
heatSecret: env.ANALYTICS_HEAT_SECRET || '',
|
heatSecret: env.ANALYTICS_HEAT_SECRET || '',
|
||||||
|
|||||||
+17
-2
@@ -2,9 +2,24 @@ import { mkdirSync } from 'node:fs';
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
import { openDb } from './db.js';
|
import { openDb } from './db.js';
|
||||||
import { createServer } from './server.js';
|
import { createPublicServer, createAdminServer } from './server.js';
|
||||||
|
|
||||||
const config = loadConfig(process.env);
|
const config = loadConfig(process.env);
|
||||||
mkdirSync(config.dataDir, { recursive: true });
|
mkdirSync(config.dataDir, { recursive: true });
|
||||||
const db = openDb(join(config.dataDir, 'analytics.db'));
|
const db = openDb(join(config.dataDir, 'analytics.db'));
|
||||||
createServer({ config, db }).listen(config.port, () => console.log(`Analytics-Dienst auf Port ${config.port}`));
|
|
||||||
|
const servers = [
|
||||||
|
['öffentlich', createPublicServer({ config, db }), config.port],
|
||||||
|
['intern (Admin)', createAdminServer({ config, db }), config.adminPort],
|
||||||
|
];
|
||||||
|
for (const [name, server, port] of servers) {
|
||||||
|
server.on('error', (e) => { console.error(`Analytics-Listener ${name} (Port ${port}):`, e.message); process.exit(1); });
|
||||||
|
server.listen(port, () => console.log(`Analytics-Dienst ${name} auf Port ${port}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
let open = servers.length;
|
||||||
|
const done = () => { if (--open === 0) { db.close(); process.exit(0); } };
|
||||||
|
for (const [, server] of servers) { server.close(done); server.closeIdleConnections?.(); }
|
||||||
|
setTimeout(() => process.exit(0), 5000).unref();
|
||||||
|
});
|
||||||
+15
-2
@@ -48,11 +48,12 @@ function validateSite(b) {
|
|||||||
|| !b.domains.every((d) => typeof d === 'string' && d.length <= 253 && HOSTNAME.test(d))) {
|
|| !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)' };
|
return { error: 'Ungültige Domains (1 bis 5 Hostnamen, klein geschrieben, ohne Port und Pfad)' };
|
||||||
}
|
}
|
||||||
|
if (new Set(b.domains).size !== b.domains.length) return { error: 'Doppelte Domains' };
|
||||||
if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' };
|
if (typeof b.active !== 'boolean') return { error: 'Ungültiger Status' };
|
||||||
return { site: { projectId: b.projectId, domains: b.domains, active: b.active } };
|
return { site: { projectId: b.projectId, domains: b.domains, active: b.active } };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServer({ config, db }) {
|
function createHandlerServer({ config, db, role }) {
|
||||||
return http.createServer(async (req, res) => {
|
return http.createServer(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url, 'http://x');
|
const url = new URL(req.url, 'http://x');
|
||||||
@@ -61,17 +62,24 @@ export function createServer({ config, db }) {
|
|||||||
|
|
||||||
if (method === 'GET' && url.pathname === '/a/health') return send(res, 200, { ok: true });
|
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
|
if (role === 'admin' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 4
|
||||||
&& (method === 'PUT' || method === 'DELETE')) {
|
&& (method === 'PUT' || method === 'DELETE')) {
|
||||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||||
const key = parts[3];
|
const key = parts[3];
|
||||||
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
||||||
|
|
||||||
if (method === 'DELETE') {
|
if (method === 'DELETE') {
|
||||||
|
db.exec('BEGIN');
|
||||||
|
try {
|
||||||
db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key);
|
db.prepare('DELETE FROM sites WHERE siteKey = ?').run(key);
|
||||||
db.prepare('DELETE FROM events_raw WHERE site = ?').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 clicks WHERE site = ?').run(key);
|
||||||
db.prepare('DELETE FROM daily WHERE site = ?').run(key);
|
db.prepare('DELETE FROM daily WHERE site = ?').run(key);
|
||||||
|
db.exec('COMMIT');
|
||||||
|
} catch (e) {
|
||||||
|
db.exec('ROLLBACK');
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
return send(res, 200, { ok: true });
|
return send(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
const v = validateSite(await readJson(req, MAX_ADMIN_BODY));
|
const v = validateSite(await readJson(req, MAX_ADMIN_BODY));
|
||||||
@@ -93,3 +101,8 @@ export function createServer({ config, db }) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Öffentlicher Listener: nur /a/health (Erfassung folgt); Admin-Routen gibt es hier nicht.
|
||||||
|
export const createPublicServer = ({ config, db }) => createHandlerServer({ config, db, role: 'public' });
|
||||||
|
// Interner Listener für die Website-Verwaltung.
|
||||||
|
export const createAdminServer = ({ config, db }) => createHandlerServer({ config, db, role: 'admin' });
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
import { test } from 'node:test';
|
import { test } from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtempSync } from 'node:fs';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
import { loadConfig } from '../src/config.js';
|
import { loadConfig } from '../src/config.js';
|
||||||
import { openDb } from '../src/db.js';
|
import { openDb } from '../src/db.js';
|
||||||
import { createServer } from '../src/server.js';
|
import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||||
|
|
||||||
const KEY = 'abcdef0123456789abcd';
|
const KEY = 'abcdef0123456789abcd';
|
||||||
|
|
||||||
async function setup(adminToken = 'geheim') {
|
async function setup(adminToken = 'geheim', make = createAdminServer) {
|
||||||
const db = openDb(':memory:');
|
const db = openDb(':memory:');
|
||||||
const server = createServer({ config: { adminToken }, db });
|
const server = make({ config: { adminToken }, db });
|
||||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||||
const base = `http://127.0.0.1:${server.address().port}`;
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
const call = async (method, path, body, token = 'geheim', raw) => {
|
const call = async (method, path, body, token = 'geheim', raw) => {
|
||||||
@@ -25,7 +28,7 @@ const valid = { projectId: 'p1', domains: ['abc.project.webklar.com'], active: t
|
|||||||
|
|
||||||
test('loadConfig: Vorgaben und Umgebung', () => {
|
test('loadConfig: Vorgaben und Umgebung', () => {
|
||||||
const c = loadConfig({});
|
const c = loadConfig({});
|
||||||
assert.deepEqual(c, { port: 8090, dataDir: '/srv/webklar-analytics', adminToken: '', heatSecret: '', geoFile: '', baseHost: 'project.webklar.com' });
|
assert.deepEqual(c, { port: 8090, adminPort: 8091, 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' });
|
const d = loadConfig({ ANALYTICS_PORT: '9000', ANALYTICS_ADMIN_TOKEN: 't', ANALYTICS_HEAT_SECRET: 'h' });
|
||||||
assert.equal(d.port, 9000);
|
assert.equal(d.port, 9000);
|
||||||
assert.equal(d.adminToken, 't');
|
assert.equal(d.adminToken, 't');
|
||||||
@@ -136,3 +139,30 @@ test('DELETE entfernt Website, unbekannte Route 404', async () => {
|
|||||||
assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404);
|
assert.equal((await t.call('GET', '/a/nichts', undefined, null)).status, 404);
|
||||||
t.close();
|
t.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Admin-Route auf dem öffentlichen Listener: 404 auch mit richtigem Token', async () => {
|
||||||
|
const t = await setup('geheim', createPublicServer);
|
||||||
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, valid)).status, 404);
|
||||||
|
assert.equal((await t.call('DELETE', `/a/admin/sites/${KEY}`)).status, 404);
|
||||||
|
assert.equal(row(t.db), undefined);
|
||||||
|
assert.equal((await t.call('GET', '/a/health', undefined, null)).status, 200);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Doppelte Domains: 400', async () => {
|
||||||
|
const t = await setup();
|
||||||
|
assert.equal((await t.call('PUT', `/a/admin/sites/${KEY}`, { ...valid, domains: ['a.de', 'a.de'] })).status, 400);
|
||||||
|
t.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
|
||||||
|
const f = join(mkdtempSync(join(tmpdir(), 'ana-')), 'a.db');
|
||||||
|
const d1 = openDb(f);
|
||||||
|
d1.prepare("INSERT INTO sites VALUES ('k','p','[]',1,NULL)").run();
|
||||||
|
assert.equal(d1.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||||
|
d1.close();
|
||||||
|
const d2 = openDb(f);
|
||||||
|
assert.equal(d2.prepare('SELECT COUNT(*) c FROM sites').get().c, 1);
|
||||||
|
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||||
|
d2.close();
|
||||||
|
});
|
||||||
Reference in new issue
Block a user