| 1 | import { Hono } from "hono"; |
| 2 | import type { Env } from "../env.js"; |
| 3 | import { requireAccount, requireAccountScope, requireUnlimitedToken, type AppContext, type AppVars } from "../auth/principal.js"; |
| 4 | import type { Account } from "../lib/directory.js"; |
| 5 | import { |
| 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"; |
| 26 | import { HttpError } from "../lib/util.js"; |
| 27 | import { auditRepo, hiddenAuthors, loadRepo } from "./common.js"; |
| 28 | import { baseOf, ghUser, isoSeconds } from "./gh.js"; |
| 29 | import { 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 | */ |
| 36 | export const moderation = new Hono<{ Bindings: Env; Variables: AppVars }>(); |
| 37 | |
| 38 | const audit = (c: AppContext) => (e: Parameters<AppVars["dir"]["audit"]>[0]) => c.get("dir").audit(e); |
| 39 | |
| 40 | /** A person (never an agent) acting for themselves. */ |
| 41 | function 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. */ |
| 48 | async 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 | |
| 56 | moderation.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 | }); |
| 61 | moderation.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 | }); |
| 68 | moderation.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 | }); |
| 75 | moderation.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 | |
| 85 | async 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 | |
| 97 | moderation.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 | }); |
| 101 | moderation.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 | }); |
| 107 | moderation.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 | }); |
| 113 | moderation.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. */ |
| 123 | export 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 | */ |
| 130 | export 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`. */ |
| 163 | export 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 | |
| 172 | const 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 | |
| 194 | moderation.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 | |
| 202 | function 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 | |
| 212 | moderation.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 | }); |
| 221 | moderation.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 | }); |
| 227 | moderation.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 | |
| 240 | async 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 | |
| 247 | moderation.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 | }); |
| 253 | moderation.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 | |
| 261 | const minimizeRoute = "/repos/:owner/:repo/diffinery/comments/:id/minimized"; |
| 262 | moderation.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 | }); |
| 270 | moderation.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 | }); |