followers.ts

4.9 KB · raw

1import type { Database } from "bun:sqlite";2import { queueEmail } from "../server/db.ts";3import { followToken, followTokenMatches } from "./spam.ts";4import type { Issue } from "./store.ts";56// Enough to catch a typo or a stray name; the confirmation email is the real check.7const EMAIL = /^[^\s@<>(),;:"]+@[^\s@<>(),;:"]+\.[^\s@<>(),;:"]+$/;89export function isEmail(address: string): boolean {10	return address.length <= 254 && EMAIL.test(address);11}1213/**14 * Asks an address to follow an issue (§23). A new one is sent one confirmation email and nothing15 * more until it is confirmed; one already asked is sent nothing. True when a confirmation went out.16 */17export function follow(18	kepr: Database,19	site: string,20	repo: string,21	issue: Issue,22	email: string,23	ipHash: string,24	now: number,25): boolean {26	const address = email.toLowerCase();27	const row = kepr28		.query<{ id: number }, [number, string, string, number]>(29			`INSERT INTO issue_followers (issue_id, email, ip_hash, created_at)30VALUES (?, ?, ?, ?)31ON CONFLICT (issue_id, email) DO NOTHING32RETURNING id`,33		)34		.get(issue.id, address, ipHash, now);35	if (!row) return false;36	const token = followToken(kepr, row.id, address);37	const base = `/${repo}/issues/${issue.number}`;38	queueEmail(kepr, {39		to: address,40		subject: `Confirm email updates for ${repo} #${issue.number}`,41		href: base,42		text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})43gets a reply or is closed.4445To start them, open:46${site}${base}/follow?f=${row.id}&t=${token}4748If that wasn't you, ignore this email and nothing more will come.49`,50	});51	return true;52}5354/** The address holding a follower link, or null when the id holds no follower of that issue. */55function followerEmail(kepr: Database, issueId: number, id: number): string | null {56	const row = kepr57		.query<{ email: string }, [number, number]>(58			"SELECT email FROM issue_followers WHERE id = ? AND issue_id = ?",59		)60		.get(id, issueId);61	return row?.email ?? null;62}6364/**65 * True when a link's token is the one this follower of this issue was sent (§23). Read through the66 * row, so a spent link fails once its row is gone rather than reaching whatever holds its id now.67 */68export function followerLinkMatches(69	kepr: Database,70	issueId: number,71	id: number,72	token: string,73): boolean {74	const email = followerEmail(kepr, issueId, id);75	return email !== null && followTokenMatches(kepr, id, email, token);76}7778/** Confirms one follower of an issue when the link's token is theirs. */79export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {80	if (!followerLinkMatches(kepr, issueId, id, token)) return false;81	const changed = kepr82		.query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")83		.run(id, issueId).changes;84	return changed === 1;85}8687/** Stops one follower's emails about an issue when the link's token is theirs. */88export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {89	if (!followerLinkMatches(kepr, issueId, id, token)) return false;90	const changed = kepr91		.query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")92		.run(id, issueId).changes;93	return changed === 1;94}9596/** Follow requests from one hashed IP since a time; they count toward the reply limit. */97export function followsSince(kepr: Database, ipHash: string, since: number): number {98	const row = kepr99		.query<{ n: number }, [string, number]>(100			"SELECT COUNT(*) AS n FROM issue_followers WHERE ip_hash = ? AND created_at > ?",101		)102		.get(ipHash, since);103	return row?.n ?? 0;104}105106export function followerCount(kepr: Database, issueId: number): number {107	const row = kepr108		.query<{ n: number }, [number]>(109			"SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",110		)111		.get(issueId);112	return row?.n ?? 0;113}114115/**116 * Emails an issue's confirmed followers (§23), except `except`, the address that caused the news.117 * Each email ends with, and carries as its unsubscribe header, that follower's own stop link.118 */119export function tellFollowers(120	kepr: Database,121	site: string,122	repo: string,123	issue: Pick<Issue, "id" | "number" | "title">,124	news: { text: string; href: string },125	except = "",126): void {127	const followers = kepr128		.query<{ id: number; email: string }, [number, string]>(129			"SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",130		)131		.all(issue.id, except.toLowerCase());132	kepr.transaction(() => {133		for (const follower of followers) {134			const token = followToken(kepr, follower.id, follower.email);135			const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;136			queueEmail(kepr, {137				to: follower.email,138				subject: `Re: ${issue.title} (${repo} #${issue.number})`,139				href: news.href,140				unsubscribe: stop,141				text: `${news.text}142143${site}${news.href}144145You follow this issue by email. To stop: ${site}${stop}146`,147			});148		}149	})();150}