File size: 2,594 Bytes
4bea006 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 | import { NextRequest, NextResponse } from 'next/server';
/**
* Sink for client-side exceptions caught by the error boundaries.
*
* Writes one line to the container log, which is readable through the Coolify API. That
* keeps error reporting entirely on UPB infrastructure — no third-party service, and
* nothing to configure on a host with no outbound internet.
*
* This endpoint is public, because the errors worth catching happen before any session
* exists. So it treats its input as hostile: the body is size-capped, every field is
* truncated, and the whole route is rate-limited, since its only effect is to write to a
* log that someone could otherwise flood.
*/
const MAX_BODY_BYTES = 16_000;
const MAX_MESSAGE_CHARS = 500;
const MAX_STACK_CHARS = 4_000;
const MAX_FIELD_CHARS = 300;
// Per-instance throttle. Not a security boundary — a cheap ceiling on log volume.
const WINDOW_MS = 60_000;
const MAX_REPORTS_PER_WINDOW = 60;
let windowStartedAt = 0;
let reportsThisWindow = 0;
function withinRateLimit(now: number): boolean {
if (now - windowStartedAt > WINDOW_MS) {
windowStartedAt = now;
reportsThisWindow = 0;
}
reportsThisWindow += 1;
return reportsThisWindow <= MAX_REPORTS_PER_WINDOW;
}
/** Coerce anything to a single-line, length-capped string. */
function clean(value: unknown, max: number): string {
if (typeof value !== 'string') return '';
return value.replace(/\s+/g, ' ').trim().slice(0, max);
}
export async function POST(request: NextRequest) {
// 204 on every path below: the caller is an error boundary and must never be handed
// another failure to deal with.
const ok = new NextResponse(null, { status: 204 });
if (!withinRateLimit(Date.now())) return ok;
let raw: string;
try {
raw = await request.text();
} catch {
return ok;
}
if (raw.length > MAX_BODY_BYTES) return ok;
let body: Record<string, unknown>;
try {
body = JSON.parse(raw);
} catch {
return ok;
}
const message = clean(body.message, MAX_MESSAGE_CHARS);
const digest = clean(body.digest, MAX_FIELD_CHARS);
const pathname = clean(body.pathname, MAX_FIELD_CHARS);
const userAgent = clean(body.userAgent, MAX_FIELD_CHARS);
// Newlines are what make a stack readable, so keep them — but cap the length.
const stack =
typeof body.stack === 'string' ? body.stack.slice(0, MAX_STACK_CHARS) : '';
if (!message && !stack && !digest) return ok;
console.error(
`CLIENT ERROR: ${JSON.stringify({ message, digest, pathname, userAgent })}` +
(stack ? `\n${stack}` : ''),
);
return ok;
}
|