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
@@ -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()
|
||||
Reference in new issue
Block a user