import { sql } from "drizzle-orm"; import { NextResponse } from "next/server"; import { z } from "zod"; import { auth } from "@/app/(auth)/auth"; import { db } from "@/lib/db"; import { clientPerformanceEvent } from "@/lib/db/schema"; export const runtime = "nodejs"; export const maxDuration = 10; const SLOW_EVENT_MIN_DURATION_MS = 120; const RATE_LIMIT_WINDOW_MS = 60_000; const RATE_LIMIT_MAX_EVENTS = 20; const TEXT_MAX_LENGTH = 256; const allowedSections = [ "brand", "style", "mode", "materials", "generate", "references", "results", "unknown", ] as const; const eventSchema = z.object({ eventType: z.string().trim().min(1).max(64), durationMs: z.coerce.number().finite().min(0).max(120_000), blockingDurationMs: z.coerce.number().finite().min(0).max(120_000).optional(), section: z.enum(allowedSections).optional(), action: z.string().trim().max(64).optional(), domNodeCount: z.coerce.number().int().min(0).max(1_000_000).optional(), visibleImageCount: z.coerce.number().int().min(0).max(100_000).optional(), loadedImageCount: z.coerce.number().int().min(0).max(100_000).optional(), viewport: z .object({ width: z.coerce.number().finite().min(0).max(20_000).optional(), height: z.coerce.number().finite().min(0).max(20_000).optional(), dpr: z.coerce.number().finite().min(0).max(8).optional(), }) .optional(), browser: z.string().trim().max(128).optional(), path: z.string().trim().max(TEXT_MAX_LENGTH).optional(), projectId: z.string().trim().max(64).optional().nullable(), chatId: z.string().trim().uuid().optional().nullable(), timestamp: z.union([z.string(), z.number(), z.date()]).optional(), }); type RateWindow = { windowStart: number; count: number; }; const rateWindows = new Map(); function clampText(value: unknown, maxLength = TEXT_MAX_LENGTH) { const normalized = String(value || "").trim(); if (!normalized) return null; return normalized.slice(0, maxLength); } function stripPath(value: unknown) { const normalized = clampText(value); if (!normalized) return null; if (/https?:\/\//i.test(normalized)) { try { return new URL(normalized).pathname.slice(0, TEXT_MAX_LENGTH) || "/"; } catch { return "/"; } } return normalized.split("?")[0].split("#")[0].slice(0, TEXT_MAX_LENGTH) || "/"; } function getRequesterIp(request: Request) { const forwarded = request.headers.get("x-forwarded-for"); const firstForwarded = forwarded?.split(",")[0]?.trim(); return ( firstForwarded || request.headers.get("x-real-ip") || request.headers.get("cf-connecting-ip") || "unknown" ).slice(0, 64); } function isRateLimited(key: string) { const now = Date.now(); const current = rateWindows.get(key); if (!current || now - current.windowStart >= RATE_LIMIT_WINDOW_MS) { rateWindows.set(key, { windowStart: now, count: 1 }); return false; } current.count += 1; return current.count > RATE_LIMIT_MAX_EVENTS; } function normalizeOccurredAt(value: unknown) { if (!value) return new Date(); const date = value instanceof Date ? value : typeof value === "number" ? new Date(value) : new Date(String(value)); return Number.isNaN(date.getTime()) ? new Date() : date; } function includesDisallowedPayloadKeys(value: unknown) { if (!value || typeof value !== "object") return false; const keys = Object.keys(value as Record).map((key) => key.toLowerCase()); return keys.some((key) => [ "prompt", "image", "imageurl", "imageurls", "thumbnailurl", "originalurl", "url", "urls", "input", "text", "content", ].includes(key) ); } async function maybePruneOldEvents() { if (Math.random() > 0.01) return; await db.execute(sql` DELETE FROM "ClientPerformanceEvent" WHERE "createdAt" < NOW() - INTERVAL '14 days' `); } export async function POST(request: Request) { try { const contentType = request.headers.get("content-type") || ""; if (!contentType.toLowerCase().includes("application/json")) { return NextResponse.json({ ok: true }); } const raw = await request.json().catch(() => null); if (!raw || typeof raw !== "object" || includesDisallowedPayloadKeys(raw)) { return NextResponse.json({ ok: true }); } const parsed = eventSchema.safeParse(raw); if (!parsed.success) { return NextResponse.json({ ok: true }); } const event = parsed.data; if (event.durationMs < SLOW_EVENT_MIN_DURATION_MS) { return NextResponse.json({ ok: true }); } const session = await auth().catch(() => null); const requesterIp = getRequesterIp(request); const rateKey = `${session?.user?.id || "anon"}:${requesterIp}`; if (isRateLimited(rateKey)) { return NextResponse.json({ ok: true }); } await maybePruneOldEvents().catch((error) => { console.warn("[ClientPerformance] prune failed", error); }); await db.insert(clientPerformanceEvent).values({ userId: session?.user?.id || null, chatId: event.chatId || null, projectId: clampText(event.projectId, 64), eventType: clampText(event.eventType, 64) || "unknown", section: event.section || "unknown", action: clampText(event.action, 64), path: stripPath(event.path), durationMs: Math.round(event.durationMs), blockingDurationMs: Math.round(event.blockingDurationMs || 0), domNodeCount: event.domNodeCount || 0, visibleImageCount: event.visibleImageCount || 0, loadedImageCount: event.loadedImageCount || 0, viewport: event.viewport || null, browser: clampText(event.browser, 128), metadata: { source: "agent-style-client", }, requesterIp, requesterUserAgent: clampText(request.headers.get("user-agent"), 512), occurredAt: normalizeOccurredAt(event.timestamp), }); return NextResponse.json({ ok: true }); } catch (error) { console.error("[ClientPerformance] failed to record agent style event:", error); return NextResponse.json({ ok: true }); } }