Messdienst: POST /a/admin/sites/:key/off setzt active=0 und offSince einmalig; Portal-Client stopSite. analyse.js: endAnalyse als gemeinsamer Kern, endAnalyseForCustomer, analyseOnProjectChanged, analyseDisabled (Sperre/Löschfrist) und täglicher Abgleich runAnalyseReconcile (Projekt/Kunde weg, Domains nachziehen, K18-Wiederholung, Messdienst für off/disabled, fehlende Entfernen-Tickets nachholen). Entfernen-Ticket je Abschaltung gekennzeichnet (kein Doppelticket, auch wenn das Speichern der ID scheiterte). Hooks: Kunde löschen, updateWebsiteProject, withdrawOrder (Wechsel), siteFeatures.disable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
76 lines
2.9 KiB
JavaScript
76 lines
2.9 KiB
JavaScript
// 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 })
|
|
},
|
|
/** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */
|
|
async stopSite(siteKey) {
|
|
try {
|
|
return await request('POST', `${sitePath(siteKey)}/off`)
|
|
} catch (err) {
|
|
if (err.status === 404) return { ok: true, missing: true }
|
|
throw err
|
|
}
|
|
},
|
|
deleteSite(siteKey) {
|
|
return request('DELETE', sitePath(siteKey))
|
|
},
|
|
getStats(siteKey, range) {
|
|
return request('GET', `/a/admin/stats/${encodeURIComponent(siteKey)}?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()
|