g1t/apps/status/src/subscribers.ts

81 lines3,509 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1/**
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. */
10export const CONFIRM_TTL_MS = 24 * 60 * 60 * 1000;
11/** No second confirmation email to one address sooner than this. */
12export const RESEND_AFTER_MS = 10 * 60 * 1000;
13
14const 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. */
17export 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
22function 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. */
29export function newToken(): string {
30 return base64url(crypto.getRandomValues(new Uint8Array(32)));
31}
32
33/** What is kept of a token: its SHA-256, hex. */
34export 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
39async 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>`. */
45export 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. */
50export 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. */
62export 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. */
73export 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. */
78export 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}