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 { ConflictError, errorResponse, NotFoundError, ValidationError } from '@/lib/errors'; import { isReservedUsername, USERNAME_REGEX } from '@/lib/validators/username'; import { sql } from 'drizzle-orm'; import { z } from 'zod'; const updateProfileSchema = z.object({ firstName: z.string().min(1).max(120).nullable().optional(), lastName: z.string().min(1).max(120).nullable().optional(), displayName: z.string().min(1).max(200).optional(), /** * Optional sign-in alias. `null` clears the existing value; a string * must match the 2–30 lowercase shape pinned by USERNAME_REGEX (also * enforced by `chk_user_profiles_username_shape` in migration 0054). * Uniqueness is checked below before the UPDATE — collisions surface * as a 409 with a friendly message. */ username: z .union([ z.string().transform((s) => s.trim().toLowerCase()), z.null(), ]) .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(), // Per-table column visibility. Keyed by entity type — entries // with an empty `hiddenColumns` mean "all visible". The validator // caps total entries / IDs so a malicious client can't bloat the // 8 KB preferences blob; see merge step below for the byte cap. tablePreferences: z .record( z.string().min(1).max(64), z .object({ hiddenColumns: z.array(z.string().min(1).max(64)).max(50).optional(), }) .strict(), ) .optional(), }) .strict() .optional(), }); export const GET = withAuth(async (_req, ctx: AuthContext) => { // Hydrate preferences from user_profiles so the client can read its // saved table-column visibility (and other prefs) without a second // round-trip on app boot. const profile = await db.query.userProfiles.findFirst({ where: eq(userProfiles.userId, ctx.userId), columns: { preferences: true, avatarFileId: true, avatarUrl: true, firstName: true, lastName: true, displayName: true, username: true, }, }); return NextResponse.json({ data: { userId: ctx.userId, portId: ctx.portId, portSlug: ctx.portSlug, permissions: ctx.permissions, isSuperAdmin: ctx.isSuperAdmin, user: ctx.user, preferences: profile?.preferences ?? {}, profile: { avatarFileId: profile?.avatarFileId ?? null, avatarUrl: profile?.avatarUrl ?? null, firstName: profile?.firstName ?? null, lastName: profile?.lastName ?? null, displayName: profile?.displayName ?? null, username: profile?.username ?? null, }, }, }); }); 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.firstName !== undefined) updates.firstName = body.firstName; if (body.lastName !== undefined) updates.lastName = body.lastName; 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.username !== undefined) { // null clears; non-null must match the shape + not be reserved + // be unique (case-insensitive) across all users in the install. if (body.username === null || body.username === '') { updates.username = null; } else { const candidate = body.username; if (!USERNAME_REGEX.test(candidate)) { throw new ValidationError( 'Username must be 2–30 lowercase letters, digits, dot, underscore, or hyphen.', ); } if (isReservedUsername(candidate)) { throw new ValidationError('That username is reserved. Please pick another.'); } const taken = await db .select({ userId: userProfiles.userId }) .from(userProfiles) .where(sql`LOWER(${userProfiles.username}) = ${candidate}`) .limit(1); if (taken.length > 0 && taken[0]!.userId !== ctx.userId) { throw new ConflictError('That username is already taken.'); } updates.username = candidate; } } if (body.preferences !== undefined) { // Allow-list — only retain keys defined in the strict schema. Pre- // strict rows may carry extra keys from when the schema was // .passthrough(); the merge prunes them so legacy bloat doesn't // accumulate forever, and a future schema regression that tries // to ship arbitrary keys still gets dropped here at write time. const ALLOWED_PREF_KEYS = new Set(['dark_mode', 'locale', 'timezone', 'tablePreferences']); const existing = (profile.preferences as Record) ?? {}; const merged = Object.fromEntries( Object.entries({ ...existing, ...body.preferences }).filter(([k]) => ALLOWED_PREF_KEYS.has(k), ), ); // Hard cap on the merged JSONB — defense in depth against any // future schema growth that might re-introduce free-form keys. 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, firstName: updated!.firstName, lastName: updated!.lastName, displayName: updated!.displayName, username: updated!.username, phone: updated!.phone, avatarUrl: updated!.avatarUrl, preferences: updated!.preferences, }, }); } catch (error) { return errorResponse(error); } });