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.

275 lines · 13.9 KBRaw History
1import { Hono } from "hono";
2import type { Env } from "../env.js";
3import { requireAccount, requireAccountScope, requireUnlimitedToken, type AppContext, type AppVars } from "../auth/principal.js";
4import type { Account } from "../lib/directory.js";
5import {
6 block,
7 blockList,
8 cleanReport,
9 cleanResolution,
10 createReport,
11 isBlocked,
12 minimizeReason,
13 REPORT_KINDS,
14 report,
15 reports,
16 setReportState,
17 suspend,
18 suspendReason,
19 unblock,
20 unsuspend,
21 visibleTo,
22 type Report,
23 type ReportKind,
24 type ReportTarget,
25} from "../lib/moderation.js";
26import { HttpError } from "../lib/util.js";
27import { auditRepo, hiddenAuthors, loadRepo } from "./common.js";
28import { baseOf, ghUser, isoSeconds } from "./gh.js";
29import { jsonBody } from "./ghctx.js";
30
31/**
32 * Moderation endpoints (lib/moderation.ts): blocking people (GitHub's /user/blocks and
33 * /orgs/{org}/blocks), reporting content, the site administrators' report queue, suspending
34 * accounts (GitHub Enterprise's PUT and DELETE /users/{username}/suspended) and hiding comments.
35 */
36export const moderation = new Hono<{ Bindings: Env; Variables: AppVars }>();
37
38const audit = (c: AppContext) => (e: Parameters<AppVars["dir"]["audit"]>[0]) => c.get("dir").audit(e);
39
40/** A person (never an agent) acting for themselves. */
41function person(c: AppContext, what: string): Account {
42 const me = requireAccount(c);
43 if (me.kind !== "user") throw new HttpError(403, `only people can ${what}`);
44 return me;
45}
46
47/** Someone to block or unblock, by login: 404 when there is no such person or you can't see them. */
48async function blockTarget(c: AppContext, me: Account): Promise<Account> {
49 const user = await c.get("dir").accountByLogin(c.req.param("username") ?? "");
50 if (!user || !visibleTo(user, me)) throw new HttpError(404, "Not Found");
51 return user;
52}
53
54// ---------------------------------------------------------------- blocks (a person's own)
55
56moderation.get("/user/blocks", async (c) => {
57 requireAccountScope(c, "read");
58 const me = person(c, "block people");
59 return c.json((await blockList(c.env, me.id)).map((b) => ghUser(baseOf(c), b.account)));
60});
61moderation.get("/user/blocks/:username", async (c) => {
62 requireAccountScope(c, "read");
63 const me = person(c, "block people");
64 const user = await blockTarget(c, me);
65 if (!(await isBlocked(c.env, me.id, [user.id]))) throw new HttpError(404, "Not Found");
66 return c.body(null, 204);
67});
68moderation.put("/user/blocks/:username", async (c) => {
69 requireAccountScope(c, "write");
70 const me = person(c, "block people");
71 const user = await blockTarget(c, me);
72 if (await block(c.env, me, user, me)) c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: "user.block", target: user.login, detail: { via: "api" } }));
73 return c.body(null, 204);
74});
75moderation.delete("/user/blocks/:username", async (c) => {
76 requireAccountScope(c, "write");
77 const me = person(c, "block people");
78 const user = await blockTarget(c, me);
79 if (await unblock(c.env, me.id, user.id)) c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: "user.unblock", target: user.login, detail: { via: "api" } }));
80 return c.body(null, 204);
81});
82
83// ---------------------------------------------------------------- blocks (an organization's, by its owners)
84
85async function orgOwner(c: AppContext, write: boolean): Promise<{ me: Account; org: Account }> {
86 requireAccountScope(c, write ? "admin" : "read");
87 const me = person(c, "block people for an organization");
88 const dir = c.get("dir");
89 const org = await dir.accountByLogin(c.req.param("org") ?? "");
90 if (!org || org.kind !== "org" || org.status === "disabled") throw new HttpError(404, "Not Found");
91 const role = await dir.orgRole(org.id, me.id);
92 if (!role && !me.site_admin) throw new HttpError(404, "Not Found");
93 if (role !== "owner" && !me.site_admin) throw new HttpError(403, "only organization owners can block people for it");
94 return { me, org };
95}
96
97moderation.get("/orgs/:org/blocks", async (c) => {
98 const { org } = await orgOwner(c, false);
99 return c.json((await blockList(c.env, org.id)).map((b) => ghUser(baseOf(c), b.account)));
100});
101moderation.get("/orgs/:org/blocks/:username", async (c) => {
102 const { me, org } = await orgOwner(c, false);
103 const user = await blockTarget(c, me);
104 if (!(await isBlocked(c.env, org.id, [user.id]))) throw new HttpError(404, "Not Found");
105 return c.body(null, 204);
106});
107moderation.put("/orgs/:org/blocks/:username", async (c) => {
108 const { me, org } = await orgOwner(c, true);
109 const user = await blockTarget(c, me);
110 if (await block(c.env, org, user, me)) c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: "org.block", target: org.login, detail: { user: user.login, via: "api" } }));
111 return c.body(null, 204);
112});
113moderation.delete("/orgs/:org/blocks/:username", async (c) => {
114 const { me, org } = await orgOwner(c, true);
115 const user = await blockTarget(c, me);
116 if (await unblock(c.env, org.id, user.id)) c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: "org.unblock", target: org.login, detail: { user: user.login, via: "api" } }));
117 return c.body(null, 204);
118});
119
120// ---------------------------------------------------------------- reports
121
122/** What a report is about, as a client names it. */
123export type ReportSubject = { kind: string; login?: string; repository?: string; number?: number; comment_id?: string };
124
125/**
126 * Resolve what someone reports, checking they can see it: a person or organization by login, a
127 * repository as owner/name, an issue or pull request by number, a comment by id (issue or review
128 * comment, either id form). Keeps an excerpt of the content as it is now.
129 */
130export async function reportTarget(c: AppContext, s: ReportSubject): Promise<ReportTarget> {
131 const kind = REPORT_KINDS.find((k) => k === s.kind) as ReportKind | undefined;
132 if (!kind) throw new HttpError(422, `kind must be one of ${REPORT_KINDS.join(", ")}`, { errors: [{ resource: "Report", code: "invalid", field: "kind" }] });
133 const pub = baseOf(c);
134 const me = requireAccount(c);
135 if (kind === "user") {
136 const a = await c.get("dir").accountByLogin((s.login ?? "").trim().replace(/^@/, ""));
137 if (!a || !visibleTo(a, me) || (a.kind === "org" && a.status === "disabled")) throw new HttpError(404, "no one with that username");
138 return { kind, target: a.login, url: `${pub}/${a.login}`, accountId: a.id, repoId: null, excerpt: a.name && a.name !== a.login ? a.name : null };
139 }
140 const [owner, name, extra] = (s.repository ?? "").trim().split("/");
141 if (!owner || !name || extra !== undefined) throw new HttpError(422, "repository must be owner/name", { errors: [{ resource: "Report", code: "invalid", field: "repository" }] });
142 // A report is a read of what it is about: anyone who can see the repository can report it.
143 const { repo, stub } = await loadRepo(c, owner, name, "read", { readOnlyPost: true });
144 const full = `${repo.owner_login}/${repo.name}`;
145 const html = `${pub}/${full}`;
146 if (kind === "repository") return { kind, target: full, url: html, accountId: repo.owner_id, repoId: repo.id, excerpt: repo.description || null };
147 const hide = await hiddenAuthors(c);
148 if (kind === "issue" || kind === "pull_request") {
149 const n = Number(s.number);
150 if (!Number.isSafeInteger(n) || n < 1) throw new HttpError(422, "number is required", { errors: [{ resource: "Report", code: "missing_field", field: "number" }] });
151 const { issue } = await stub.issue(n, hide);
152 if ((issue.kind === "pull") !== (kind === "pull_request")) throw new HttpError(404, kind === "issue" ? "no issue with that number" : "no pull request with that number");
153 return { kind, target: `${full}#${n}`, url: `${html}/${issue.kind === "pull" ? "pull" : "issues"}/${n}`, accountId: issue.author.id, repoId: repo.id, excerpt: `${issue.title}\n\n${issue.body}`.trim() };
154 }
155 const id = String(s.comment_id ?? "").trim();
156 if (!id) throw new HttpError(422, "comment_id is required", { errors: [{ resource: "Report", code: "missing_field", field: "comment_id" }] });
157 const found = await stub.comment(id, false, hide).catch(() => stub.comment(id, true, hide));
158 const where = `${html}/${found.isPull ? "pull" : "issues"}/${found.number}`;
159 return { kind, target: `${full}#${found.number} comment ${found.comment.id}`, url: found.comment.reviewId ? `${where}/files#c-${found.comment.id}` : `${where}#c-${found.comment.id}`, accountId: found.comment.author.id, repoId: repo.id, excerpt: found.comment.body };
160}
161
162/** File a report and audit it as `report.create`. */
163export async function fileReport(c: AppContext, s: ReportSubject, input: { reason?: unknown; body?: unknown }, via: "api" | "web"): Promise<Report> {
164 const me = requireAccount(c);
165 const clean = cleanReport(input);
166 const target = await reportTarget(c, s);
167 const r = await createReport(c.env, me, target, clean);
168 c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: "report.create", target: r.target, detail: { id: r.id, kind: r.kind, reason: r.reason, via } }));
169 return r;
170}
171
172const reportJson = (c: AppContext, r: Report, full: boolean) => ({
173 id: r.id,
174 kind: r.kind,
175 target: r.target,
176 html_url: r.url,
177 reason: r.reason,
178 body: r.body,
179 state: r.state,
180 created_at: isoSeconds(r.created_at),
181 ...(full
182 ? {
183 reporter: r.reporter_login,
184 reported_account: r.account_login,
185 excerpt: r.excerpt,
186 resolved_at: isoSeconds(r.resolved_at),
187 resolved_by: r.resolved_by,
188 resolution: r.resolution,
189 admin_url: `${baseOf(c)}/settings/admin/reports/${r.id}`,
190 }
191 : {}),
192});
193
194moderation.post("/diffinery/reports", async (c) => {
195 requireAccountScope(c, "write");
196 person(c, "report content");
197 const b = await jsonBody<{ kind?: string; login?: string; repository?: string; number?: number; comment_id?: string | number; reason?: unknown; body?: unknown }>(c);
198 const r = await fileReport(c, { kind: String(b.kind ?? ""), login: b.login, repository: b.repository, number: b.number, comment_id: b.comment_id === undefined ? undefined : String(b.comment_id) }, b, "api");
199 return c.json(reportJson(c, r, false), 201);
200});
201
202function siteAdmin(c: AppContext, write: boolean): Account {
203 if (write) {
204 requireAccountScope(c, "admin");
205 requireUnlimitedToken(c, "moderate");
206 } else requireAccountScope(c, "read");
207 const me = requireAccount(c);
208 if (!me.site_admin) throw new HttpError(403, "site administrators only");
209 return me;
210}
211
212moderation.get("/admin/reports", async (c) => {
213 siteAdmin(c, false);
214 const state = (["open", "resolved", "all"] as const).find((s) => s === c.req.query("state")) ?? "open";
215 const page = Math.max(1, Number(c.req.query("page")) || 1);
216 const per = Math.min(100, Math.max(1, Number(c.req.query("per_page")) || 30));
217 const res = await reports(c.env, state, per, (page - 1) * per);
218 c.header("x-total-count", String(res.total));
219 return c.json(res.items.map((r) => reportJson(c, r, true)));
220});
221moderation.get("/admin/reports/:id", async (c) => {
222 siteAdmin(c, false);
223 const r = await report(c.env, c.req.param("id"));
224 if (!r) throw new HttpError(404, "Not Found");
225 return c.json(reportJson(c, r, true));
226});
227moderation.patch("/admin/reports/:id", async (c) => {
228 const me = siteAdmin(c, true);
229 const b = await jsonBody<{ state?: unknown; resolution?: unknown }>(c);
230 if (b.state !== "resolved" && b.state !== "open") throw new HttpError(422, "state must be resolved or open", { errors: [{ resource: "Report", code: "invalid", field: "state" }] });
231 const note = b.state === "resolved" ? cleanResolution(b.resolution) : null;
232 const r = await setReportState(c.env, c.req.param("id"), me, b.state, note);
233 if (!r) throw new HttpError(404, "Not Found");
234 c.executionCtx.waitUntil(audit(c)({ actorId: me.id, actorLogin: me.login, action: b.state === "resolved" ? "report.resolve" : "report.reopen", target: r.target, detail: { id: r.id, ...(note ? { resolution: note } : {}), via: "api" } }));
235 return c.json(reportJson(c, r, true));
236});
237
238// ---------------------------------------------------------------- suspension (GitHub Enterprise's shape)
239
240async function suspendTarget(c: AppContext): Promise<{ me: Account; user: Account }> {
241 const me = siteAdmin(c, true);
242 const user = await c.get("dir").accountByLogin(c.req.param("username") ?? "");
243 if (!user || user.kind !== "user") throw new HttpError(404, "no person with that username");
244 return { me, user };
245}
246
247moderation.put("/users/:username/suspended", async (c) => {
248 const { me, user } = await suspendTarget(c);
249 const b = await jsonBody<{ reason?: unknown }>(c, true);
250 await suspend(c.env, audit(c), me, user, suspendReason(b.reason), "api");
251 return c.body(null, 204);
252});
253moderation.delete("/users/:username/suspended", async (c) => {
254 const { me, user } = await suspendTarget(c);
255 await unsuspend(c.env, audit(c), me, user, "api");
256 return c.body(null, 204);
257});
258
259// ---------------------------------------------------------------- hidden comments (GitHub's minimizeComment)
260
261const minimizeRoute = "/repos/:owner/:repo/diffinery/comments/:id/minimized";
262moderation.put(minimizeRoute, async (c) => {
263 const { stub, caller, repo } = await loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, "write");
264 const b = await jsonBody<{ reason?: unknown; classifier?: unknown }>(c);
265 const reason = minimizeReason(b.reason ?? b.classifier);
266 const r = await stub.minimizeComment(c.req.param("id")!, reason, caller);
267 auditRepo(c, caller, repo, "comment.minimize", { comment: c.req.param("id"), number: r.number, reason });
268 return c.json({ is_minimized: true, minimized_reason: reason });
269});
270moderation.delete(minimizeRoute, async (c) => {
271 const { stub, caller, repo } = await loadRepo(c, c.req.param("owner")!, c.req.param("repo")!, "write");
272 const r = await stub.minimizeComment(c.req.param("id")!, null, caller);
273 auditRepo(c, caller, repo, "comment.unminimize", { comment: c.req.param("id"), number: r.number });
274 return c.json({ is_minimized: false, minimized_reason: null });
275});