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;
}