| 1 | import { devAuth } from "../auth/dev.js"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { b64url, fromB64url, safeEqual } from "../lib/util.js"; |
| 4 | import type { Category } from "./types.js"; |
| 5 | |
| 6 | /** |
| 7 | * Unsubscribe links work without signing in, so each one carries a signed |
| 8 | * capability: whose settings to change and what to turn off. The key is |
| 9 | * derived from MASTER_KEY, so links stay valid across deploys, and each one |
| 10 | * expires about a year after it was sent. |
| 11 | */ |
| 12 | export type UnsubScope = |
| 13 | /** Stop email for a settings group. */ |
| 14 | | { t: "category"; category: Exclude<Category, "security" | "account"> } |
| 15 | /** Mute one issue or pull request. */ |
| 16 | | { t: "thread"; repoId: string; number: number; repo: string } |
| 17 | /** Stop watching a repository. */ |
| 18 | | { t: "repo"; repoId: string; repo: string }; |
| 19 | |
| 20 | export interface UnsubToken { |
| 21 | accountId: string; |
| 22 | scope: UnsubScope; |
| 23 | } |
| 24 | |
| 25 | async function key(env: Env): Promise<CryptoKey> { |
| 26 | // Local development has no MASTER_KEY unless one is passed; production always does. |
| 27 | const secret = env.MASTER_KEY ?? (devAuth(env) ? "diffinery-dev-unsubscribe" : null); |
| 28 | if (!secret) throw new Error("MASTER_KEY is not set, so unsubscribe links can't be signed"); |
| 29 | return crypto.subtle.importKey("raw", new TextEncoder().encode(`diffinery-unsubscribe-v1:${secret}`), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); |
| 30 | } |
| 31 | |
| 32 | async function sign(env: Env, payload: string): Promise<string> { |
| 33 | const sig = await crypto.subtle.sign("HMAC", await key(env), new TextEncoder().encode(payload)); |
| 34 | return b64url(new Uint8Array(sig)); |
| 35 | } |
| 36 | |
| 37 | /** How long an unsubscribe link works: about a year, so old emails in someone's inbox stop being a capability. */ |
| 38 | export const UNSUB_TTL_MS = 365 * 24 * 60 * 60 * 1000; |
| 39 | /** Links signed before they carried an expiry keep working until this date (a year after expiries were added). */ |
| 40 | export const UNSUB_LEGACY_CUTOFF_MS = Date.UTC(2027, 9, 16); |
| 41 | |
| 42 | export async function unsubToken(env: Env, t: UnsubToken, now = Date.now()): Promise<string> { |
| 43 | // `e` is the expiry in seconds since the epoch. |
| 44 | const payload = b64url(new TextEncoder().encode(JSON.stringify({ a: t.accountId, s: t.scope, e: Math.floor((now + UNSUB_TTL_MS) / 1000) }))); |
| 45 | return `${payload}.${await sign(env, payload)}`; |
| 46 | } |
| 47 | |
| 48 | export async function readUnsubToken(env: Env, token: string, now = Date.now()): Promise<UnsubToken | null> { |
| 49 | const [payload, sig] = token.split("."); |
| 50 | if (!payload || !sig || token.length > 2000) return null; |
| 51 | if (!safeEqual(sig, await sign(env, payload))) return null; |
| 52 | try { |
| 53 | const v = JSON.parse(new TextDecoder().decode(fromB64url(payload))) as { a?: unknown; s?: UnsubScope; e?: unknown }; |
| 54 | if (typeof v.a !== "string" || !v.s || typeof v.s !== "object") return null; |
| 55 | // A link without an expiry was sent before links had one; those are honoured only until the cutoff. |
| 56 | const expires = v.e === undefined ? UNSUB_LEGACY_CUTOFF_MS : typeof v.e === "number" ? v.e * 1000 : 0; |
| 57 | if (now >= expires) return null; |
| 58 | return { accountId: v.a, scope: v.s }; |
| 59 | } catch { |
| 60 | return null; |
| 61 | } |
| 62 | } |
| 63 | |
| 64 | export async function unsubUrl(env: Env, t: UnsubToken): Promise<string> { |
| 65 | return `${env.PUBLIC_URL}/-/unsubscribe/${await unsubToken(env, t)}`; |
| 66 | } |