Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

297 lines · 18.4 KBRaw History
1import type { Env } from "../env.js";
2import type { Account } from "./directory.js";
3import { stopIn, type Stopped } from "./killswitch.js";
4import { enqueueMail } from "../mail/send.js";
5import { 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). */
30export 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;
40export type ReportReason = keyof typeof REPORT_REASONS;
41
42export const REPORT_KINDS = ["user", "repository", "issue", "pull_request", "comment"] as const;
43export type ReportKind = (typeof REPORT_KINDS)[number];
44
45/** Longest note a reporter writes, and resolution note an administrator writes, in characters. */
46export const MAX_REPORT_TEXT = 2000;
47/** Longest excerpt of the reported content kept with a report. */
48export const MAX_EXCERPT = 2000;
49/** Reports one person files in 24 hours, on top of the per-minute rate limit. */
50export 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. */
53export 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. */
64export 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). */
72export 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
82export 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
102const 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. */
105export 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
118export 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. */
123export 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
133export 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. */
138export 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. */
149export 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`. */
152export 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). */
159export 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
166export interface Block {
167 account: Account;
168 createdAt: string;
169 createdBy: string | null;
170}
171
172/** The people `blockerId` blocked, by login. */
173export 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 */
187export 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. */
207export 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
214type Actor = Pick<Account, "id" | "login">;
215type Audit = (entry: { actorId?: string; actorLogin?: string; action: string; target?: string; detail?: unknown }) => Promise<void>;
216
217/** Longest suspension reason, in characters. */
218export const MAX_SUSPEND_REASON = 500;
219
220export 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 */
234export 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. */
255export 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. */
264export 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. */
269export 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. */
274export 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. */
279export 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;
287export 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`). */
290export 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. */
297export const minimizedNote = (reason: string | null | undefined): string => MINIMIZE_REASONS[reason as MinimizeReason] ?? "This comment has been minimized.";