mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
Admins had logs and nothing else: client errors only reached the browser console. An admin can now set a Sentry-protocol address (DSN) for Bugsink, GlitchTip or Sentry under System > Monitoring, or with `ERROR_REPORTING_DSN`, `ERROR_REPORTING_DSN_FILE`, `ERROR_REPORTING_SAMPLE_RATE` and `ERROR_REPORTING_ENVIRONMENT`. Bulwark Lite reads the same from `errorReporting` in policy.json. Everything stays off while no address is set. - browsers report errors from the error boundaries, window.onerror, unhandled rejections and failed JMAP requests (method and error type only), with route and JMAP method breadcrumbs and the chunks' debug IDs - the server reports what Next hands to onRequestError and what routes log with logger.error - every report is scrubbed first: quoted text, addresses, URLs, file names, tokens and the rest after the first line are removed, and the server scrubs what browsers post to /api/errors again - reports are deduplicated and rate-limited per session and process - errors are counted per day for "errors today" on the admin page - startup messages and audit entries go through the logger, so `LOG_FORMAT=json` covers them
82 lines
2.8 KiB
TypeScript
82 lines
2.8 KiB
TypeScript
/**
|
|
* Keeps error reporting from flooding anyone: each fingerprint once per
|
|
* window, at most `perMinute` reports in any minute and `perSession` in all.
|
|
* The browser keeps its state in sessionStorage, so a page that crashes on
|
|
* every load doesn't start over each time.
|
|
*/
|
|
|
|
export interface LimiterOptions {
|
|
perMinute: number;
|
|
perSession: number;
|
|
/** How long a fingerprint stays "already sent"; Infinity for the whole session. */
|
|
dedupWindowMs: number;
|
|
/** Persist across page loads (sessionStorage); absent on the server. */
|
|
storage?: Pick<Storage, 'getItem' | 'setItem'> | null;
|
|
storageKey?: string;
|
|
now?: () => number;
|
|
}
|
|
|
|
interface State {
|
|
total: number;
|
|
sent: Record<string, number>;
|
|
}
|
|
|
|
const MAX_REMEMBERED = 500;
|
|
|
|
export class ReportLimiter {
|
|
private readonly options: Required<Omit<LimiterOptions, 'storage'>> & { storage: LimiterOptions['storage'] };
|
|
private recent: number[] = [];
|
|
private state: State = { total: 0, sent: {} };
|
|
|
|
constructor(options: LimiterOptions) {
|
|
this.options = { storageKey: 'bulwark.error-reporting', now: () => Date.now(), storage: null, ...options };
|
|
this.load();
|
|
}
|
|
|
|
private load(): void {
|
|
const { storage, storageKey } = this.options;
|
|
if (!storage) return;
|
|
try {
|
|
const raw = storage.getItem(storageKey);
|
|
if (!raw) return;
|
|
const parsed = JSON.parse(raw) as Partial<State>;
|
|
if (typeof parsed.total === 'number' && parsed.sent && typeof parsed.sent === 'object') {
|
|
this.state = { total: parsed.total, sent: parsed.sent as Record<string, number> };
|
|
}
|
|
} catch {
|
|
// Unreadable or blocked storage: start fresh, in memory.
|
|
}
|
|
}
|
|
|
|
private save(): void {
|
|
const { storage, storageKey } = this.options;
|
|
if (!storage) return;
|
|
try {
|
|
storage.setItem(storageKey, JSON.stringify(this.state));
|
|
} catch {
|
|
// Quota or privacy mode: the in-memory state still applies.
|
|
}
|
|
}
|
|
|
|
/** Whether a report with this fingerprint may go out now; counts it when it may. */
|
|
allow(fingerprint: string): boolean {
|
|
const now = this.options.now();
|
|
const last = this.state.sent[fingerprint];
|
|
if (last !== undefined && now - last < this.options.dedupWindowMs) return false;
|
|
if (this.state.total >= this.options.perSession) return false;
|
|
this.recent = this.recent.filter((at) => now - at < 60_000);
|
|
if (this.recent.length >= this.options.perMinute) return false;
|
|
this.recent.push(now);
|
|
this.state.total += 1;
|
|
this.state.sent[fingerprint] = now;
|
|
const keys = Object.keys(this.state.sent);
|
|
if (keys.length > MAX_REMEMBERED) {
|
|
for (const key of keys.sort((a, b) => this.state.sent[a] - this.state.sent[b]).slice(0, keys.length - MAX_REMEMBERED)) {
|
|
delete this.state.sent[key];
|
|
}
|
|
}
|
|
this.save();
|
|
return true;
|
|
}
|
|
}
|