From 0e1bac97535e46fa86bd2344c2c037a1f2ddc9a5 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Wed, 21 Oct 2026 12:57:03 -0400 Subject: [PATCH] feat(site,sanity): add expiring magic-link Guest Profile Portal (/guest-portal/[token]) --- apps/sanity/schemas/partials/user.ts | 14 + apps/site/src/lib/guest-portal.ts | 201 ++++++++ .../site/src/pages/api/guest-portal/update.ts | 160 +++++++ .../site/src/pages/guest-portal/[token].astro | 437 ++++++++++++++++++ 4 files changed, 812 insertions(+) create mode 100644 apps/site/src/lib/guest-portal.ts create mode 100644 apps/site/src/pages/api/guest-portal/update.ts create mode 100644 apps/site/src/pages/guest-portal/[token].astro diff --git a/apps/sanity/schemas/partials/user.ts b/apps/sanity/schemas/partials/user.ts index f4baf6c2..274f21de 100644 --- a/apps/sanity/schemas/partials/user.ts +++ b/apps/sanity/schemas/partials/user.ts @@ -125,6 +125,20 @@ const content = defineType({ }), ], }), + defineField({ + name: "portalToken", + title: "Guest Portal Magic Token", + type: "string", + readOnly: true, + hidden: true, + }), + defineField({ + name: "portalTokenExpiresAt", + title: "Guest Portal Token Expiration", + type: "datetime", + readOnly: true, + hidden: true, + }), ], }); diff --git a/apps/site/src/lib/guest-portal.ts b/apps/site/src/lib/guest-portal.ts new file mode 100644 index 00000000..359c6f90 --- /dev/null +++ b/apps/site/src/lib/guest-portal.ts @@ -0,0 +1,201 @@ +import { env } from "cloudflare:workers"; + +export interface GuestPortalTokenPayload { + /** Sanity guest document _id */ + id: string; + /** Sanity guest slug.current */ + slug: string; + /** Expiration timestamp in unix milliseconds */ + exp: number; +} + +const DEFAULT_PORTAL_SIGNING_KEY = + "codingcat-guest-portal-hmac-v1-hfh83o0w-secret"; + +function getSigningSecret(): string { + const workerEnv = (env ?? {}) as unknown as Record< + string, + string | undefined + >; + return ( + workerEnv.GUEST_PORTAL_SECRET || + workerEnv.SANITY_API_WRITE_TOKEN || + DEFAULT_PORTAL_SIGNING_KEY + ); +} + +function toBase64Url(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); +} + +function fromBase64Url(str: string): Uint8Array { + const padded = + str.replace(/-/g, "+").replace(/_/g, "/") + + "===".slice((str.length + 3) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +async function computeHmacB64Url( + data: string, + secret: string, +): Promise { + const enc = new TextEncoder(); + const key = await crypto.subtle.importKey( + "raw", + enc.encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data)); + return toBase64Url(new Uint8Array(sig)).slice(0, 24); +} + +export async function createGuestPortalToken( + payload: GuestPortalTokenPayload, + secretOverride?: string, +): Promise { + const enc = new TextEncoder(); + const payloadB64 = toBase64Url(enc.encode(JSON.stringify(payload))); + const sig = await computeHmacB64Url( + payloadB64, + secretOverride || DEFAULT_PORTAL_SIGNING_KEY, + ); + return `${payloadB64}.${sig}`; +} + +export async function verifyGuestPortalToken(token: string | undefined): Promise<{ + valid: boolean; + expired: boolean; + payload: GuestPortalTokenPayload | null; +}> { + if (!token || !token.includes(".")) { + return { valid: false, expired: false, payload: null }; + } + const [payloadB64, providedSig] = token.split("."); + if (!payloadB64 || !providedSig) { + return { valid: false, expired: false, payload: null }; + } + + try { + const dec = new TextDecoder(); + const parsed = JSON.parse( + dec.decode(fromBase64Url(payloadB64)), + ) as GuestPortalTokenPayload; + + if (!parsed || !parsed.id || !parsed.exp) { + return { valid: false, expired: false, payload: null }; + } + + // Accept signature signed with either worker secret or default deterministic key + const candidateSecrets = Array.from( + new Set([getSigningSecret(), DEFAULT_PORTAL_SIGNING_KEY]), + ); + let sigMatches = false; + for (const sec of candidateSecrets) { + const expected = await computeHmacB64Url(payloadB64, sec); + if (expected === providedSig) { + sigMatches = true; + break; + } + } + + if (!sigMatches) { + return { valid: false, expired: false, payload: null }; + } + + if (Date.now() > parsed.exp) { + return { valid: false, expired: true, payload: parsed }; + } + + return { valid: true, expired: false, payload: parsed }; + } catch { + return { valid: false, expired: false, payload: null }; + } +} + +export const ALLOWED_SOCIAL_KEYS = [ + "twitter", + "bluesky", + "linkedin", + "instagram", + "threads", + "youtube", + "github", + "mastodon", + "tiktok", + "facebook", + "twitch", + "substack", + "devto", + "medium", + "codepen", + "discord", + "stackoverflow", + "email", +] as const; + +export type AllowedSocialKey = (typeof ALLOWED_SOCIAL_KEYS)[number]; + +/** + * Normalizes a handle or URL into a canonical URL for Sanity storage. + */ +export function normalizeSocialInput( + key: AllowedSocialKey, + rawValue: string | undefined | null, +): string { + const val = String(rawValue ?? "").trim(); + if (!val) return ""; + if (key === "email" || key === "discord") return val; + if (/^https?:\/\//i.test(val)) return val; + + const clean = val.replace(/^@/, ""); + switch (key) { + case "twitter": + return `https://x.com/${clean}`; + case "bluesky": + return `https://bsky.app/profile/${clean.includes(".") ? clean : `${clean}.bsky.social`}`; + case "linkedin": + return clean.startsWith("in/") + ? `https://www.linkedin.com/${clean}` + : `https://www.linkedin.com/in/${clean}`; + case "instagram": + return `https://www.instagram.com/${clean}`; + case "threads": + return `https://www.threads.net/@${clean}`; + case "youtube": + return `https://www.youtube.com/@${clean}`; + case "github": + return `https://github.com/${clean}`; + case "tiktok": + return `https://www.tiktok.com/@${clean}`; + case "facebook": + return `https://www.facebook.com/${clean}`; + case "twitch": + return `https://www.twitch.tv/${clean}`; + case "devto": + return `https://dev.to/${clean}`; + case "mastodon": { + // Support @user@instance.tld + if (clean.includes("@")) { + const [user, instance] = clean.split("@"); + if (user && instance) return `https://${instance}/@${user}`; + } + return `https://mastodon.social/@${clean}`; + } + default: + return `https://${clean}`; + } +} diff --git a/apps/site/src/pages/api/guest-portal/update.ts b/apps/site/src/pages/api/guest-portal/update.ts new file mode 100644 index 00000000..67076cc8 --- /dev/null +++ b/apps/site/src/pages/api/guest-portal/update.ts @@ -0,0 +1,160 @@ +import type { APIRoute } from "astro"; +import { + ALLOWED_SOCIAL_KEYS, + normalizeSocialInput, + verifyGuestPortalToken, +} from "@/lib/guest-portal"; +import { getSanityServerClient } from "@/lib/sanity/write-client"; + +export const POST: APIRoute = async ({ request }) => { + try { + const body = (await request.json()) as { + token?: string; + title?: string; + websiteUrl?: string; + socials?: Record; + }; + + const rawToken = body.token?.trim(); + if (!rawToken) { + return new Response( + JSON.stringify({ error: "Missing guest portal token." }), + { status: 400, headers: { "Content-Type": "application/json" } }, + ); + } + + const { client, canWrite } = getSanityServerClient(); + const verification = await verifyGuestPortalToken(rawToken); + + let guestId = verification.payload?.id; + if (!verification.valid) { + if (verification.expired) { + return new Response( + JSON.stringify({ + error: + "This magic link has expired. Please reply to Alex's email for a fresh link.", + }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + + // Fallback: check if token is stored directly on a guest doc in Sanity with non-expired portalTokenExpiresAt + const docByToken = await client.fetch<{ + _id: string; + portalTokenExpiresAt?: string; + } | null>( + `*[_type == "guest" && portalToken == $token][0]{ _id, portalTokenExpiresAt }`, + { token: rawToken }, + ); + + if (!docByToken) { + return new Response( + JSON.stringify({ error: "Invalid guest portal token." }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + if ( + docByToken.portalTokenExpiresAt && + new Date(docByToken.portalTokenExpiresAt).getTime() < Date.now() + ) { + return new Response( + JSON.stringify({ + error: + "This magic link has expired. Please reply to Alex's email for a fresh link.", + }), + { status: 403, headers: { "Content-Type": "application/json" } }, + ); + } + guestId = docByToken._id; + } + + if (!guestId) { + return new Response( + JSON.stringify({ error: "Could not resolve guest profile." }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + + const existingGuest = await client.fetch<{ + _id: string; + title?: string; + socials?: Record; + websites?: Array; + } | null>( + `*[_type == "guest" && _id == $id][0]{ _id, title, socials, websites }`, + { id: guestId }, + ); + + if (!existingGuest) { + return new Response( + JSON.stringify({ error: "Guest profile not found in Sanity." }), + { status: 404, headers: { "Content-Type": "application/json" } }, + ); + } + + const mergedSocials: Record = { + ...(existingGuest.socials || {}), + }; + + if (body.socials && typeof body.socials === "object") { + for (const key of ALLOWED_SOCIAL_KEYS) { + if (key in body.socials) { + const normalized = normalizeSocialInput(key, body.socials[key]); + if (normalized) { + mergedSocials[key] = normalized; + } else { + delete mergedSocials[key]; + } + } + } + } + + const patchSet: Record = { + socials: mergedSocials, + }; + + if (body.title && body.title.trim().length >= 2) { + patchSet.title = body.title.trim(); + } + + if (typeof body.websiteUrl === "string") { + const cleanSite = body.websiteUrl.trim(); + if (cleanSite) { + const href = /^https?:\/\//i.test(cleanSite) + ? cleanSite + : `https://${cleanSite}`; + patchSet.websites = [ + { + _key: "primary-site", + _type: "site", + site: href, + link: { + blank: true, + href, + }, + }, + ]; + } + } + + if (canWrite) { + await client.patch(existingGuest._id).set(patchSet).commit(); + } + + return new Response( + JSON.stringify({ + ok: true, + guestId: existingGuest._id, + socials: mergedSocials, + persistedToSanity: canWrite, + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return new Response(JSON.stringify({ error: message }), { + status: 500, + 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 new file mode 100644 index 00000000..22a17677 --- /dev/null +++ b/apps/site/src/pages/guest-portal/[token].astro @@ -0,0 +1,437 @@ +--- +import Breadcrumbs from "@/components/Breadcrumbs.astro"; +import BaseLayout from "@/layouts/BaseLayout.astro"; +import { verifyGuestPortalToken } from "@/lib/guest-portal"; +import { getSanityServerClient } from "@/lib/sanity/write-client"; + +const { token } = Astro.params; + +interface EpisodeRef { + _id: string; + title: string; + slug: string; + season: number; + episode: number; + youtube?: string; +} + +interface GuestDoc { + _id: string; + title: string; + slug: string; + avatarUrl?: string; + socials?: Record; + websites?: Array<{ site?: string; link?: { href?: string } }>; + episodes: EpisodeRef[]; +} + +const verification = await verifyGuestPortalToken(token); +const { client } = getSanityServerClient(); + +let isExpired = verification.expired; +let isValid = verification.valid; +let expiresAtIso = verification.payload?.exp + ? new Date(verification.payload.exp).toISOString() + : ""; +let guestDoc: GuestDoc | null = null; + +if (isValid && verification.payload) { + guestDoc = await client.fetch( + `*[_type == "guest" && (_id == $id || slug.current == $slug)][0]{ + _id, + title, + "slug": slug.current, + "avatarUrl": coverImage.asset->url, + socials, + websites, + "episodes": *[_type == "podcast" && references(^._id) && !(_id in path("drafts.**"))] | order(season desc, episode desc) { + _id, + title, + "slug": slug.current, + season, + episode, + youtube + } + }`, + { id: verification.payload.id, slug: verification.payload.slug }, + ); +} else if (!isExpired && token) { + // Fallback: check Sanity portalToken field + const docByToken = await client.fetch< + (GuestDoc & { portalTokenExpiresAt?: string }) | null + >( + `*[_type == "guest" && portalToken == $token][0]{ + _id, + title, + "slug": slug.current, + "avatarUrl": coverImage.asset->url, + socials, + websites, + portalTokenExpiresAt, + "episodes": *[_type == "podcast" && references(^._id) && !(_id in path("drafts.**"))] | order(season desc, episode desc) { + _id, + title, + "slug": slug.current, + season, + episode, + youtube + } + }`, + { token }, + ); + if (docByToken) { + if ( + docByToken.portalTokenExpiresAt && + new Date(docByToken.portalTokenExpiresAt).getTime() < Date.now() + ) { + isExpired = true; + } else { + isValid = true; + guestDoc = docByToken; + expiresAtIso = docByToken.portalTokenExpiresAt || ""; + } + } +} + +const socials = guestDoc?.socials || {}; +const primaryWebsite = + guestDoc?.websites?.[0]?.link?.href || guestDoc?.websites?.[0]?.site || ""; + +const socialFields = [ + { + key: "twitter", + label: "X (Twitter)", + placeholder: "@username or https://x.com/username", + priority: true, + }, + { + key: "bluesky", + label: "Bluesky", + placeholder: "handle.bsky.social or https://bsky.app/profile/...", + priority: true, + }, + { + key: "linkedin", + label: "LinkedIn", + placeholder: "https://www.linkedin.com/in/username", + priority: true, + }, + { + key: "instagram", + label: "Instagram", + placeholder: "@username or https://www.instagram.com/username", + priority: true, + }, + { + key: "threads", + label: "Threads", + placeholder: "@username or https://www.threads.net/@username", + priority: true, + }, + { + key: "facebook", + label: "Facebook", + placeholder: "https://www.facebook.com/username", + priority: true, + }, + { + key: "mastodon", + label: "Mastodon", + placeholder: "@user@mastodon.social or full URL", + priority: false, + }, + { + key: "youtube", + label: "YouTube Channel", + placeholder: "@channel or https://www.youtube.com/@channel", + priority: false, + }, + { + key: "github", + label: "GitHub", + placeholder: "username or https://github.com/username", + priority: false, + }, + { + key: "tiktok", + label: "TikTok", + placeholder: "@username or https://www.tiktok.com/@username", + priority: false, + }, + { + key: "twitch", + label: "Twitch", + placeholder: "https://www.twitch.tv/username", + priority: false, + }, + { + key: "substack", + label: "Substack", + placeholder: "https://username.substack.com", + priority: false, + }, + { + key: "devto", + label: "Dev.to", + placeholder: "https://dev.to/username", + priority: false, + }, + { + key: "medium", + label: "Medium", + placeholder: "https://medium.com/@username", + priority: false, + }, +]; +--- + + +
+ + + {!isValid || !guestDoc ? ( +
+

+ {isExpired ? "This Guest Portal Magic Link Has Expired" : "Invalid Guest Portal Link"} +

+

+ {isExpired + ? "For security, guest profile edit links expire after 30 days." + : "We could not verify this guest profile link."} +

+ +
+ ) : ( +
+
+
+
+ {guestDoc.avatarUrl && ( + {guestDoc.title} + )} +
+ + Verified Sanity Guest Magic Link + {expiresAtIso && ` · Expires ${new Date(expiresAtIso).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}`} + +

+ {guestDoc.title} +

+

+ Public Speaker Profile:{" "} + + codingcat.dev/guest/{guestDoc.slug} + +

+
+
+
+ +

+ We are sharing full episodes and vertical video clips from our archive across{" "} + X, Instagram, Threads, Bluesky, Facebook, Mastodon, LinkedIn, and TikTok. + Add or update your social handles below and they will immediately sync to your Sanity profile so our social agent tags you whenever your episode is shared. +

+ + {guestDoc.episodes?.length > 0 && ( +
+

+ Your CodingCat.dev Podcast Episode(s) +

+ +
+ )} +
+ +
+
+
+ + +
+
+ + +
+
+ +
+ +
+

+ Social Handles for Episode Tagging +

+

+ You can paste either your `@handle` or full profile URL — we normalize it automatically. +

+
+ +
+ {socialFields.map((field) => { + const currentVal = socials[field.key] || ""; + const isMissingPriority = field.priority && !currentVal; + return ( +
+ + +
+ ); + })} +
+ +
+
+ +
+
+
+ )} +
+ + +