mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 00:14:12 +00:00
A second switch under System > Usage stats, "Also send anonymous error counts", or `BULWARK_TELEMETRY_ERRORS=on|off`. Off by default, off whenever the heartbeat is off or no third-party traffic is on, and switching the heartbeat off switches it off too. With it on the heartbeat becomes schema 2: the v1 fields unchanged plus `errors`, up to 50 fingerprints of the last complete UTC day with the error name, the top frames in Bulwark's bundles (path, line, column, debug ID), the version, a bucketed count, on how many of the last 7 days it occurred, and a message only when it is a fixed error code. Without the consent the heartbeat stays exactly schema 1. The admin preview shows the v2 payload as soon as the switch is flipped.
190 lines
7.1 KiB
TypeScript
190 lines
7.1 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|
import { auditLog } from '@/lib/admin/audit';
|
|
import { logger } from '@/lib/logger';
|
|
import {
|
|
effectiveConsent,
|
|
effectiveErrorsConsent,
|
|
updateState,
|
|
buildPayload,
|
|
sendOnce,
|
|
reschedule,
|
|
DEFAULT_ENDPOINT,
|
|
getLoginCounts,
|
|
resolveEndpointAllowed,
|
|
} from '@/lib/telemetry';
|
|
import { storedDataUnavailableResponse } from '@/lib/stored-data-errors';
|
|
import { errorsForPayload } from '@/lib/telemetry/payload';
|
|
import { readErrors } from '@/lib/error-reporting/aggregate';
|
|
|
|
/**
|
|
* GET /api/admin/telemetry
|
|
* Returns current consent + endpoint + next/last send + a live preview
|
|
* of exactly what the next heartbeat would contain.
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
try {
|
|
const auth = await requireAdminAuth(request);
|
|
if ('error' in auth) return auth.error;
|
|
|
|
const { consent, source, state } = await effectiveConsent();
|
|
const [payload, accountCounts, errors, counted] = await Promise.all([
|
|
buildPayload(),
|
|
getLoginCounts(),
|
|
effectiveErrorsConsent(),
|
|
readErrors(),
|
|
]);
|
|
|
|
return NextResponse.json(
|
|
{
|
|
consent,
|
|
consentSource: source,
|
|
// The separate consent to add error counts (schema v2).
|
|
errorsConsent: errors.consent,
|
|
errorsConsentSource: errors.source,
|
|
endpoint: state.endpoint ?? DEFAULT_ENDPOINT,
|
|
consentedAt: state.consentedAt,
|
|
lastSentAt: state.lastSentAt,
|
|
nextScheduledAt: state.nextScheduledAt,
|
|
defaultEndpoint: DEFAULT_ENDPOINT,
|
|
payloadPreview: payload,
|
|
// What `errors` would hold, so the page can show the v2 payload as soon
|
|
// as the switch is flipped, before anything is saved or sent.
|
|
errorsPreview: errorsForPayload(counted),
|
|
accountCounts,
|
|
},
|
|
{ headers: { 'Cache-Control': 'no-store' } },
|
|
);
|
|
} catch (err) {
|
|
logger.error('telemetry GET error', {
|
|
error: err instanceof Error ? err.message : 'unknown',
|
|
});
|
|
const unavailable = storedDataUnavailableResponse(err);
|
|
if (unavailable) return unavailable;
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/admin/telemetry
|
|
* Body: { action: 'set-consent' | 'set-endpoint' | 'send-now', ... }
|
|
* set-consent : { action, consent: 'on' | 'off' } (off also switches error counts off)
|
|
* set-errors-consent : { action, consent: 'on' | 'off' }
|
|
* set-endpoint : { action, endpoint: string }
|
|
* send-now : { action }
|
|
*/
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const auth = await requireAdminAuth(request);
|
|
if ('error' in auth) return auth.error;
|
|
const ip = getClientIP(request);
|
|
|
|
const body = (await request.json().catch(() => null)) as
|
|
| { action?: string; consent?: string; endpoint?: string }
|
|
| null;
|
|
if (!body || typeof body.action !== 'string') {
|
|
return NextResponse.json({ error: 'action required' }, { status: 400 });
|
|
}
|
|
|
|
const { source } = await effectiveConsent();
|
|
|
|
if (body.action === 'set-consent') {
|
|
if (source === 'env') {
|
|
return NextResponse.json(
|
|
{ error: 'consent is overridden by BULWARK_TELEMETRY env var' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
if (source === 'no-third-party') {
|
|
return NextResponse.json(
|
|
{ error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
if (body.consent !== 'on' && body.consent !== 'off') {
|
|
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
|
}
|
|
const consent = body.consent;
|
|
const now = new Date().toISOString();
|
|
let before: string | undefined;
|
|
// Against the stored state, so a schedule written meanwhile stays.
|
|
await updateState((state) => {
|
|
before = state.consent;
|
|
state.consent = consent;
|
|
if (consent === 'on' && !state.consentedAt) state.consentedAt = now;
|
|
// Error counts ride on the heartbeat: no heartbeats, no error counts,
|
|
// and turning heartbeats on again doesn't bring them back by itself.
|
|
if (consent === 'off') state.errorsConsent = 'off';
|
|
});
|
|
await reschedule();
|
|
await auditLog('telemetry.set-consent', { from: before, to: body.consent }, ip);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
if (body.action === 'set-errors-consent') {
|
|
const errors = await effectiveErrorsConsent();
|
|
if (errors.source === 'env') {
|
|
return NextResponse.json({ error: 'error counts are set by the BULWARK_TELEMETRY_ERRORS env var' }, { status: 409 });
|
|
}
|
|
if (errors.source === 'no-third-party') {
|
|
return NextResponse.json({ error: 'telemetry is off while NO_THIRD_PARTY_TRAFFIC is on' }, { status: 409 });
|
|
}
|
|
if (body.consent !== 'on' && body.consent !== 'off') {
|
|
return NextResponse.json({ error: 'consent must be "on" or "off"' }, { status: 400 });
|
|
}
|
|
if (body.consent === 'on' && errors.source === 'telemetry') {
|
|
return NextResponse.json({ error: 'turn on the heartbeat first' }, { status: 409 });
|
|
}
|
|
const consent = body.consent;
|
|
let before: string | undefined;
|
|
await updateState((state) => {
|
|
before = state.errorsConsent ?? 'off';
|
|
state.errorsConsent = consent;
|
|
});
|
|
await auditLog('telemetry.set-errors-consent', { from: before, to: consent }, ip);
|
|
return NextResponse.json({ ok: true });
|
|
}
|
|
|
|
if (body.action === 'set-endpoint') {
|
|
if (typeof body.endpoint !== 'string') {
|
|
return NextResponse.json({ error: 'endpoint required' }, { status: 400 });
|
|
}
|
|
const trimmed = body.endpoint.trim();
|
|
if (trimmed) {
|
|
const check = await resolveEndpointAllowed(trimmed);
|
|
if (!check.ok) {
|
|
return NextResponse.json({ error: check.reason }, { status: 400 });
|
|
}
|
|
}
|
|
let before: string | undefined;
|
|
// Empty means no collector: heartbeats stop until a URL is set again.
|
|
const state = await updateState((current) => {
|
|
before = current.endpoint;
|
|
current.endpoint = trimmed;
|
|
});
|
|
await reschedule();
|
|
await auditLog('telemetry.set-endpoint', { from: before, to: state.endpoint }, ip);
|
|
return NextResponse.json({ ok: true, endpoint: state.endpoint });
|
|
}
|
|
|
|
if (body.action === 'send-now') {
|
|
const result = await sendOnce({ reason: 'admin-manual' });
|
|
await auditLog(
|
|
'telemetry.send-now',
|
|
{ ok: result.ok, status: result.status ?? null, error: result.error ?? null },
|
|
ip,
|
|
);
|
|
return NextResponse.json(result, { status: result.ok ? 200 : 502 });
|
|
}
|
|
|
|
return NextResponse.json({ error: 'unknown action' }, { status: 400 });
|
|
} catch (err) {
|
|
logger.error('telemetry POST error', {
|
|
error: err instanceof Error ? err.message : 'unknown',
|
|
});
|
|
const unavailable = storedDataUnavailableResponse(err);
|
|
if (unavailable) return unavailable;
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|