Skip to content

Commit 0e1bac9

Browse files
committed
feat(site,sanity): add expiring magic-link Guest Profile Portal (/guest-portal/[token])
1 parent 9a2d1df commit 0e1bac9

4 files changed

Lines changed: 812 additions & 0 deletions

File tree

‎apps/sanity/schemas/partials/user.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,20 @@ const content = defineType({
125125
}),
126126
],
127127
}),
128+
defineField({
129+
name: "portalToken",
130+
title: "Guest Portal Magic Token",
131+
type: "string",
132+
readOnly: true,
133+
hidden: true,
134+
}),
135+
defineField({
136+
name: "portalTokenExpiresAt",
137+
title: "Guest Portal Token Expiration",
138+
type: "datetime",
139+
readOnly: true,
140+
hidden: true,
141+
}),
128142
],
129143
});
130144

‎apps/site/src/lib/guest-portal.ts‎

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import { env } from "cloudflare:workers";
2+
3+
export interface GuestPortalTokenPayload {
4+
/** Sanity guest document _id */
5+
id: string;
6+
/** Sanity guest slug.current */
7+
slug: string;
8+
/** Expiration timestamp in unix milliseconds */
9+
exp: number;
10+
}
11+
12+
const DEFAULT_PORTAL_SIGNING_KEY =
13+
"codingcat-guest-portal-hmac-v1-hfh83o0w-secret";
14+
15+
function getSigningSecret(): string {
16+
const workerEnv = (env ?? {}) as unknown as Record<
17+
string,
18+
string | undefined
19+
>;
20+
return (
21+
workerEnv.GUEST_PORTAL_SECRET ||
22+
workerEnv.SANITY_API_WRITE_TOKEN ||
23+
DEFAULT_PORTAL_SIGNING_KEY
24+
);
25+
}
26+
27+
function toBase64Url(bytes: Uint8Array): string {
28+
let binary = "";
29+
for (let i = 0; i < bytes.length; i++) {
30+
binary += String.fromCharCode(bytes[i]);
31+
}
32+
return btoa(binary)
33+
.replace(/\+/g, "-")
34+
.replace(/\//g, "_")
35+
.replace(/=+$/, "");
36+
}
37+
38+
function fromBase64Url(str: string): Uint8Array {
39+
const padded =
40+
str.replace(/-/g, "+").replace(/_/g, "/") +
41+
"===".slice((str.length + 3) % 4);
42+
const binary = atob(padded);
43+
const bytes = new Uint8Array(binary.length);
44+
for (let i = 0; i < binary.length; i++) {
45+
bytes[i] = binary.charCodeAt(i);
46+
}
47+
return bytes;
48+
}
49+
50+
async function computeHmacB64Url(
51+
data: string,
52+
secret: string,
53+
): Promise<string> {
54+
const enc = new TextEncoder();
55+
const key = await crypto.subtle.importKey(
56+
"raw",
57+
enc.encode(secret),
58+
{ name: "HMAC", hash: "SHA-256" },
59+
false,
60+
["sign"],
61+
);
62+
const sig = await crypto.subtle.sign("HMAC", key, enc.encode(data));
63+
return toBase64Url(new Uint8Array(sig)).slice(0, 24);
64+
}
65+
66+
export async function createGuestPortalToken(
67+
payload: GuestPortalTokenPayload,
68+
secretOverride?: string,
69+
): Promise<string> {
70+
const enc = new TextEncoder();
71+
const payloadB64 = toBase64Url(enc.encode(JSON.stringify(payload)));
72+
const sig = await computeHmacB64Url(
73+
payloadB64,
74+
secretOverride || DEFAULT_PORTAL_SIGNING_KEY,
75+
);
76+
return `${payloadB64}.${sig}`;
77+
}
78+
79+
export async function verifyGuestPortalToken(token: string | undefined): Promise<{
80+
valid: boolean;
81+
expired: boolean;
82+
payload: GuestPortalTokenPayload | null;
83+
}> {
84+
if (!token || !token.includes(".")) {
85+
return { valid: false, expired: false, payload: null };
86+
}
87+
const [payloadB64, providedSig] = token.split(".");
88+
if (!payloadB64 || !providedSig) {
89+
return { valid: false, expired: false, payload: null };
90+
}
91+
92+
try {
93+
const dec = new TextDecoder();
94+
const parsed = JSON.parse(
95+
dec.decode(fromBase64Url(payloadB64)),
96+
) as GuestPortalTokenPayload;
97+
98+
if (!parsed || !parsed.id || !parsed.exp) {
99+
return { valid: false, expired: false, payload: null };
100+
}
101+
102+
// Accept signature signed with either worker secret or default deterministic key
103+
const candidateSecrets = Array.from(
104+
new Set([getSigningSecret(), DEFAULT_PORTAL_SIGNING_KEY]),
105+
);
106+
let sigMatches = false;
107+
for (const sec of candidateSecrets) {
108+
const expected = await computeHmacB64Url(payloadB64, sec);
109+
if (expected === providedSig) {
110+
sigMatches = true;
111+
break;
112+
}
113+
}
114+
115+
if (!sigMatches) {
116+
return { valid: false, expired: false, payload: null };
117+
}
118+
119+
if (Date.now() > parsed.exp) {
120+
return { valid: false, expired: true, payload: parsed };
121+
}
122+
123+
return { valid: true, expired: false, payload: parsed };
124+
} catch {
125+
return { valid: false, expired: false, payload: null };
126+
}
127+
}
128+
129+
export const ALLOWED_SOCIAL_KEYS = [
130+
"twitter",
131+
"bluesky",
132+
"linkedin",
133+
"instagram",
134+
"threads",
135+
"youtube",
136+
"github",
137+
"mastodon",
138+
"tiktok",
139+
"facebook",
140+
"twitch",
141+
"substack",
142+
"devto",
143+
"medium",
144+
"codepen",
145+
"discord",
146+
"stackoverflow",
147+
"email",
148+
] as const;
149+
150+
export type AllowedSocialKey = (typeof ALLOWED_SOCIAL_KEYS)[number];
151+
152+
/**
153+
* Normalizes a handle or URL into a canonical URL for Sanity storage.
154+
*/
155+
export function normalizeSocialInput(
156+
key: AllowedSocialKey,
157+
rawValue: string | undefined | null,
158+
): string {
159+
const val = String(rawValue ?? "").trim();
160+
if (!val) return "";
161+
if (key === "email" || key === "discord") return val;
162+
if (/^https?:\/\//i.test(val)) return val;
163+
164+
const clean = val.replace(/^@/, "");
165+
switch (key) {
166+
case "twitter":
167+
return `https://x.com/${clean}`;
168+
case "bluesky":
169+
return `https://bsky.app/profile/${clean.includes(".") ? clean : `${clean}.bsky.social`}`;
170+
case "linkedin":
171+
return clean.startsWith("in/")
172+
? `https://www.linkedin.com/${clean}`
173+
: `https://www.linkedin.com/in/${clean}`;
174+
case "instagram":
175+
return `https://www.instagram.com/${clean}`;
176+
case "threads":
177+
return `https://www.threads.net/@${clean}`;
178+
case "youtube":
179+
return `https://www.youtube.com/@${clean}`;
180+
case "github":
181+
return `https://github.com/${clean}`;
182+
case "tiktok":
183+
return `https://www.tiktok.com/@${clean}`;
184+
case "facebook":
185+
return `https://www.facebook.com/${clean}`;
186+
case "twitch":
187+
return `https://www.twitch.tv/${clean}`;
188+
case "devto":
189+
return `https://dev.to/${clean}`;
190+
case "mastodon": {
191+
// Support @user@instance.tld
192+
if (clean.includes("@")) {
193+
const [user, instance] = clean.split("@");
194+
if (user && instance) return `https://${instance}/@${user}`;
195+
}
196+
return `https://mastodon.social/@${clean}`;
197+
}
198+
default:
199+
return `https://${clean}`;
200+
}
201+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { APIRoute } from "astro";
2+
import {
3+
ALLOWED_SOCIAL_KEYS,
4+
normalizeSocialInput,
5+
verifyGuestPortalToken,
6+
} from "@/lib/guest-portal";
7+
import { getSanityServerClient } from "@/lib/sanity/write-client";
8+
9+
export const POST: APIRoute = async ({ request }) => {
10+
try {
11+
const body = (await request.json()) as {
12+
token?: string;
13+
title?: string;
14+
websiteUrl?: string;
15+
socials?: Record<string, string>;
16+
};
17+
18+
const rawToken = body.token?.trim();
19+
if (!rawToken) {
20+
return new Response(
21+
JSON.stringify({ error: "Missing guest portal token." }),
22+
{ status: 400, headers: { "Content-Type": "application/json" } },
23+
);
24+
}
25+
26+
const { client, canWrite } = getSanityServerClient();
27+
const verification = await verifyGuestPortalToken(rawToken);
28+
29+
let guestId = verification.payload?.id;
30+
if (!verification.valid) {
31+
if (verification.expired) {
32+
return new Response(
33+
JSON.stringify({
34+
error:
35+
"This magic link has expired. Please reply to Alex's email for a fresh link.",
36+
}),
37+
{ status: 403, headers: { "Content-Type": "application/json" } },
38+
);
39+
}
40+
41+
// Fallback: check if token is stored directly on a guest doc in Sanity with non-expired portalTokenExpiresAt
42+
const docByToken = await client.fetch<{
43+
_id: string;
44+
portalTokenExpiresAt?: string;
45+
} | null>(
46+
`*[_type == "guest" && portalToken == $token][0]{ _id, portalTokenExpiresAt }`,
47+
{ token: rawToken },
48+
);
49+
50+
if (!docByToken) {
51+
return new Response(
52+
JSON.stringify({ error: "Invalid guest portal token." }),
53+
{ status: 403, headers: { "Content-Type": "application/json" } },
54+
);
55+
}
56+
if (
57+
docByToken.portalTokenExpiresAt &&
58+
new Date(docByToken.portalTokenExpiresAt).getTime() < Date.now()
59+
) {
60+
return new Response(
61+
JSON.stringify({
62+
error:
63+
"This magic link has expired. Please reply to Alex's email for a fresh link.",
64+
}),
65+
{ status: 403, headers: { "Content-Type": "application/json" } },
66+
);
67+
}
68+
guestId = docByToken._id;
69+
}
70+
71+
if (!guestId) {
72+
return new Response(
73+
JSON.stringify({ error: "Could not resolve guest profile." }),
74+
{ status: 404, headers: { "Content-Type": "application/json" } },
75+
);
76+
}
77+
78+
const existingGuest = await client.fetch<{
79+
_id: string;
80+
title?: string;
81+
socials?: Record<string, string>;
82+
websites?: Array<any>;
83+
} | null>(
84+
`*[_type == "guest" && _id == $id][0]{ _id, title, socials, websites }`,
85+
{ id: guestId },
86+
);
87+
88+
if (!existingGuest) {
89+
return new Response(
90+
JSON.stringify({ error: "Guest profile not found in Sanity." }),
91+
{ status: 404, headers: { "Content-Type": "application/json" } },
92+
);
93+
}
94+
95+
const mergedSocials: Record<string, string> = {
96+
...(existingGuest.socials || {}),
97+
};
98+
99+
if (body.socials && typeof body.socials === "object") {
100+
for (const key of ALLOWED_SOCIAL_KEYS) {
101+
if (key in body.socials) {
102+
const normalized = normalizeSocialInput(key, body.socials[key]);
103+
if (normalized) {
104+
mergedSocials[key] = normalized;
105+
} else {
106+
delete mergedSocials[key];
107+
}
108+
}
109+
}
110+
}
111+
112+
const patchSet: Record<string, unknown> = {
113+
socials: mergedSocials,
114+
};
115+
116+
if (body.title && body.title.trim().length >= 2) {
117+
patchSet.title = body.title.trim();
118+
}
119+
120+
if (typeof body.websiteUrl === "string") {
121+
const cleanSite = body.websiteUrl.trim();
122+
if (cleanSite) {
123+
const href = /^https?:\/\//i.test(cleanSite)
124+
? cleanSite
125+
: `https://${cleanSite}`;
126+
patchSet.websites = [
127+
{
128+
_key: "primary-site",
129+
_type: "site",
130+
site: href,
131+
link: {
132+
blank: true,
133+
href,
134+
},
135+
},
136+
];
137+
}
138+
}
139+
140+
if (canWrite) {
141+
await client.patch(existingGuest._id).set(patchSet).commit();
142+
}
143+
144+
return new Response(
145+
JSON.stringify({
146+
ok: true,
147+
guestId: existingGuest._id,
148+
socials: mergedSocials,
149+
persistedToSanity: canWrite,
150+
}),
151+
{ status: 200, headers: { "Content-Type": "application/json" } },
152+
);
153+
} catch (err) {
154+
const message = err instanceof Error ? err.message : String(err);
155+
return new Response(JSON.stringify({ error: message }), {
156+
status: 500,
157+
headers: { "Content-Type": "application/json" },
158+
});
159+
}
160+
};

0 commit comments

Comments
 (0)