Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions apps/sanity/schemas/partials/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
],
});

Expand Down
201 changes: 201 additions & 0 deletions apps/site/src/lib/guest-portal.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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}`;
}
}
160 changes: 160 additions & 0 deletions apps/site/src/pages/api/guest-portal/update.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
};

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<string, string>;
websites?: Array<any>;
} | 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<string, string> = {
...(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<string, unknown> = {
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" },
});
}
};
Loading
Loading