199 lines
6.0 KiB
TypeScript
199 lines
6.0 KiB
TypeScript
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<string, RateWindow>();
|
|
|
|
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<string, unknown>).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 });
|
|
}
|
|
}
|