| 1 | import type { Env } from "../env.js"; |
| 2 | import type { Account } from "./directory.js"; |
| 3 | import { stopIn, type Stopped } from "./killswitch.js"; |
| 4 | import { enqueueMail } from "../mail/send.js"; |
| 5 | import { HttpError, newId, now } from "./util.js"; |
| 6 | |
| 7 | /** |
| 8 | * Moderation, as on GitHub: reporting content to the site administrators, blocking people, |
| 9 | * suspending accounts and hiding (minimizing) comments. |
| 10 | * |
| 11 | * - Reports: any signed-in person reports a person, a repository, an issue, a pull request or a |
| 12 | * comment, with a reason from REPORT_REASONS and a note. Site administrators work through the |
| 13 | * queue under Site administration → Reports and resolve each report with a note. |
| 14 | * - Blocks: a person, or an organization through one of its owners, blocks a person. In the |
| 15 | * blocker's repositories the blocked person (and their agents) can't open issues or pull |
| 16 | * requests, comment, review or react; they aren't collaborators there any more, the two can't |
| 17 | * invite each other, and the blocker hears nothing from them. What they can read stays as it is. |
| 18 | * - Suspension: a site administrator suspends a person (accounts.suspended_*, with status |
| 19 | * `disabled`). Nothing they hold works while it lasts: sessions end, tokens, agents and runners |
| 20 | * are refused, running Actions and sandboxes in their repositories stop. Their repositories, |
| 21 | * profile, issues, pull requests and comments are hidden from everyone but site administrators. |
| 22 | * Unsuspending restores all of it; tokens weren't revoked. |
| 23 | * - Hidden comments: someone with write access to a repository (site administrators have it |
| 24 | * everywhere) minimizes a comment with a reason; it is shown collapsed. |
| 25 | */ |
| 26 | |
| 27 | // ---------------------------------------------------------------- reports |
| 28 | |
| 29 | /** Why something is reported (GitHub's report form, shortened). */ |
| 30 | export const REPORT_REASONS = { |
| 31 | spam: "Spam", |
| 32 | harassment: "Harassment or bullying", |
| 33 | hate: "Hateful content", |
| 34 | violence: "Violence or threats", |
| 35 | sexual: "Sexual content", |
| 36 | malware: "Malware or phishing", |
| 37 | impersonation: "Impersonation", |
| 38 | other: "Something else", |
| 39 | } as const; |
| 40 | export type ReportReason = keyof typeof REPORT_REASONS; |
| 41 | |
| 42 | export const REPORT_KINDS = ["user", "repository", "issue", "pull_request", "comment"] as const; |
| 43 | export type ReportKind = (typeof REPORT_KINDS)[number]; |
| 44 | |
| 45 | /** Longest note a reporter writes, and resolution note an administrator writes, in characters. */ |
| 46 | export const MAX_REPORT_TEXT = 2000; |
| 47 | /** Longest excerpt of the reported content kept with a report. */ |
| 48 | export const MAX_EXCERPT = 2000; |
| 49 | /** Reports one person files in 24 hours, on top of the per-minute rate limit. */ |
| 50 | export const MAX_REPORTS_PER_DAY = 20; |
| 51 | |
| 52 | /** A report's reason and note as sent by the reporter: a known reason, a note within the limit. */ |
| 53 | export function cleanReport(input: { reason?: unknown; body?: unknown }): { reason: ReportReason; body: string } { |
| 54 | const reason = typeof input.reason === "string" ? input.reason.trim().toLowerCase() : ""; |
| 55 | if (!(reason in REPORT_REASONS)) throw new HttpError(422, `reason must be one of ${Object.keys(REPORT_REASONS).join(", ")}`, { errors: [{ resource: "Report", code: "invalid", field: "reason" }] }); |
| 56 | if (input.body !== undefined && input.body !== null && typeof input.body !== "string") throw new HttpError(422, "body must be a string", { errors: [{ resource: "Report", code: "invalid", field: "body" }] }); |
| 57 | const body = ((input.body as string | undefined) ?? "").replace(/\r\n/g, "\n").trim(); |
| 58 | if (body.length > MAX_REPORT_TEXT) throw new HttpError(422, `the note can be at most ${MAX_REPORT_TEXT} characters`, { errors: [{ resource: "Report", code: "invalid", field: "body" }] }); |
| 59 | if (reason === "other" && !body) throw new HttpError(422, "say what is wrong in the note", { errors: [{ resource: "Report", code: "missing_field", field: "body" }] }); |
| 60 | return { reason: reason as ReportReason, body }; |
| 61 | } |
| 62 | |
| 63 | /** An administrator's resolution note: required, trimmed and bounded. */ |
| 64 | export function cleanResolution(v: unknown): string { |
| 65 | const s = typeof v === "string" ? v.replace(/\r\n/g, "\n").trim() : ""; |
| 66 | if (!s) throw new HttpError(422, "write what you did about it (the note is kept with the report)"); |
| 67 | if (s.length > MAX_REPORT_TEXT) throw new HttpError(422, `the note can be at most ${MAX_REPORT_TEXT} characters`); |
| 68 | return s; |
| 69 | } |
| 70 | |
| 71 | /** What is reported, resolved by the caller (who checked the reporter can see it). */ |
| 72 | export interface ReportTarget { |
| 73 | kind: ReportKind; |
| 74 | /** login, owner/name, owner/name#12, or owner/name#12 (comment id) */ |
| 75 | target: string; |
| 76 | url: string; |
| 77 | accountId: string | null; |
| 78 | repoId: string | null; |
| 79 | excerpt: string | null; |
| 80 | } |
| 81 | |
| 82 | export interface Report { |
| 83 | id: string; |
| 84 | reporter_id: string; |
| 85 | reporter_login: string | null; |
| 86 | kind: ReportKind; |
| 87 | target: string; |
| 88 | url: string; |
| 89 | account_id: string | null; |
| 90 | account_login: string | null; |
| 91 | repo_id: string | null; |
| 92 | excerpt: string | null; |
| 93 | reason: ReportReason; |
| 94 | body: string; |
| 95 | state: "open" | "resolved"; |
| 96 | created_at: string; |
| 97 | resolved_at: string | null; |
| 98 | resolved_by: string | null; |
| 99 | resolution: string | null; |
| 100 | } |
| 101 | |
| 102 | const REPORT_SELECT = "SELECT r.*, p.login AS reporter_login, a.login AS account_login FROM abuse_reports r LEFT JOIN accounts p ON p.id = r.reporter_id LEFT JOIN accounts a ON a.id = r.account_id"; |
| 103 | |
| 104 | /** File a report. One person files at most MAX_REPORTS_PER_DAY a day, and one open report per thing. */ |
| 105 | export async function createReport(env: Env, reporter: Account, t: ReportTarget, input: { reason: ReportReason; body: string }): Promise<Report> { |
| 106 | const since = new Date(Date.now() - 86400_000).toISOString(); |
| 107 | const filed = (await env.DB.prepare("SELECT count(*) AS n FROM abuse_reports WHERE reporter_id = ? AND created_at > ?").bind(reporter.id, since).first<{ n: number }>())?.n ?? 0; |
| 108 | if (filed >= MAX_REPORTS_PER_DAY) throw new HttpError(429, `you can file at most ${MAX_REPORTS_PER_DAY} reports in 24 hours; try again later`); |
| 109 | const open = await env.DB.prepare("SELECT 1 FROM abuse_reports WHERE reporter_id = ? AND kind = ? AND target = ? AND state = 'open'").bind(reporter.id, t.kind, t.target).first(); |
| 110 | if (open) throw new HttpError(422, "you already reported this; the site administrators haven't resolved your report yet"); |
| 111 | const id = newId(); |
| 112 | await env.DB.prepare("INSERT INTO abuse_reports (id, reporter_id, kind, target, url, account_id, repo_id, excerpt, reason, body, state, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?)") |
| 113 | .bind(id, reporter.id, t.kind, t.target, t.url, t.accountId, t.repoId, t.excerpt ? t.excerpt.slice(0, MAX_EXCERPT) : null, input.reason, input.body, now()) |
| 114 | .run(); |
| 115 | return (await report(env, id))!; |
| 116 | } |
| 117 | |
| 118 | export async function report(env: Env, id: string): Promise<Report | null> { |
| 119 | return env.DB.prepare(`${REPORT_SELECT} WHERE r.id = ?`).bind(id).first<Report>(); |
| 120 | } |
| 121 | |
| 122 | /** Reports in the queue, newest first: open ones, resolved ones, or (all) both. */ |
| 123 | export async function reports(env: Env, state: "open" | "resolved" | "all" = "open", limit = 100, offset = 0): Promise<{ items: Report[]; total: number }> { |
| 124 | const where = state === "all" ? "" : "WHERE r.state = ?"; |
| 125 | const args = state === "all" ? [] : [state]; |
| 126 | const [items, total] = await Promise.all([ |
| 127 | env.DB.prepare(`${REPORT_SELECT} ${where} ORDER BY r.created_at DESC LIMIT ? OFFSET ?`).bind(...args, Math.min(limit, 100), offset).all<Report>(), |
| 128 | env.DB.prepare(`SELECT count(*) AS n FROM abuse_reports r ${where}`).bind(...args).first<{ n: number }>(), |
| 129 | ]); |
| 130 | return { items: items.results, total: total?.n ?? 0 }; |
| 131 | } |
| 132 | |
| 133 | export async function openReportCount(env: Env): Promise<number> { |
| 134 | return (await env.DB.prepare("SELECT count(*) AS n FROM abuse_reports WHERE state = 'open'").first<{ n: number }>())?.n ?? 0; |
| 135 | } |
| 136 | |
| 137 | /** Resolve a report with a note, or (`open`) reopen it. Returns the report, or null when there is none. */ |
| 138 | export async function setReportState(env: Env, id: string, by: Pick<Account, "login">, state: "open" | "resolved", note: string | null): Promise<Report | null> { |
| 139 | const r = await report(env, id); |
| 140 | if (!r) return null; |
| 141 | if (state === "resolved") await env.DB.prepare("UPDATE abuse_reports SET state = 'resolved', resolved_at = ?, resolved_by = ?, resolution = ? WHERE id = ?").bind(now(), by.login, note, id).run(); |
| 142 | else await env.DB.prepare("UPDATE abuse_reports SET state = 'open', resolved_at = NULL, resolved_by = NULL, resolution = NULL WHERE id = ?").bind(id).run(); |
| 143 | return report(env, id); |
| 144 | } |
| 145 | |
| 146 | // ---------------------------------------------------------------- blocks |
| 147 | |
| 148 | /** The ids an account acts as for blocks: itself, and an agent's owner too. */ |
| 149 | export const blockSubjects = (a: Pick<Account, "id" | "kind" | "owner_id">): string[] => (a.kind === "agent" && a.owner_id ? [a.id, a.owner_id] : [a.id]); |
| 150 | |
| 151 | /** Whether `blockerId` (a person or an organization) blocked any of `ids`. */ |
| 152 | export async function isBlocked(env: Env, blockerId: string, ids: Array<string | null | undefined>): Promise<boolean> { |
| 153 | const list = ids.filter((x): x is string => !!x && x !== blockerId); |
| 154 | if (!list.length) return false; |
| 155 | return !!(await env.DB.prepare(`SELECT 1 FROM user_blocks WHERE blocker_id = ? AND blocked_id IN (${list.map(() => "?").join(", ")}) LIMIT 1`).bind(blockerId, ...list).first()); |
| 156 | } |
| 157 | |
| 158 | /** Everyone who blocked any of `ids` (for notifications: a blocker hears nothing from the people they blocked). */ |
| 159 | export async function blockersOf(env: Env, ids: Array<string | null | undefined>): Promise<Set<string>> { |
| 160 | const list = ids.filter((x): x is string => !!x); |
| 161 | if (!list.length) return new Set(); |
| 162 | const r = await env.DB.prepare(`SELECT blocker_id FROM user_blocks WHERE blocked_id IN (${list.map(() => "?").join(", ")})`).bind(...list).all<{ blocker_id: string }>(); |
| 163 | return new Set(r.results.map((x) => x.blocker_id)); |
| 164 | } |
| 165 | |
| 166 | export interface Block { |
| 167 | account: Account; |
| 168 | createdAt: string; |
| 169 | createdBy: string | null; |
| 170 | } |
| 171 | |
| 172 | /** The people `blockerId` blocked, by login. */ |
| 173 | export async function blockList(env: Env, blockerId: string): Promise<Block[]> { |
| 174 | const r = await env.DB.prepare("SELECT a.*, b.created_at AS blocked_at, b.created_by AS blocked_by FROM user_blocks b JOIN accounts a ON a.id = b.blocked_id WHERE b.blocker_id = ? ORDER BY a.login LIMIT 1000") |
| 175 | .bind(blockerId) |
| 176 | .all<Account & { blocked_at: string; blocked_by: string | null }>(); |
| 177 | return r.results.map(({ blocked_at, blocked_by, ...account }) => ({ account: account as Account, createdAt: blocked_at, createdBy: blocked_by })); |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Block `user` for `blocker` (a person blocking for themselves, or an organization blocking |
| 182 | * through `by`, one of its owners). Only people can be blocked, never yourself, and an |
| 183 | * organization can't block its own members (remove them first). Like GitHub, blocking also |
| 184 | * removes the person (and their agents) as a collaborator on the blocker's repositories and |
| 185 | * cancels pending invitations between the two. False when the person was already blocked. |
| 186 | */ |
| 187 | export async function block(env: Env, blocker: Account, user: Account, by: Account): Promise<boolean> { |
| 188 | if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; only people can be blocked`); |
| 189 | if (user.id === blocker.id || user.id === by.id) throw new HttpError(422, "you can't block yourself"); |
| 190 | if (blocker.kind === "org" && (await env.DB.prepare("SELECT 1 FROM org_members WHERE org_id = ? AND user_id = ?").bind(blocker.id, user.id).first())) { |
| 191 | throw new HttpError(422, `${user.login} is a member of ${blocker.login}; remove them from the organization before blocking them`); |
| 192 | } |
| 193 | const r = await env.DB.prepare("INSERT INTO user_blocks (blocker_id, blocked_id, created_at, created_by) VALUES (?, ?, ?, ?) ON CONFLICT DO NOTHING").bind(blocker.id, user.id, now(), by.id === blocker.id ? null : by.login).run(); |
| 194 | if (!(r.meta?.changes ?? 0)) return false; |
| 195 | await env.DB.batch([ |
| 196 | env.DB.prepare("DELETE FROM repo_access WHERE repo_id IN (SELECT id FROM repos WHERE owner_id = ?) AND account_id IN (SELECT ? UNION SELECT id FROM accounts WHERE kind = 'agent' AND owner_id = ?)").bind(blocker.id, user.id, user.id), |
| 197 | env.DB.prepare( |
| 198 | `DELETE FROM invites WHERE accepted_at IS NULL AND ( |
| 199 | (account_id = ? AND (org_id = ? OR repo_id IN (SELECT id FROM repos WHERE owner_id = ?) OR invited_by = ?)) |
| 200 | OR (invited_by = ? AND (account_id = ? OR org_id = ?)))`, |
| 201 | ).bind(user.id, blocker.id, blocker.id, blocker.id, user.id, blocker.id, blocker.id), |
| 202 | ]); |
| 203 | return true; |
| 204 | } |
| 205 | |
| 206 | /** Unblock; false when the person wasn't blocked. */ |
| 207 | export async function unblock(env: Env, blockerId: string, userId: string): Promise<boolean> { |
| 208 | const r = await env.DB.prepare("DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?").bind(blockerId, userId).run(); |
| 209 | return (r.meta?.changes ?? 0) > 0; |
| 210 | } |
| 211 | |
| 212 | // ---------------------------------------------------------------- suspension |
| 213 | |
| 214 | type Actor = Pick<Account, "id" | "login">; |
| 215 | type Audit = (entry: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>; |
| 216 | |
| 217 | /** Longest suspension reason, in characters. */ |
| 218 | export const MAX_SUSPEND_REASON = 500; |
| 219 | |
| 220 | export function suspendReason(v: unknown): string { |
| 221 | const s = typeof v === "string" ? v.trim().replace(/\s+/g, " ") : ""; |
| 222 | if (!s) throw new HttpError(422, "give a reason for the suspension (it is shown to the person and kept in the audit log)", { errors: [{ resource: "User", code: "missing_field", field: "reason" }] }); |
| 223 | if (s.length > MAX_SUSPEND_REASON) throw new HttpError(422, `the reason can be at most ${MAX_SUSPEND_REASON} characters`); |
| 224 | return s; |
| 225 | } |
| 226 | |
| 227 | /** |
| 228 | * Suspend a person, as GitHub Enterprise does: their status becomes `disabled` with the reason, |
| 229 | * their browser sessions end, and the work running in their repositories (Actions runs and jobs, |
| 230 | * agent sandboxes) stops. Tokens aren't revoked: they are refused while the suspension lasts and |
| 231 | * work again once it is lifted. Site administrators can't be suspended, nor can you suspend |
| 232 | * yourself. Audited as `user.suspend`. |
| 233 | */ |
| 234 | export async function suspend(env: Env, audit: Audit, actor: Actor, user: Account, reason: string, via: "api" | "web"): Promise<Stopped | null> { |
| 235 | if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; only people can be suspended`); |
| 236 | if (user.id === actor.id) throw new HttpError(422, "you can't suspend yourself"); |
| 237 | if (user.site_admin) throw new HttpError(422, `${user.login} is a site administrator; site administrators can't be suspended`); |
| 238 | const before = user.suspended_at; |
| 239 | await env.DB.batch([ |
| 240 | env.DB.prepare("UPDATE accounts SET status = 'disabled', suspended_at = COALESCE(suspended_at, ?), suspended_by = ?, suspended_reason = ? WHERE id = ?").bind(now(), actor.login, reason, user.id), |
| 241 | env.DB.prepare("DELETE FROM sessions WHERE account_id = ?").bind(user.id), |
| 242 | ]); |
| 243 | let stopped: Stopped | null = null; |
| 244 | try { |
| 245 | const ids = (await env.DB.prepare("SELECT id FROM repos WHERE owner_id = ?").bind(user.id).all<{ id: string }>()).results.map((r) => r.id); |
| 246 | if (ids.length) stopped = await stopIn(env, ids, { hostedOnly: false, message: `Actions are suspended for every repository of ${user.login} by a site administrator: ${reason}` }); |
| 247 | } finally { |
| 248 | await audit({ actorId: actor.id, actorLogin: actor.login, action: "user.suspend", target: user.login, detail: { reason, via, stopped } }); |
| 249 | } |
| 250 | if (!before) await enqueueMail(env, [{ kind: "account_status", data: { status: "suspended", reason }, to: { accountId: user.id }, reason: "account", at: now() }]); |
| 251 | return stopped; |
| 252 | } |
| 253 | |
| 254 | /** Lift a suspension: the account is active again, with its tokens. Audited as `user.unsuspend`; false when it wasn't suspended. */ |
| 255 | export async function unsuspend(env: Env, audit: Audit, actor: Actor, user: Account, via: "api" | "web"): Promise<boolean> { |
| 256 | const r = await env.DB.prepare("UPDATE accounts SET status = 'active', suspended_at = NULL, suspended_by = NULL, suspended_reason = NULL WHERE id = ? AND suspended_at IS NOT NULL").bind(user.id).run(); |
| 257 | if (!(r.meta?.changes ?? 0)) return false; |
| 258 | await audit({ actorId: actor.id, actorLogin: actor.login, action: "user.unsuspend", target: user.login, detail: { via } }); |
| 259 | await enqueueMail(env, [{ kind: "account_status", data: { status: "unsuspended" }, to: { accountId: user.id }, reason: "account", at: now() }]); |
| 260 | return true; |
| 261 | } |
| 262 | |
| 263 | /** Suspended people, newest first, for the site administration page. */ |
| 264 | export async function suspendedAccounts(env: Env): Promise<Array<Pick<Account, "id" | "login" | "avatar_version" | "suspended_at" | "suspended_by" | "suspended_reason">>> { |
| 265 | return (await env.DB.prepare("SELECT id, login, avatar_version, suspended_at, suspended_by, suspended_reason FROM accounts WHERE suspended_at IS NOT NULL ORDER BY suspended_at DESC LIMIT 500").all<Pick<Account, "id" | "login" | "avatar_version" | "suspended_at" | "suspended_by" | "suspended_reason">>()).results; |
| 266 | } |
| 267 | |
| 268 | /** Ids of suspended people: what they wrote is hidden from everyone but site administrators. */ |
| 269 | export async function suspendedIds(env: Env): Promise<string[]> { |
| 270 | return (await env.DB.prepare("SELECT id FROM accounts WHERE suspended_at IS NOT NULL LIMIT 5000").all<{ id: string }>()).results.map((r) => r.id); |
| 271 | } |
| 272 | |
| 273 | /** Whether a person's content is visible to `viewer`: always unless they are suspended, and then only to site administrators. */ |
| 274 | export const visibleTo = (author: { suspended_at?: string | null }, viewer: Pick<Account, "site_admin"> | null): boolean => !author.suspended_at || !!viewer?.site_admin; |
| 275 | |
| 276 | // ---------------------------------------------------------------- hidden (minimized) comments |
| 277 | |
| 278 | /** GitHub's classifiers for minimizing a comment, and how each is shown. */ |
| 279 | export const MINIMIZE_REASONS = { |
| 280 | spam: "This comment was marked as spam.", |
| 281 | abuse: "This comment was marked as abuse.", |
| 282 | off_topic: "This comment was marked as off-topic.", |
| 283 | outdated: "This comment was marked as outdated.", |
| 284 | duplicate: "This comment was marked as duplicate.", |
| 285 | resolved: "This comment was marked as resolved.", |
| 286 | } as const; |
| 287 | export type MinimizeReason = keyof typeof MINIMIZE_REASONS; |
| 288 | |
| 289 | /** A reason as sent by a client: ours (`off_topic`) or GitHub's GraphQL classifier (`OFF_TOPIC`). */ |
| 290 | export function minimizeReason(v: unknown): MinimizeReason { |
| 291 | const s = typeof v === "string" ? v.trim().toLowerCase().replace(/[\s-]+/g, "_") : ""; |
| 292 | if (!(s in MINIMIZE_REASONS)) throw new HttpError(422, `reason must be one of ${Object.keys(MINIMIZE_REASONS).join(", ")}`, { errors: [{ resource: "IssueComment", code: "invalid", field: "reason" }] }); |
| 293 | return s as MinimizeReason; |
| 294 | } |
| 295 | |
| 296 | /** The line a minimized comment shows instead of its body. */ |
| 297 | export const minimizedNote = (reason: string | null | undefined): string => MINIMIZE_REASONS[reason as MinimizeReason] ?? "This comment has been minimized."; |