Analyse: Schema, Konfiguration, Client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
9fa05e23d1
commit
9e66f55c2c
5 files changed
+320
No files matched your search
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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 || '',
|
||||
|
||||
@@ -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): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, 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()
|
||||
@@ -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')
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
Reference in new issue
Block a user