g1t/apps/status/src/subscribers.ts
| 1 | /** |
| 2 | * Email subscribers: double opt-in and one-click leaving. Only hashes are |
| 3 | * kept. A confirmation token is random and stored as its SHA-256; an |
| 4 | * unsubscribe link is the subscriber's id signed with STATUS_SECRET, so it |
| 5 | * is never stored at all and a copy of the database cannot unsubscribe |
| 6 | * anyone. Web Crypto only, so it is tested under Node. |
| 7 | */ |
| 8 | |
| 9 | /** How long a confirmation link works. */ |
| 10 | export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000; |
| 11 | /** No second confirmation email to one address sooner than this. */ |
| 12 | export const RESEND_AFTER_MS = 10 * 60 * 1000; |
| 13 | |
| 14 | const EMAIL = /^[^\s@<>"(),;:]{1,64}@[a-z0-9.-]{1,190}\.[a-z]{2,}$/; |
| 15 | |
| 16 | /** An address as kept: trimmed and lowercased; null when it is not one. */ |
| 17 | export function normalizeEmail(raw: unknown): string | null { |
| 18 | const email = typeof raw === "string" ? raw.trim().toLowerCase() : ""; |
| 19 | return email.length <= 254 && EMAIL.test(email) ? email : null; |
| 20 | } |
| 21 | |
| 22 | function base64url(bytes: Uint8Array): string { |
| 23 | let text = ""; |
| 24 | for (const b of bytes) text += String.fromCharCode(b); |
| 25 | return btoa(text).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 26 | } |
| 27 | |
| 28 | /** A new random token, as it goes in a link. */ |
| 29 | export function newToken(): string { |
| 30 | return base64url(crypto.getRandomValues(new Uint8Array(32))); |
| 31 | } |
| 32 | |
| 33 | /** What is kept of a token: its SHA-256, hex. */ |
| 34 | export async function hashToken(token: string): Promise<string> { |
| 35 | const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(token)); |
| 36 | return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 37 | } |
| 38 | |
| 39 | async function hmac(secret: string, message: string): Promise<string> { |
| 40 | const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 41 | return base64url(new Uint8Array(await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)))); |
| 42 | } |
| 43 | |
| 44 | /** The token in a subscriber's unsubscribe link: `<id>.<signature>`. */ |
| 45 | export async function unsubscribeToken(secret: string, id: string): Promise<string> { |
| 46 | return `${id}.${await hmac(secret, `unsubscribe:${id}`)}`; |
| 47 | } |
| 48 | |
| 49 | /** The subscriber an unsubscribe token is for, or null when it is not genuine. */ |
| 50 | export async function readUnsubscribeToken(secret: string, token: string): Promise<string | null> { |
| 51 | const dot = token.lastIndexOf("."); |
| 52 | if (dot <= 0) return null; |
| 53 | const id = token.slice(0, dot); |
| 54 | const expected = await unsubscribeToken(secret, id); |
| 55 | if (expected.length !== token.length) return null; |
| 56 | let diff = 0; |
| 57 | for (let i = 0; i < token.length; i++) diff |= expected.charCodeAt(i) ^ token.charCodeAt(i); |
| 58 | return diff === 0 ? id : null; |
| 59 | } |
| 60 | |
| 61 | /** A subscriber's parts: null for everything. */ |
| 62 | export function parseParts(json: string | null): string[] | null { |
| 63 | if (!json) return null; |
| 64 | try { |
| 65 | const value = JSON.parse(json); |
| 66 | return Array.isArray(value) && value.length ? value.map(String) : null; |
| 67 | } catch { |
| 68 | return null; |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | /** Whether a subscriber wants news about these parts. */ |
| 73 | export function wants(parts: string[] | null, about: string[]): boolean { |
| 74 | return parts == null || about.length === 0 || about.some((key) => parts.includes(key)); |
| 75 | } |
| 76 | |
| 77 | /** The parts chosen in the form, known ones only; null (everything) when none or all are. */ |
| 78 | export function chosenParts(raw: string[], known: string[]): string[] | null { |
| 79 | const parts = [...new Set(raw.filter((key) => known.includes(key)))]; |
| 80 | return parts.length === 0 || parts.length === known.length ? null : parts; |
| 81 | } |