import { NextResponse } from 'next/server'; import { eq } from 'drizzle-orm'; import { withAuth, type AuthContext } from '@/lib/api/helpers'; import { parseBody } from '@/lib/api/route-helpers'; import { db } from '@/lib/db'; import { userProfiles } from '@/lib/db/schema'; import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; import { z } from 'zod'; const updateProfileSchema = z.object({ displayName: z.string().min(1).max(200).optional(), phone: z.string().nullable().optional(), // Refuse `javascript:` / `data:` schemes โ€” z.string().url() lets them // through and `` would otherwise be a stored-XSS // vector if any future renderer treated the value as a link. avatarUrl: z .string() .url() .refine((u) => /^https?:\/\//i.test(u), 'must be an http(s) URL') .nullable() .optional(), // Strict allow-list โ€” no `.passthrough()` here. The previous schema let // arbitrary client-supplied keys survive validation and persist into // `userProfiles.preferences` JSONB unbounded; auditor-E3 ยง28 caught this. // Add new keys here as the UI surfaces them rather than letting the // client mint them at will. preferences: z .object({ dark_mode: z.boolean().optional(), locale: z.string().optional(), timezone: z.string().optional(), }) .strict() .optional(), }); export const GET = withAuth(async (_req, ctx: AuthContext) => { return NextResponse.json({ data: { userId: ctx.userId, portId: ctx.portId, portSlug: ctx.portSlug, permissions: ctx.permissions, isSuperAdmin: ctx.isSuperAdmin, user: ctx.user, }, }); }); export const PATCH = withAuth(async (req, ctx: AuthContext) => { try { const body = await parseBody(req, updateProfileSchema); const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId), }); if (!profile) throw new NotFoundError('profile'); const updates: Record = { updatedAt: new Date() }; if (body.displayName !== undefined) updates.displayName = body.displayName; if (body.phone !== undefined) updates.phone = body.phone; if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl; if (body.preferences !== undefined) { const merged = { ...((profile.preferences as Record) ?? {}), ...body.preferences, }; // Hard cap on the merged JSONB to defend against historical rows // bloated by the previous .passthrough() schema. 8 KB is generous // โ€” current legitimate keys are 3 booleans/strings. const serialized = JSON.stringify(merged); if (Buffer.byteLength(serialized, 'utf8') > 8 * 1024) { throw new ValidationError('preferences exceeds 8KB'); } updates.preferences = merged; } const [updated] = await db .update(userProfiles) .set(updates) .where(eq(userProfiles.userId, ctx.userId)) .returning(); return NextResponse.json({ data: { userId: updated!.userId, displayName: updated!.displayName, phone: updated!.phone, avatarUrl: updated!.avatarUrl, preferences: updated!.preferences, }, }); } catch (error) { return errorResponse(error); } });