|
| 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 | +} |
0 commit comments