followers.ts

5.6 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	// Only a request that mails something counts, and it is logged here rather than in the follower37	// rows, so stopping the emails cannot free the hour's allowance (§23).38	recordFollowRequest(kepr, ipHash, now - 3600, now);39	const token = followToken(kepr, row.id, address);40	const base = `/${repo}/issues/${issue.number}`;41	queueEmail(kepr, {42		to: address,43		subject: `Confirm email updates for ${repo} #${issue.number}`,44		href: base,45		text: `Someone asked for an email whenever "${issue.title}" (${repo} #${issue.number})46gets a reply or is closed.4748To start them, open:49${site}${base}/follow?f=${row.id}&t=${token}5051If that wasn't you, ignore this email and nothing more will come.52`,53	});54	return true;55}5657/** The address holding a follower link, or null when the id holds no follower of that issue. */58function followerEmail(kepr: Database, issueId: number, id: number): string | null {59	const row = kepr60		.query<{ email: string }, [number, number]>(61			"SELECT email FROM issue_followers WHERE id = ? AND issue_id = ?",62		)63		.get(id, issueId);64	return row?.email ?? null;65}6667/**68 * True when a link's token is the one this follower of this issue was sent (§23). Read through the69 * row, so a spent link fails once its row is gone rather than reaching whatever holds its id now.70 */71export function followerLinkMatches(72	kepr: Database,73	issueId: number,74	id: number,75	token: string,76): boolean {77	const email = followerEmail(kepr, issueId, id);78	return email !== null && followTokenMatches(kepr, id, email, token);79}8081/** Confirms one follower of an issue when the link's token is theirs. */82export function confirmFollower(kepr: Database, issueId: number, id: number, token: string) {83	if (!followerLinkMatches(kepr, issueId, id, token)) return false;84	const changed = kepr85		.query("UPDATE issue_followers SET confirmed = 1 WHERE id = ? AND issue_id = ?")86		.run(id, issueId).changes;87	return changed === 1;88}8990/** Stops one follower's emails about an issue when the link's token is theirs. */91export function unfollow(kepr: Database, issueId: number, id: number, token: string): boolean {92	if (!followerLinkMatches(kepr, issueId, id, token)) return false;93	const changed = kepr94		.query("DELETE FROM issue_followers WHERE id = ? AND issue_id = ?")95		.run(id, issueId).changes;96	return changed === 1;97}9899/**100 * Follow requests from one hashed IP since a time; they count toward the reply limit (§23). Read101 * from the request log, not the follower rows, since stopping one's emails deletes its row.102 */103export function followsSince(kepr: Database, ipHash: string, since: number): number {104	const row = kepr105		.query<{ n: number }, [string, number]>(106			"SELECT COUNT(*) AS n FROM issue_follow_requests WHERE ip_hash = ? AND created_at > ?",107		)108		.get(ipHash, since);109	return row?.n ?? 0;110}111112/** Records one accepted follow request, and drops the ones too old to count. */113export function recordFollowRequest(114	kepr: Database,115	ipHash: string,116	since: number,117	now: number,118): void {119	kepr120		.query("DELETE FROM issue_follow_requests WHERE created_at <= ? AND ip_hash = ?")121		.run(since, ipHash);122	kepr123		.query("INSERT INTO issue_follow_requests (ip_hash, created_at) VALUES (?, ?)")124		.run(ipHash, now);125}126127export function followerCount(kepr: Database, issueId: number): number {128	const row = kepr129		.query<{ n: number }, [number]>(130			"SELECT COUNT(*) AS n FROM issue_followers WHERE issue_id = ? AND confirmed = 1",131		)132		.get(issueId);133	return row?.n ?? 0;134}135136/**137 * Emails an issue's confirmed followers (§23), except `except`, the address that caused the news.138 * Each email ends with, and carries as its unsubscribe header, that follower's own stop link.139 */140export function tellFollowers(141	kepr: Database,142	site: string,143	repo: string,144	issue: Pick<Issue, "id" | "number" | "title">,145	news: { text: string; href: string },146	except = "",147): void {148	const followers = kepr149		.query<{ id: number; email: string }, [number, string]>(150			"SELECT id, email FROM issue_followers WHERE issue_id = ? AND confirmed = 1 AND email != ?",151		)152		.all(issue.id, except.toLowerCase());153	kepr.transaction(() => {154		for (const follower of followers) {155			const token = followToken(kepr, follower.id, follower.email);156			const stop = `/${repo}/issues/${issue.number}/unfollow?f=${follower.id}&t=${token}`;157			queueEmail(kepr, {158				to: follower.email,159				subject: `Re: ${issue.title} (${repo} #${issue.number})`,160				href: news.href,161				unsubscribe: stop,162				text: `${news.text}163164${site}${news.href}165166You follow this issue by email. To stop: ${site}${stop}167`,168			});169		}170	})();171}