import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import { z } from "zod"; import { gravatarUrl } from "@/lib/gravatar"; import { USERNAME_PATTERN, isUsernameTaken } from "@/lib/username"; import { isOwnedAvatarKey, getPublicUrl } from "@/lib/storage"; import { checkTierLimitInTransaction, TierLimitError } from "@/lib/tiers"; const PatchSchema = z.object({ name: z.string().min(1).max(100).optional(), locale: z.string().max(10).optional(), bio: z.string().max(500).optional().nullable(), privateBio: z.string().max(2000).optional().nullable(), isPrivate: z.boolean().optional(), username: z.string().trim().toLowerCase().regex(USERNAME_PATTERN, "3-20 characters, lowercase letters, numbers, and underscores only").optional(), // The storage key returned by /api/v1/upload/avatar-presign (not a URL) — // the server validates it was actually issued to this user and builds the // public URL itself, so a client can't point avatarUrl at an arbitrary or // another user's object. `null` reverts to the initials fallback (or // Gravatar, if useGravatar is on — see below). avatarKey: z.string().max(500).optional().nullable(), // Size (MB, rounded up) of the file behind avatarKey — as returned by // /api/v1/upload/avatar-presign. Ignored unless avatarKey is set. avatarSizeMb: z.number().int().min(0).max(50).default(0), // Off by default (Settings → Profile) — Gravatar is looked up by an MD5 // hash of the user's email, sent to a third party. useGravatar: z.boolean().optional(), }); export async function PATCH(req: Request) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); const body = PatchSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); if (body.data.username !== undefined && (await isUsernameTaken(body.data.username, session.user.id))) { return NextResponse.json({ error: "Username already taken" }, { status: 409 }); } const { avatarKey, avatarSizeMb, useGravatar, ...rest } = body.data; const updates: Partial = { ...rest }; // useGravatar is only ever toggled from the settings form, never alongside // an avatar upload/removal in the same request — so it's fine for these // two branches to each independently decide the resulting avatarUrl below. if (useGravatar !== undefined) { updates.useGravatar = useGravatar; if (!(await hasCustomAvatar(session.user.id))) { updates.avatarUrl = useGravatar ? gravatarUrl(session.user.email) : null; } } let avatarSizeDeltaMb = 0; if (avatarKey !== undefined) { if (avatarKey === null) { const gravatarOptedIn = useGravatar ?? (await getUseGravatar(session.user.id)); updates.avatarUrl = gravatarOptedIn ? gravatarUrl(session.user.email) : null; updates.hasCustomAvatar = false; updates.avatarSizeMb = 0; } else { if (!isOwnedAvatarKey(avatarKey, session.user.id)) { return NextResponse.json({ error: "Invalid avatar key" }, { status: 400 }); } const currentAvatarSizeMb = await getAvatarSizeMb(session.user.id); avatarSizeDeltaMb = avatarSizeMb - currentAvatarSizeMb; updates.avatarUrl = getPublicUrl(avatarKey); updates.hasCustomAvatar = true; updates.avatarSizeMb = avatarSizeMb; } } try { await db.transaction(async (tx) => { if (avatarSizeDeltaMb > 0) { await checkTierLimitInTransaction(tx, session.user.id, session.user.tier as "free" | "pro" | "family", "storage", avatarSizeDeltaMb); } await tx.update(users).set(updates).where(eq(users.id, session.user.id)); }); } catch (err) { if (err instanceof TierLimitError) { return NextResponse.json({ error: "Storage limit reached for your tier" }, { status: 403 }); } throw err; } return NextResponse.json({ ok: true, avatarUrl: updates.avatarUrl }); } async function hasCustomAvatar(userId: string): Promise { const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { hasCustomAvatar: true } }); return row?.hasCustomAvatar ?? false; } async function getUseGravatar(userId: string): Promise { const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { useGravatar: true } }); return row?.useGravatar ?? false; } async function getAvatarSizeMb(userId: string): Promise { const row = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { avatarSizeMb: true } }); return row?.avatarSizeMb ?? 0; }