From 26ebf2e595b793aa69f9556c32e0221e495d4067 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Wed, 21 Oct 2026 23:54:07 -0400 Subject: [PATCH] feat(site): add profile photo upload to guest portal (#772) --- apps/site/src/lib/sanity/write-client.ts | 100 +++++++++++++ .../site/src/pages/api/guest-portal/update.ts | 28 +++- .../site/src/pages/guest-portal/[token].astro | 134 ++++++++++++++++-- 3 files changed, 252 insertions(+), 10 deletions(-) diff --git a/apps/site/src/lib/sanity/write-client.ts b/apps/site/src/lib/sanity/write-client.ts index 12156f33..f26c9967 100644 --- a/apps/site/src/lib/sanity/write-client.ts +++ b/apps/site/src/lib/sanity/write-client.ts @@ -240,3 +240,103 @@ export function getSanityServerClient(): { canWrite: Boolean(writeToken), }; } + +export async function uploadSanityImageDataUrl( + dataUrl: string, + filenameBase = "guest-avatar", +): Promise<{ _id: string; url: string } | null> { + const workerEnv = (env ?? {}) as unknown as Record< + string, + string | undefined + >; + const projectId = workerEnv.PUBLIC_SANITY_PROJECT_ID || "hfh83o0w"; + const dataset = workerEnv.PUBLIC_SANITY_DATASET || "production"; + const apiVersion = workerEnv.PUBLIC_SANITY_API_VERSION || "2025-09-30"; + const writeToken = + workerEnv.SANITY_API_WRITE_TOKEN || + workerEnv.SANITY_AUTH_TOKEN || + workerEnv.SANITY_WRITE_TOKEN; + + if (!writeToken) { + return null; + } + + const match = dataUrl.match( + /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=]+)$/, + ); + if (!match) { + throw new Error( + "Invalid image format. Please upload a JPEG, PNG, or WebP image.", + ); + } + + const mimeType = match[1]; + const base64Data = match[2]; + const binaryStr = atob(base64Data); + const len = binaryStr.length; + + if (len < 64 || len > 2 * 1024 * 1024) { + throw new Error("Profile photo must be under 2 MB."); + } + + const bytes = new Uint8Array(len); + for (let i = 0; i < len; i++) { + bytes[i] = binaryStr.charCodeAt(i); + } + + // Verify magic bytes for JPEG (FF D8 FF), PNG (89 50 4E 47), or WebP (RIFF....WEBP) + const isJpeg = bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + const isPng = + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47; + const isWebp = + bytes[0] === 0x52 && + bytes[1] === 0x49 && + bytes[2] === 0x46 && + bytes[3] === 0x46 && + bytes[8] === 0x57 && + bytes[9] === 0x45 && + bytes[10] === 0x42 && + bytes[11] === 0x50; + + if (!isJpeg && !isPng && !isWebp) { + throw new Error("Invalid image file header."); + } + + const ext = + mimeType === "image/png" + ? "png" + : mimeType === "image/webp" + ? "webp" + : "jpg"; + const cleanFilename = `${filenameBase.replace(/[^a-zA-Z0-9_-]/g, "-")}.${ext}`; + + const uploadUrl = `https://${projectId}.api.sanity.io/v${apiVersion}/assets/images/${dataset}?filename=${encodeURIComponent(cleanFilename)}`; + const res = await fetch(uploadUrl, { + method: "POST", + headers: { + Authorization: `Bearer ${writeToken}`, + "Content-Type": mimeType, + }, + body: bytes, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`Sanity image upload failed (${res.status}): ${text}`); + } + + const json = (await res.json()) as { + document?: { _id?: string; url?: string }; + }; + if (!json.document?._id || !json.document?.url) { + throw new Error("Sanity did not return an uploaded image asset document."); + } + + return { + _id: json.document._id, + url: json.document.url, + }; +} diff --git a/apps/site/src/pages/api/guest-portal/update.ts b/apps/site/src/pages/api/guest-portal/update.ts index 3950661e..33479a75 100644 --- a/apps/site/src/pages/api/guest-portal/update.ts +++ b/apps/site/src/pages/api/guest-portal/update.ts @@ -5,7 +5,10 @@ import { sanitizePortableTextBlocks, verifyGuestPortalToken, } from "@/lib/guest-portal"; -import { getSanityServerClient } from "@/lib/sanity/write-client"; +import { + getSanityServerClient, + uploadSanityImageDataUrl, +} from "@/lib/sanity/write-client"; export const POST: APIRoute = async ({ request }) => { try { @@ -15,6 +18,7 @@ export const POST: APIRoute = async ({ request }) => { websiteUrl?: string; excerpt?: string; content?: unknown[]; + avatarDataUrl?: string; socials?: Record; }; @@ -129,6 +133,27 @@ export const POST: APIRoute = async ({ request }) => { } } + let uploadedAvatarUrl: string | undefined; + if ( + typeof body.avatarDataUrl === "string" && + body.avatarDataUrl.startsWith("data:image/") + ) { + const uploadedAsset = await uploadSanityImageDataUrl( + body.avatarDataUrl, + verification.payload?.slug || existingGuest._id, + ); + if (uploadedAsset) { + patchSet.coverImage = { + _type: "image", + asset: { + _type: "reference", + _ref: uploadedAsset._id, + }, + }; + uploadedAvatarUrl = uploadedAsset.url; + } + } + if (canWrite) { await client.patch(existingGuest._id).set(patchSet).commit(); } @@ -138,6 +163,7 @@ export const POST: APIRoute = async ({ request }) => { ok: true, guestId: existingGuest._id, socials: mergedSocials, + avatarUrl: uploadedAvatarUrl, persistedToSanity: canWrite, }), { status: 200, headers: { "Content-Type": "application/json" } }, diff --git a/apps/site/src/pages/guest-portal/[token].astro b/apps/site/src/pages/guest-portal/[token].astro index 122ca0cc..aaafcea8 100644 --- a/apps/site/src/pages/guest-portal/[token].astro +++ b/apps/site/src/pages/guest-portal/[token].astro @@ -197,13 +197,12 @@ const socialFields = [
- {guestDoc.avatarUrl && ( - {guestDoc.title} - )} + {guestDoc.title}
Verified Sanity Guest Magic Link @@ -271,6 +270,40 @@ const socialFields = [ data-token={token} class="rounded-2xl border border-border bg-card p-6 shadow-sm sm:p-8 space-y-6" > +
+
+ {guestDoc.title} +
+

+ Profile Photo +

+

+ Upload a new JPG, PNG, or WebP speaker headshot (automatically optimized before saving to Sanity). +

+
+
+
+ + +
+
+