import "server-only"; import { createClient } from "@supabase/supabase-js"; import { randomUUID } from "node:crypto"; import sharp from "sharp"; import { NextRequest, NextResponse } from "next/server"; import { admin, authClient, required, user, rawUser, accountState, } from "./supabase"; import { accountProfile } from "./profile"; import { browserAccount } from "./request-security"; import { ServiceError } from "./service-error"; import { authActionSchema, authDestination, passwordUpdateSchema, profileUpdateSchema, initialPasswordSchema, } from "@/shared/account"; import { createHash } from "node:crypto"; import { recoveryCookie, verifyRecoveryGrant } from "./password-recovery"; import { requestOrigin } from "./request-origin"; import { issueInitialPassword } from "@/shared/initial-password"; import { profileAvatarBucket, profileAvatarMaxBytes, } from "@/shared/profile-appearance"; type Context = { jsonBody: (r: NextRequest) => Promise; limit: (key: string, count: number, seconds: number) => Promise; }; const json = (body: unknown, status = 200) => NextResponse.json(body, { status, headers: { "Cache-Control": "no-store" } }); const avatarRequestLimit = profileAvatarMaxBytes + 64 * 1024; async function avatarFile(request: NextRequest): Promise { const contentType = request.headers.get("content-type") ?? ""; if (!/^multipart\/form-data\s*;.*\bboundary=/i.test(contentType)) throw new ServiceError("Choose a valid profile photo", 400); const contentLength = request.headers.get("content-length"); if ( contentLength && /^\d+$/.test(contentLength) && Number(contentLength) > avatarRequestLimit ) throw new ServiceError("Image must be 5 MB or smaller", 413); const reader = request.body?.getReader(); if (!reader) throw new ServiceError("Choose a profile photo", 400); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > avatarRequestLimit) { try { await reader.cancel(); } catch { // The request is already being rejected; cancellation is best effort. } throw new ServiceError("Image must be 5 MB or smaller", 413); } chunks.push(value); } let form: FormData; try { const boundedRequest = new Request(request.url, { method: "POST", headers: { "content-type": contentType }, body: Buffer.concat(chunks, size), }); form = await boundedRequest.formData(); } catch { throw new ServiceError("Choose a valid profile photo", 400); } const entries = [...form.entries()]; if (entries.length !== 1) throw new ServiceError("Choose a valid profile photo", 400); const entry = entries[0]!; if ( entry[0] !== "photo" || typeof entry[1] === "string" || !(entry[1] instanceof File) ) throw new ServiceError("Choose a valid profile photo", 400); const file = entry[1]; if (!file.size || file.size > profileAvatarMaxBytes) throw new ServiceError("Image must be 5 MB or smaller", 413); return file; } async function normalizedAvatar(file: File) { try { const image = sharp(Buffer.from(await file.arrayBuffer()), { limitInputPixels: 24_000_000, failOn: "error", animated: false, }); const metadata = await image.metadata(); if ( !["jpeg", "png", "webp"].includes(metadata.format ?? "") || (metadata.pages ?? 1) !== 1 ) throw new ServiceError("Choose a JPG, PNG or WebP image", 400); const output = await image .rotate() .resize({ width: 512, height: 512, fit: "inside", withoutEnlargement: true, }) .webp({ quality: 85 }) .toBuffer(); if (output.byteLength > profileAvatarMaxBytes) throw new ServiceError("Image must be 5 MB or smaller", 413); return output; } catch (error) { if (error instanceof ServiceError) throw error; throw new ServiceError("Choose a valid JPG, PNG or WebP image", 400); } } export async function accountApi( request: NextRequest, path: string[], context: Context, ) { if ( path[0] === "auth" && path[1] === "logout" && path.length === 2 && request.method === "POST" ) { const client = await authClient(); const { error } = await client.auth.signOut({ scope: "local" }); if (error) throw new Error("Could not sign out. Please try again."); const response = json({ ok: true }); for (const name of ["mg_guest", "mg_multiplayer"]) response.cookies.set(name, "", { path: "/", maxAge: 0, httpOnly: true, sameSite: "lax", }); return response; } if (path[0] === "profile" && path[1] === "avatar" && path.length === 2) { if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); const account = await browserAccount(request, user); await context.limit(`profile:avatar:${account.id}`, 10, 3600); const file = await avatarFile(request); const image = await normalizedAvatar(file); const profileBefore = await accountProfile(account); const avatarPath = `${account.id}/${randomUUID()}.webp`; const storage = admin().storage.from(profileAvatarBucket); const upload = await storage.upload(avatarPath, image, { cacheControl: "31536000", contentType: "image/webp", upsert: false, }); if (upload.error) throw new ServiceError( "Profile photo upload failed. Please try again.", 503, ); const profile = await (async () => { try { return required( await admin() .from("profiles") .update({ avatar_path: avatarPath }) .eq("id", account.id) .select( "id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon", ) .single(), ); } catch (error) { await storage.remove([avatarPath]).catch(() => {}); throw error; } })(); if ( profileBefore.avatar_path && profileBefore.avatar_path.startsWith(`${account.id}/`) && profileBefore.avatar_path.split("/").length === 2 ) await storage.remove([profileBefore.avatar_path]).catch(() => {}); return json({ profile }); } if ( path[0] === "auth" && path[1] === "initial-password" && path.length === 2 ) { const account = await rawUser(); if (!account?.email || !account.email_confirmed_at) return json({ error: "Log in to manage your account" }, 401); if (request.method === "GET") { return json({ needed: required( await admin().rpc("legacy_password_needed", { p_user: account.id }), ), }); } if (request.method !== "POST") return json({ error: "Method not allowed" }, 405); const body = initialPasswordSchema.parse(await context.jsonBody(request)); await context.limit(`initial-password:${account.id}`, 3, 600); await issueInitialPassword(account.id, body.password, { claim: async (id) => required(await admin().rpc("claim_legacy_password", { p_user: id })), update: async (id, password) => { const { error } = await admin().auth.admin.updateUserById(id, { password, }); if (error) throw new Error( "Password setup could not be confirmed. Use password recovery before signing out.", ); }, }); return json({ ok: true }); } if (path[0] === "profile" && path.length === 1) { const account = await browserAccount(request, user); if (request.method === "GET") return json({ profile: await accountProfile(account), email: account.email, }); if (request.method === "PATCH") { const body = profileUpdateSchema.parse(await context.jsonBody(request)); const current = await accountProfile(account); if ( body.avatarPath !== undefined && body.avatarPath !== null && body.avatarPath !== current.avatar_path ) return json( { error: "Upload a profile photo through the avatar form" }, 400, ); if (body.glowColor !== undefined && !current.name_glow) return json( { error: "Earn a username glow before choosing its color" }, 403, ); const profile = required( await admin() .from("profiles") .update({ ...(body.displayName !== undefined ? { display_name: body.displayName } : {}), ...(body.glowColor !== undefined ? { glow_color: body.glowColor } : {}), ...(body.avatarPath !== undefined ? { avatar_path: body.avatarPath } : {}), ...(body.bio !== undefined ? { bio: body.bio } : {}), }) .eq("id", account.id) .select( "id,display_name,bio,avatar_path,profile_effect,role,name_glow,glow_color,bug_finder,custom_prefix,custom_prefix_icon", ) .single(), ); if ( body.avatarPath !== undefined && body.avatarPath !== current.avatar_path ) { if ( current.avatar_path?.startsWith(`${account.id}/`) && current.avatar_path.split("/").length === 2 ) await admin() .storage.from(profileAvatarBucket) .remove([current.avatar_path]); } return json({ profile }); } return json({ error: "Method not allowed" }, 405); } if (path[0] !== "auth" || request.method !== "POST" || path.length > 2) return json({ error: "Not found" }, 404); const client = await authClient(); if (path.length === 1) { const body = authActionSchema.parse(await context.jsonBody(request)); const identity = createHash("sha256") .update(body.email.toLowerCase()) .digest("hex"); await context.limit( `auth:${body.action}:${identity}`, body.action === "login" ? 15 : 5, 600, ); const origin = requestOrigin(request, process.env.MINEGEO_URL); const next = authDestination("next" in body ? body.next : null); const callback = new URL("/auth/callback", origin); callback.searchParams.set( "type", body.action === "recover" ? "recovery" : "signup", ); callback.searchParams.set( "next", body.action === "recover" ? "/auth/reset-password" : next === "/auth/reset-password" ? "/dashboard" : next, ); if (body.action === "login") { const { data, error } = await client.auth.signInWithPassword({ email: body.email, password: body.password, }); if (error) return json( { error: error.code === "email_not_confirmed" ? "Confirm your email address before logging in." : "Email or password is incorrect. Try again or reset your password.", }, 401, ); return json({ next: data.user && (await accountState(data.user.id)) !== "active" ? "/ban/appeal" : next, }); } if (body.action === "signup") { const { data, error } = await client.auth.signUp({ email: body.email, password: body.password, options: { data: { display_name: body.displayName }, emailRedirectTo: callback.toString(), }, }); if (error) throw new Error(error.message); if (data.session && data.user) { await accountProfile(data.user); return json({ next }); } return json({ message: "Check your email to confirm your account. If you already have an account, log in or reset your password.", }); } const { error } = await client.auth.resetPasswordForEmail(body.email, { redirectTo: callback.toString(), }); if ( error && !["user_not_found", "signup_disabled"].includes(error.code ?? "") ) throw new Error(error.message); return json({ message: "If an account exists for this email, a password reset link is on its way. Check your inbox.", }); } if (path[1] !== "password") return json({ error: "Not found" }, 404); const account = await rawUser(); if (!account?.email) return json({ error: "Log in before changing your password" }, 401); const body = passwordUpdateSchema.parse(await context.jsonBody(request)); await context.limit(`password:${account.id}`, 10, 600); const { data: claimData, error: claimError } = await client.auth.getClaims(); const sessionId = typeof claimData?.claims.session_id === "string" ? claimData.claims.session_id : ""; if (claimError || !sessionId) return json({ error: "Your session expired. Log in again." }, 401); if (body.currentPassword) { // Verify through Auth without replacing this browser's session. const verifier = createClient( process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, { auth: { persistSession: false, autoRefreshToken: false } }, ); const { error } = await verifier.auth.signInWithPassword({ email: account.email, password: body.currentPassword, }); if (error) return json( { error: "Your current password is incorrect. Use an email reset link if you have not set a password yet.", }, 403, ); await verifier.auth.signOut({ scope: "local" }); } else if ( !verifyRecoveryGrant( request.cookies.get(recoveryCookie)?.value, account.id, sessionId, process.env.SUPABASE_SECRET_KEY!, ) ) { return json( { error: "Enter your current password or open a fresh password reset link from your email.", }, 403, ); } const { error } = await client.auth.updateUser({ password: body.password, ...(body.currentPassword ? { current_password: body.currentPassword } : {}), ...(body.nonce ? { nonce: body.nonce } : {}), }); if (error) return json( { error: error.message, needsReauthentication: error.code === "reauthentication_needed", }, 400, ); const { error: signOutError } = await client.auth.signOut({ scope: "others", }); const response = json({ ok: true, message: signOutError ? "Password updated. Other sessions could not be signed out; please try signing out other devices again." : "Password updated. Other devices have been signed out.", }); response.cookies.set(recoveryCookie, "", { httpOnly: true, sameSite: "lax", path: "/", maxAge: 0, }); return response; }