Moderation: reports to site administrators, blocking, account suspension and hidden comments (#155)
- Report a person, repository, issue, pull request or comment to the site
administrators (web menus and POST /api/v3/diffinery/reports); a queue under
Site administration -> Reports and /api/v3/admin/reports to resolve them.
- Block a person, for yourself or an organization (GitHub's /user/blocks and
/orgs/{org}/blocks): no new issues, pull requests, comments, reviews or
reactions in the blocker's repositories, no collaboration or invitations,
no notifications to the blocker.
- Suspend a person (GitHub Enterprise's PUT/DELETE /users/{u}/suspended):
sessions end, tokens, agents and runners are refused, Actions stop, their
profile, repositories and conversations are hidden; unsuspending restores it.
- Hide (minimize) a comment with GitHub's reasons; it shows collapsed.
Migration 0025_moderation.
Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo
Co-authored-by: Claude <noreply@anthropic.com>Canberk Demirci authored · GitHub committed signed1 parent 6074ea7commit 1afc28e284a0feeabb252e0b236175bd40087a60Browse files
42 files changed+2191−64
Files (42)
- apps/diffinery/docs/accounts/blocking-and-reporting.md+87−0
- apps/diffinery/docs/admin/site-administration.md+52−3
- apps/diffinery/docs/collaboration/issues.md+20−0
- apps/diffinery/docs/nav.json+1−0
- apps/diffinery/docs/reference/rest-api.md+12−1
- apps/diffinery/docs/security/audit-log.md+15−1
- apps/diffinery/migrations/0025_moderation.sql+41−0
- apps/diffinery/scripts/make-ui-icons.mjs+4−0
- apps/diffinery/src/auth/principal.ts+11−1
- apps/diffinery/src/docs/api-meta.ts+73−1
- apps/diffinery/src/http/api.ts+13−9
- apps/diffinery/src/http/common.ts+16−0
- apps/diffinery/src/http/compat.ts+2−0
- apps/diffinery/src/http/ghctx.ts+2−2
- apps/diffinery/src/http/issues.ts+8−6
- apps/diffinery/src/http/moderation.ts+275−0
- apps/diffinery/src/http/search.ts+2−2
- apps/diffinery/src/lib/directory.ts+29−5
- apps/diffinery/src/lib/killswitch.ts+5−2
- apps/diffinery/src/lib/moderation.ts+297−0
- apps/diffinery/src/lib/rate-limit.ts+5−1
- apps/diffinery/src/mail/notify.ts+6−1
- apps/diffinery/src/mail/samples.ts+2−0
- apps/diffinery/src/mail/templates.ts+21−0
- apps/diffinery/src/mail/types.ts+1−1
- apps/diffinery/src/mcp/server.ts+5−5
- apps/diffinery/src/registry/access.ts+3−0
- apps/diffinery/src/repo/RepoDO.ts+5−2
- apps/diffinery/src/repo/collab.ts+34−2
- apps/diffinery/src/repo/schema.ts+5−0
- apps/diffinery/src/repo/social.ts+16−5
- apps/diffinery/src/repo/types.ts+11−0
- apps/diffinery/src/web/account.tsx+108−4
- apps/diffinery/src/web/assets.ts+2−0
- apps/diffinery/src/web/collab.tsx+44−9
- apps/diffinery/src/web/icons.ui.generated.ts+4−0
- apps/diffinery/src/web/moderation.tsx+438−0
- apps/diffinery/src/web/routes.tsx+3−0
- apps/diffinery/src/web/ui.tsx+5−1
- apps/diffinery/test/e2e/moderation.test.mjs+300−0
- apps/diffinery/test/unit/moderation.test.ts+206−0
- apps/diffinery/test/unit/rate-limit.test.ts+2−0
addedapps/diffinery/docs/accounts/blocking-and-reporting.md+87−0View file
| @@ -1,0 +1,87 @@ | ||
| 1 | +--- | |
| 2 | +title: Blocking and reporting | |
| 3 | +description: Block a person from your or your organization's repositories, and report abusive people, repositories, issues, pull requests and comments to the site administrators. | |
| 4 | +--- | |
| 5 | + | |
| 6 | +When someone is abusive, you can block them, which keeps them out of your repositories, and report them, which asks the site administrators to act. Both work as on GitHub. | |
| 7 | + | |
| 8 | +## Block a person | |
| 9 | + | |
| 10 | +Block someone from their profile: open the **…** menu next to their name and click **Block user**, then confirm. Or go to **Settings → Blocked users** (`/settings/blocked-users`), enter their username and click **Block user**. | |
| 11 | + | |
| 12 | +In your repositories, a person you blocked (and their agents) can't: | |
| 13 | + | |
| 14 | +- open issues or pull requests, | |
| 15 | +- comment, review or react, | |
| 16 | +- be added as a collaborator: blocking removes them from every repository you own, and adding them again fails with "olive blocked diffy; unblock them first". | |
| 17 | + | |
| 18 | +Also: | |
| 19 | + | |
| 20 | +- pending invitations between the two of you are cancelled, and neither of you can invite the other, | |
| 21 | +- you get no notifications from them, including when they @mention you anywhere. | |
| 22 | + | |
| 23 | +They can still read your public repositories, and what they wrote before stays. They aren't told they were blocked, but a refused request says so: `403 you can't open issues or pull requests, comment, review or react in olive's repositories: olive blocked you`. Only people can be blocked, not organizations or agents. | |
| 24 | + | |
| 25 | +To unblock, click **Unblock** next to them on **Settings → Blocked users**, or **Unblock user** in the **…** menu on their profile. They aren't added back to anything. | |
| 26 | + | |
| 27 | +### Block a person from an organization | |
| 28 | + | |
| 29 | +An organization's owners block people for the whole organization on its **Settings** tab, under **Blocked users**. The effect is the same in every repository the organization owns. An organization can't block its own members; remove them from the organization first. | |
| 30 | + | |
| 31 | +### Blocks through the API | |
| 32 | + | |
| 33 | +These are GitHub's endpoints. Reading needs any token; blocking and unblocking need a `write`-scoped token (`admin` for an organization). | |
| 34 | + | |
| 35 | +| Method and path | Does | | |
| 36 | +| --- | --- | | |
| 37 | +| `GET /api/v3/user/blocks` | The people you blocked | | |
| 38 | +| `GET /api/v3/user/blocks/{username}` | `204` if you blocked them, `404` if not | | |
| 39 | +| `PUT /api/v3/user/blocks/{username}` | Block them (`204`) | | |
| 40 | +| `DELETE /api/v3/user/blocks/{username}` | Unblock them (`204`) | | |
| 41 | +| `GET`, `PUT`, `DELETE /api/v3/orgs/{org}/blocks[/{username}]` | The same for an organization; owners only | | |
| 42 | + | |
| 43 | +Blocking and unblocking are audited as `user.block`, `user.unblock`, `org.block` and `org.unblock`. | |
| 44 | + | |
| 45 | +## Report content | |
| 46 | + | |
| 47 | +Report a person, a repository, an issue, a pull request or a comment to the site administrators of your instance: | |
| 48 | + | |
| 49 | +- a person: **Report user** in the **…** menu on their profile, | |
| 50 | +- a repository: **Report repository** in the **…** menu next to **Watch**, | |
| 51 | +- an issue or pull request: **Report content** in the **…** menu next to its title, | |
| 52 | +- a comment: **Report content** in the comment's **…** menu. | |
| 53 | + | |
| 54 | +Pick what is wrong (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else), add a note if it helps (up to 2,000 characters; needed for "Something else") and click **Send report**. You see "Thanks for reporting it. The site administrators will look at it." The person you reported isn't told who reported them. | |
| 55 | + | |
| 56 | +You can file 20 reports a day, and one open report about the same thing. Site administrators see what was reported as it was at the time, so editing or deleting it afterwards changes nothing. They resolve the report and may hide the content, suspend the account or do nothing; see [Site administration](/docs/admin/site-administration#reports). | |
| 57 | + | |
| 58 | +### Report through the API | |
| 59 | + | |
| 60 | +`POST /api/v3/diffinery/reports` with a `write`-scoped token. Send `kind` and what identifies the thing: | |
| 61 | + | |
| 62 | +| `kind` | Also send | | |
| 63 | +| --- | --- | | |
| 64 | +| `user` | `login` | | |
| 65 | +| `repository` | `repository` (`owner/name`) | | |
| 66 | +| `issue`, `pull_request` | `repository` and `number` | | |
| 67 | +| `comment` | `repository` and `comment_id` | | |
| 68 | + | |
| 69 | +with `reason` (`spam`, `harassment`, `hate`, `violence`, `sexual`, `malware`, `impersonation` or `other`) and an optional `body`: | |
| 70 | + | |
| 71 | +```sh | |
| 72 | +echo '{"kind": "comment", "repository": "acme/website", "comment_id": "1234", "reason": "spam"}' | diffinery api POST /diffinery/reports --input - | |
| 73 | +``` | |
| 74 | + | |
| 75 | +The answer is `201` with the report's `id`, `kind`, `target`, `html_url`, `reason`, `body`, `state` and `created_at`. Agents can't file reports. | |
| 76 | + | |
| 77 | +## Troubleshooting | |
| 78 | + | |
| 79 | +| Message | Cause and fix | | |
| 80 | +| --- | --- | | |
| 81 | +| `403 ... olive blocked you` | The repository's owner blocked you. | | |
| 82 | +| `422 olive blocked diffy; unblock them first` | You (or your organization) blocked the person you are adding. | | |
| 83 | +| `422 you can't invite diffy` | That person blocked you. | | |
| 84 | +| `422 diffy is a member of acme; remove them from the organization before blocking them` | Organizations can't block their members. | | |
| 85 | +| `422 you already reported this` | Your earlier report about it is still open. | | |
| 86 | +| `429 you can file at most 20 reports in 24 hours` | Try again tomorrow. | | |
| 87 | +| `422 say what is wrong in the note` | "Something else" needs a note. | |
modifiedapps/diffinery/docs/admin/site-administration.md+52−3View file
| @@ -1,17 +1,18 @@ | ||
| 1 | 1 | --- |
| 2 | 2 | title: Site administration |
| 3 | -description: What site administrators do: manage people and access, appoint administrators, delete accounts, issue sign-in links, stop Actions, oversee imports, read the audit log and check email. | |
| 3 | +description: What site administrators do: manage people and admins, handle reports, suspend and delete accounts, issue sign-in links, stop Actions, oversee imports, read the audit log and check email. | |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | 6 | Site administrators run a Diffinery instance. They decide who gets in, help people who lost their passkeys, and check the audit log and email delivery. This page covers who the site administrators are and each page in the **Site administration** group of **Settings**. |
| 7 | 7 | |
| 8 | 8 | ## Site administrators |
| 9 | 9 | |
| 10 | -Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar with four pages: | |
| 10 | +Site administrators have admin on every repository, can act as an owner of every organization (including its **Settings** tab, where the organization's name, picture and base permission are changed, and the **Add people** form on its **People** tab), and see an extra **Site administration** group in the **Settings** sidebar: | |
| 11 | 11 | |
| 12 | 12 | | Page | Path | What it is for | |
| 13 | 13 | | --- | --- | --- | |
| 14 | 14 | | **People and invitations** | `/settings/admin` | Access requests, instance invitations, the list of users, account status and sign-in links | |
| 15 | +| **Reports** | `/settings/admin/reports` | What people reported as abusive, to look at and resolve | | |
| 15 | 16 | | **Email** | `/settings/admin/email` | Email delivery status, the suppression list, template previews and the log of every email | |
| 16 | 17 | | **Kill switches** | `/settings/admin/kill-switches` | Stop Actions and agent sandboxes for a repository, an account or the whole instance | |
| 17 | 18 | | **Audit log** | `/settings/audit` | The most recent security-relevant events | |
| @@ -73,6 +74,8 @@ | ||
| 73 | 74 | | **Pending** | `pending` | Waiting for an invite: can sign in but only sees the **Waiting for an invite** page | |
| 74 | 75 | | **Disabled** | `disabled` | Can't use Diffinery | |
| 75 | 76 | |
| 77 | +A suspended person has a red **suspended** badge, and their status is `disabled` until you unsuspend them (see [Suspend an account](#suspend-an-account)). Each row also has **Suspend** (with a reason), or **Unsuspend** for someone suspended. | |
| 78 | + | |
| 76 | 79 | You can't change your own status ("you cannot change your own status"). |
| 77 | 80 | |
| 78 | 81 | Pending accounts also become active without you when someone invites their email address (including with **Add people**), or when the person accepts an invitation sent to their username on the **Waiting for an invite** page. Being invited by username doesn't make the account active by itself. |
| @@ -98,6 +101,50 @@ | ||
| 98 | 101 | |
| 99 | 102 | Deleting an account is the same as the person deleting it themselves: everything they own goes, what they wrote in other people's repositories is shown as written by **ghost**, and their username can't be used for 90 days. See [Exporting and deleting your account](/docs/accounts/account-data#deleting-your-account). It can't be undone; to keep someone out while you decide, disable the account instead. Each deletion is recorded in the audit log as `account.delete`, with `"via":"admin"`. |
| 100 | 103 | |
| 104 | +### Suspend an account | |
| 105 | + | |
| 106 | +Suspending is for abuse: like disabling, it stops the person at once, but it also hides what they made, and unsuspending puts everything back as it was, tokens included. It works as on GitHub Enterprise. | |
| 107 | + | |
| 108 | +In the **Users** table, click **Suspend** next to the person, enter a **Reason** (at most 500 characters; it is emailed to them and kept in the audit log) and click **Suspend diffy**. You can also suspend from a report about the person. Immediately: | |
| 109 | + | |
| 110 | +- every browser session they have ends, and passkey sign-in is refused with "Your account is disabled.", | |
| 111 | +- their access tokens, their agents' tokens and their runners are refused (`401`), but not revoked, | |
| 112 | +- running workflow runs and agent sandboxes in their repositories stop, and no new ones start, as with [Suspend Actions for an account](#kill-switches), | |
| 113 | +- their profile, repositories (on the web, through Git, the API and the container registry) and packages answer `404` to everyone else, | |
| 114 | +- their issues and pull requests answer `404` and are left out of lists and search, and their comments and reviews are left out of every conversation, | |
| 115 | +- they get a "Your account was suspended" email with the reason. | |
| 116 | + | |
| 117 | +Site administrators still see all of it, with a **suspended** badge on the profile; `GET /api/v3/users/{username}` shows them `suspended_at` and `suspension_reason`. Site administrators can't be suspended, and you can't suspend yourself. | |
| 118 | + | |
| 119 | +**Unsuspend** makes the account active again. Their tokens, agents and runners work again, their content is shown again and they get a "Your account is no longer suspended" email. Their browser sessions stay ended, so they sign in again with their passkey. Workflow runs that were stopped don't restart. | |
| 120 | + | |
| 121 | +Suspensions are audited as `user.suspend` (with the reason and what was stopped) and `user.unsuspend`. From the API, with an `admin`-scoped token that isn't limited to repositories (GitHub Enterprise's endpoints): | |
| 122 | + | |
| 123 | +```sh | |
| 124 | +echo '{"reason": "spam in issues, see report 4f2c"}' | diffinery api PUT /users/diffy/suspended --input - | |
| 125 | +diffinery api DELETE /users/diffy/suspended | |
| 126 | +``` | |
| 127 | + | |
| 128 | +Both answer `204`. Anyone else gets `403 site administrators only`. | |
| 129 | + | |
| 130 | +## Reports | |
| 131 | + | |
| 132 | +Anyone signed in can report a person, a repository, an issue, a pull request or a comment as abusive, from the **…** menu on it (or on a profile) and **Report**. They pick a reason (spam, harassment or bullying, hateful content, violence or threats, sexual content, malware or phishing, impersonation, or something else) and can add a note of up to 2,000 characters; "Something else" needs the note. The person reported isn't told. One person files at most 20 reports a day, and one open report per thing. | |
| 133 | + | |
| 134 | +**Settings → Reports** (`/settings/admin/reports`) lists the open reports, newest first, with what was reported, the reason, who reported it and when; **Resolved** lists the rest. Open one to see: | |
| 135 | + | |
| 136 | +- a link to what was reported, and the account that wrote it, | |
| 137 | +- the reporter's note, | |
| 138 | +- what was reported as it was then (an issue's title and text, a comment's text), so editing or deleting it afterwards hides nothing, | |
| 139 | +- **Resolve with a note**: say what you did (hid the comment, suspended the account, nothing to do) and click **Resolve**. A resolved report can be reopened with **Reopen**. | |
| 140 | +- **Suspend** the account that wrote it, with a reason. | |
| 141 | + | |
| 142 | +Filing, resolving and reopening are audited as `report.create`, `report.resolve` and `report.reopen`. | |
| 143 | + | |
| 144 | +From the API, people file reports with `POST /api/v3/diffinery/reports`; site administrators read the queue with `GET /api/v3/admin/reports` (`?state=open`, `resolved` or `all`) and `GET /api/v3/admin/reports/{id}`, and resolve with `PATCH /api/v3/admin/reports/{id}` and `{"state": "resolved", "resolution": "..."}` (an `admin`-scoped token). See [REST API](/docs/reference/rest-api#moderation). | |
| 145 | + | |
| 146 | +Other tools for abuse: [blocking](/docs/accounts/blocking-and-reporting) (anyone, for themselves or their organization), [hiding comments](/docs/collaboration/issues#hide-a-comment) (anyone with write access to the repository; site administrators everywhere) and the [kill switches](#kill-switches). | |
| 147 | + | |
| 101 | 148 | ## Invites |
| 102 | 149 | |
| 103 | 150 | Under **Invite someone to this instance**, enter an **Email address** and click **Send invitation** (the hint says "We email them a link. Opening it lets them pick a username and create a passkey."). The invite isn't tied to an organization or repository: it only lets the person create an account. Diffinery emails the link and also shows it to you under "Invite created for friend@example.com" with a copy button, with the note that it expires in 14 days and works once. Instance invitations can't be revoked. |
| @@ -268,7 +315,7 @@ | ||
| 268 | 315 | | `DELETE /api/v3/diffinery/admin/email/suppressions/{email}` | Remove an address from the list, whatever put it there; `204`, or `404` when it isn't there. Audited as `email.suppression.remove`. | site administrator, with an `admin` token or a browser session | |
| 269 | 316 | | `GET /api/v3/diffinery/emails?all=1` | Everyone's recent emails (without `all=1`, only yours). Also takes `kind`, `since` and `per_page` (up to 500). | site administrator for `all=1` | |
| 270 | 317 | |
| 271 | -The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`. | |
| 318 | +The sample IDs are `pr-opened`, `issue-opened`, `comment`, `review-approved`, `review-changes`, `review-requested`, `assigned`, `pushed`, `merged`, `closed`, `reopened`, `run-failed`, `signin`, `passkey-added`, `passkey-removed`, `token-created`, `token-expiring`, `agent-created`, `model-key-added`, `review-limit`, `push-bypassed`, `push-blocked`, `commit-blocked`, `account-disabled`, `account-enabled`, `account-suspended`, `account-unsuspended`, `invite-org`, `invite-repo`, `invite-accepted`, `welcome`, `signin-link`, `email-verify`, `access-request`, `org-member`, `repo-access`, `repo-created`, `repo-deleted`, `repo-transferred` and `test`. | |
| 272 | 319 | |
| 273 | 320 | ### Bounces, complaints and the suppression list |
| 274 | 321 | |
| @@ -382,6 +429,8 @@ | ||
| 382 | 429 | | `403 site admin only` from `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | Your account isn't a site administrator. | |
| 383 | 430 | | `404 link not found` | No GitHub link has that id. Ask the account's owner for the id from `GET /api/v3/diffinery/user/linked-accounts`. | |
| 384 | 431 | | "you cannot change your own status" | Ask another site administrator. | |
| 432 | +| `422 ... site administrators can't be suspended` | Site administrators can't be suspended. | | |
| 433 | +| `422 give a reason for the suspension` | Suspending needs a reason. | | |
| 385 | 434 | | `403 Actions are suspended ... by a site administrator` | A kill switch is on for the repository or its owner. Lift it under **Settings → Kill switches**. | |
| 386 | 435 | | "Hosted runners are paused on this instance" | Hosted compute is paused. Resume it under **Settings → Kill switches**, or run the job on a self-hosted runner. | |
| 387 | 436 | | `422 give a reason` | Kill switches need a reason. | |
modifiedapps/diffinery/docs/collaboration/issues.md+20−0View file
| @@ -327,6 +327,25 @@ | ||
| 327 | 327 | |
| 328 | 328 | Only the comment's author or someone with maintain or higher can edit or delete it (`permission denied` otherwise). Deleting a comment also deletes its reactions. The web UI and the CLI have no comment editing or deleting yet. |
| 329 | 329 | |
| 330 | +### Hide a comment | |
| 331 | + | |
| 332 | +Someone with write access to the repository (site administrators everywhere) can hide a comment that is spam, abuse, off-topic, outdated, a duplicate or resolved, as GitHub's **Hide** does. On the web, open the comment's **…** menu and pick a reason under **Hide comment**. The comment stays, collapsed behind a line such as "This comment was marked as spam."; anyone can click **Show comment** to read it. **Unhide** in the same menu shows it again. This works on issue and pull request comments, review comments and reviews' comments alike. | |
| 333 | + | |
| 334 | +Through the API (Diffinery's; GitHub only has GraphQL's `minimizeComment`), with the comment's `id`: | |
| 335 | + | |
| 336 | +```sh | |
| 337 | +curl -X PUT https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1234/minimized \ | |
| 338 | + -H "Authorization: Bearer dfy_..." -H "Content-Type: application/json" \ | |
| 339 | + -d '{"reason": "spam"}' | |
| 340 | + | |
| 341 | +curl -X DELETE https://diffinery.com/api/v3/repos/acme/website/diffinery/comments/1234/minimized \ | |
| 342 | + -H "Authorization: Bearer dfy_..." | |
| 343 | +``` | |
| 344 | + | |
| 345 | +`reason` is `spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved` (GitHub's classifiers, such as `OFF_TOPIC` in `classifier`, work too). The answer is `{"is_minimized": true, "minimized_reason": "spam"}`. Agents can't hide comments. Hiding and showing are audited as `comment.minimize` and `comment.unminimize`. | |
| 346 | + | |
| 347 | +To report a comment to the site administrators instead, pick **Report content** in the same menu. See [Blocking and reporting](/docs/accounts/blocking-and-reporting). | |
| 348 | + | |
| 330 | 349 | ### Edit the title and description |
| 331 | 350 | |
| 332 | 351 | - **Web UI:** click **Edit** next to the title, change **Title** or **Description**, and click **Save**. An empty title is rejected with `title is required`. |
| @@ -586,6 +605,7 @@ | ||
| 586 | 605 | | `permission denied` | You tried to edit, close or reopen someone else's issue without triage access, or edit someone else's comment without maintain access. | |
| 587 | 606 | | `triage access required` | Creating, changing or deleting labels, setting labels and assignees in the web UI, the issue label, assignee and lock endpoints, and creating, editing and setting milestones need triage or higher. | |
| 588 | 607 | | `#12 not found` | There is no issue or pull request with that number. | |
| 608 | +| `you can't open issues or pull requests, comment, review or react in acme's repositories: acme blocked you` | The repository's owner blocked you. See [Blocking and reporting](/docs/accounts/blocking-and-reporting). | | |
| 589 | 609 | | `conversation is locked` | The issue is locked. Only triage or higher can comment or react. | |
| 590 | 610 | | `a label with this name already exists` | Use `PATCH /labels/{name}` to change an existing label. | |
| 591 | 611 | | `milestone not found` | There is no milestone with that number. List them with `GET .../milestones?state=all`. | |
modifiedapps/diffinery/docs/reference/rest-api.md+12−1View file
| @@ -407,7 +407,7 @@ | ||
| 407 | 407 | | Sign-in, passkey registration, invitation and sign-in links, open sign-up (`POST /signup`, `POST /signup/verify`) | 20 a minute | IP address | |
| 408 | 408 | | Anything else without signing in (web pages, API, git) | 300 a minute | IP address | |
| 409 | 409 | | Anything with a session or token (web, API, MCP, git) | 2,000 a minute | Account | |
| 410 | -| Creating repositories, organizations, forks, issues, pull requests, releases, comments and reviews, and MCP tools that change something | 60 a minute | Account | | |
| 410 | +| Creating repositories, organizations, forks, issues, pull requests, releases, comments, reviews and reports, and MCP tools that change something | 60 a minute | Account | | |
| 411 | 411 | |
| 412 | 412 | Workflow jobs, sandboxes, self-hosted runners and imports use their own credentials and aren't counted. The counts are kept in each Cloudflare location, so they stop bursts rather than meter use exactly. Past a limit, the request answers `429` with a message naming the limit, `retry-after: 60` and `x-ratelimit-remaining: 0`; Octokit's throttling plugin waits and retries. |
| 413 | 413 | |
| @@ -532,6 +532,15 @@ | ||
| 532 | 532 | - Team reviewers (`team_reviewers`) are refused with `422 team reviewers are not supported; request people by login`. |
| 533 | 533 | - **Milestones:** `GET /milestones` (`state` `open`, `closed` or `all`), `GET /milestones/{n}`, `POST /milestones` and `PATCH /milestones/{n}` (triage). There is no `DELETE`. Set an issue's or pull request's milestone with `PATCH /issues/{n}` and `"milestone": <number>` or `null` (triage); issues and pull requests carry a `milestone` object. |
| 534 | 534 | |
| 535 | +### Moderation | |
| 536 | + | |
| 537 | +As on GitHub, a person or an organization's owners block people, people report abuse, and site administrators suspend accounts. See [Blocking and reporting](/docs/accounts/blocking-and-reporting) and [Site administration](/docs/admin/site-administration#reports). | |
| 538 | + | |
| 539 | +- **Blocks:** `GET /user/blocks`, and `GET`, `PUT`, `DELETE /user/blocks/{username}`; `GET /orgs/{org}/blocks`, and `GET`, `PUT`, `DELETE /orgs/{org}/blocks/{username}` (organization owners). `GET` on one answers `204` if blocked and `404` if not. In the blocker's repositories, a blocked person (or their agent) gets `403 you can't open issues or pull requests, comment, review or react in <owner>'s repositories: <owner> blocked you` for new issues, pull requests, comments, reviews and reactions, and adding them as a collaborator fails with `422`. | |
| 540 | +- **Reports:** `POST /diffinery/reports` (people, `write` scope; at most 20 a day). Site administrators: `GET /admin/reports` (`state` `open`, `resolved` or `all`; `page`, `per_page`; the total is in `x-total-count`), `GET /admin/reports/{id}` and `PATCH /admin/reports/{id}` with `state` (`resolved` with a `resolution` note, or `open`). | |
| 541 | +- **Suspensions:** `PUT /users/{username}/suspended` with `{"reason": "..."}` and `DELETE /users/{username}/suspended` (site administrators, `admin` scope; GitHub Enterprise's endpoints). While suspended, the person's tokens and their agents' get `401`, and their profile, repositories, issues and pull requests answer `404` to everyone but site administrators; their comments and reviews are left out of lists. Site administrators see `suspended_at` and `suspension_reason` on `GET /users/{username}`. | |
| 542 | +- **Hidden comments:** `PUT /repos/{owner}/{repo}/diffinery/comments/{id}/minimized` with `reason` (`spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved`) and `DELETE` on the same path (write access, people only), GitHub's GraphQL `minimizeComment` and `unminimizeComment` as REST. See [Hide a comment](/docs/collaboration/issues#hide-a-comment). | |
| 543 | + | |
| 535 | 544 | ### Deployments and environments |
| 536 | 545 | |
| 537 | 546 | - **Deployments:** create, list, get and delete; deployment statuses: create, list and get. A deployment without `required_contexts` needs every check and status on the commit to have passed, as on GitHub (those reported by agents are not counted); send `"required_contexts": []` to skip the check. A deployment whose contexts haven't passed is refused with `409`. |
| @@ -659,6 +668,8 @@ | ||
| 659 | 668 | | `GET`, `POST /diffinery/imports`, `GET /diffinery/imports/{id}`, `POST /diffinery/imports/{id}/cancel`, `/retry`, `/selection` | Import repositories from GitHub, follow and manage the imports. See [Imports](#imports). | |
| 660 | 669 | | `GET /diffinery/user/linked-accounts`, `POST /diffinery/user/linked-accounts/github/challenge`, `POST /diffinery/user/linked-accounts/github`, `DELETE /diffinery/user/linked-accounts/{id}` | Link your GitHub account (gist proof, no token), so imports attribute your GitHub work to you. | |
| 661 | 670 | | `DELETE /diffinery/admin/linked-accounts/{id}` | Site administrators: clear anyone's GitHub link. | |
| 671 | +| `POST /diffinery/reports` | Report a person, repository, issue, pull request or comment to the site administrators. See [Moderation](#moderation). | | |
| 672 | +| `PUT`, `DELETE /repos/{owner}/{repo}/diffinery/comments/{id}/minimized` | Hide (minimize) a comment with a reason, or show it again. | | |
| 662 | 673 | |
| 663 | 674 | Other Diffinery additions sit next to the GitHub-shaped endpoints: |
| 664 | 675 |
modifiedapps/diffinery/docs/security/audit-log.md+15−1View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | description: What Diffinery records in its audit log, the fields of each entry, who can view it, and which entries also send security emails. |
| 4 | 4 | --- |
| 5 | 5 | |
| 6 | -The audit log is an append-only record of security-relevant actions on a Diffinery instance: sign-ins, passkey and token changes, profile pictures, repository and environment settings (from the web UI and the API alike), pushes, merges, admin merges that bypass branch protection, push protection decisions, package changes, imports from GitHub and linked GitHub accounts. Site administrators use it to answer "who did what, when, and on whose behalf". | |
| 6 | +The audit log is an append-only record of security-relevant actions on a Diffinery instance: sign-ins, passkey and token changes, profile pictures, repository and environment settings (from the web UI and the API alike), pushes, merges, admin merges that bypass branch protection, push protection decisions, package changes, imports from GitHub, linked GitHub accounts, and moderation (reports, blocks, suspensions and hidden comments). Site administrators use it to answer "who did what, when, and on whose behalf". | |
| 7 | 7 | |
| 8 | 8 | ## Viewing the audit log |
| 9 | 9 | |
| @@ -64,6 +64,8 @@ | ||
| 64 | 64 | | `account.delete` | Someone deletes their account on **Settings → Account**, or a site administrator deletes it (see [Exporting and deleting your account](/docs/accounts/account-data)) | the deleted account's login | `via` (`settings` or `admin`), `repositories` and `packages` (how many were deleted), `agents` (the logins of the agents deleted with it) | |
| 65 | 65 | | `account.export` | Someone asks for an export of their account on **Settings → Account** | none | `id` (the export's id) | |
| 66 | 66 | | `account.export_download` | Someone downloads their account export | none | `id`, `size` (in bytes) | |
| 67 | +| `user.suspend` | A site administrator [suspends](/docs/admin/site-administration#suspend-an-account) a person, from the **Users** table, a report, or `PUT /api/v3/users/{username}/suspended` | that person's login | `reason`, `via` (`web` or `api`), `stopped` (runs, jobs and sandboxes stopped in their repositories) | | |
| 68 | +| `user.unsuspend` | A site administrator lifts a suspension (**Unsuspend**, or `DELETE /api/v3/users/{username}/suspended`) | that person's login | `via` | | |
| 67 | 69 | | `account.link_github` | You link a GitHub account on **Settings → Linked accounts** (gist code or GitHub sign-in) or with `POST /api/v3/diffinery/user/linked-accounts/github` | `<host>/<GitHub login>`, such as `github.com/diffy` | none | |
| 68 | 70 | | `account.unlink_github` | You unlink a GitHub account (**Unlink**, or `DELETE /api/v3/diffinery/user/linked-accounts/{id}`) | the link's id | none | |
| 69 | 71 | | `account.link_cleared` | A site admin clears someone's GitHub link with `DELETE /api/v3/diffinery/admin/linked-accounts/{id}` | `<host>/<GitHub login>` | `account` (the id of the Diffinery account the link belonged to) | |
| @@ -119,11 +121,23 @@ | ||
| 119 | 121 | | `actions.suspend` | A site administrator suspends Actions for a repository or an account ([kill switches](/docs/admin/site-administration#kill-switches)), on **Settings → Kill switches** or with `PUT /api/v3/admin/repos/{owner}/{repo}/actions-suspension` or `PUT /api/v3/admin/accounts/{login}/actions-suspension` | `owner/name`, or the account's login | `kind` (`repo` or `account`), `reason`, `via` (`web` or `api`), `stopped` (runs, jobs and sandboxes stopped) | |
| 120 | 122 | | `actions.unsuspend` | A site administrator lifts that suspension (**Lift**, or `DELETE` on the same path) | `owner/name`, or the account's login | `kind`, `via` | |
| 121 | 123 | | `compute.pause` | A site administrator pauses hosted compute on the instance (**Pause hosted compute**, or `PUT /api/v3/admin/hosted-compute-pause`) | none | `reason`, `via`, `stopped` | |
| 124 | +| `org.block`, `org.unblock` | An organization owner [blocks](/docs/accounts/blocking-and-reporting) or unblocks a person for the organization (its **Settings** tab, or `PUT`/`DELETE /api/v3/orgs/{org}/blocks/{username}`) | the organization's login | `user`, `via` | | |
| 122 | 125 | | `compute.unpause` | A site administrator resumes it (**Resume hosted compute**, or `DELETE /api/v3/admin/hosted-compute-pause`) | none | `via` | |
| 123 | 126 | | `email.suppression.add` | An address goes on the email suppression list: a permanent bounce or a spam complaint arrives (no actor, shown as `system`), or a site administrator adds it (**Suppress an address** on **Settings → Email**, or `PUT /api/v3/diffinery/admin/email/suppressions/{email}`). See [the suppression list](/docs/admin/site-administration#bounces-complaints-and-the-suppression-list). | the email address | `reason` (`hard-bounce`, `complaint` or `manual`), `source` (`cloudflare`, `webhook` or `admin`), `detail` (the provider's words or the administrator's note) | |
| 124 | 127 | | `email.suppression.remove` | A site administrator removes an address from the list (**Remove**, or `DELETE /api/v3/diffinery/admin/email/suppressions/{email}`) | the email address | `reason` and `source` of the entry removed | |
| 125 | 128 | | `email.suppression.lift` | Someone lifts a bounce or complaint on their own address by following the verification link from **Settings → Email** | the email address | `reason` of the entry lifted | |
| 126 | 129 | |
| 130 | +### Moderation | |
| 131 | + | |
| 132 | +| Action | Recorded when | Target | Detail | | |
| 133 | +|---|---|---|---| | |
| 134 | +| `user.block`, `user.unblock` | Someone [blocks](/docs/accounts/blocking-and-reporting) or unblocks a person (a profile's **…** menu, **Settings → Blocked users**, or `PUT`/`DELETE /api/v3/user/blocks/{username}`) | the person blocked | `via` | | |
| 135 | +| `report.create` | Someone reports content to the site administrators (**Report**, or `POST /api/v3/diffinery/reports`) | what was reported: a login, `owner/name`, `owner/name#12`, or `owner/name#12 comment <id>` | `id` (the report's), `kind`, `reason`, `via` | | |
| 136 | +| `report.resolve`, `report.reopen` | A site administrator resolves a report with a note, or reopens it (**Settings → Reports**, or `PATCH /api/v3/admin/reports/{id}`) | as for `report.create` | `id`, `resolution` (for `resolve`), `via` | | |
| 137 | +| `comment.minimize`, `comment.unminimize` | Someone with write access [hides a comment](/docs/collaboration/issues#hide-a-comment) or shows it again | `owner/name` | `comment` (its id), `number` (the issue or pull request), `reason` (for `minimize`), `via` | | |
| 138 | + | |
| 139 | +Suspensions and organization blocks are listed under [Organizations and invitations](#organizations-and-invitations) and [Sign-in and account security](#sign-in-and-account-security). | |
| 140 | + | |
| 127 | 141 | ### Repositories |
| 128 | 142 | |
| 129 | 143 | All repository entries use the repository (`acme/website`) as the target. Settings changes are recorded the same way whether they are made on the web settings pages or through the REST API (and the CLI, which uses it). Entries written for an API call add `via: "api"` to the detail. |
addedapps/diffinery/migrations/0025_moderation.sql+41−0View file
| @@ -1,0 +1,41 @@ | ||
| 1 | +-- Moderation (lib/moderation.ts): suspended accounts, blocked users and reports to site administrators. | |
| 2 | + | |
| 3 | +-- A site administrator suspended this person: accounts.status is 'disabled' while it lasts (nothing signs in, | |
| 4 | +-- no token, session, agent or runner works) and their repositories, profile and conversations are hidden from | |
| 5 | +-- everyone else. Unlike disabling, suspending keeps their tokens, so unsuspending restores everything. NULL is not suspended. | |
| 6 | +ALTER TABLE accounts ADD COLUMN suspended_at TEXT; | |
| 7 | +ALTER TABLE accounts ADD COLUMN suspended_by TEXT; | |
| 8 | +ALTER TABLE accounts ADD COLUMN suspended_reason TEXT; | |
| 9 | + | |
| 10 | +-- A person, or an organization (by one of its owners), blocked a person: in the blocker's repositories the | |
| 11 | +-- blocked person can't open issues or pull requests, comment, review or react, and the two can't invite each other. | |
| 12 | +CREATE TABLE IF NOT EXISTS user_blocks ( | |
| 13 | + blocker_id TEXT NOT NULL REFERENCES accounts(id), | |
| 14 | + blocked_id TEXT NOT NULL REFERENCES accounts(id), | |
| 15 | + created_at TEXT NOT NULL, | |
| 16 | + created_by TEXT, -- the organization owner who blocked; the person themselves otherwise | |
| 17 | + PRIMARY KEY (blocker_id, blocked_id) | |
| 18 | +); | |
| 19 | +CREATE INDEX IF NOT EXISTS user_blocks_blocked ON user_blocks(blocked_id); | |
| 20 | + | |
| 21 | +-- Reports of abusive content, the queue site administrators work through. `excerpt` keeps what was reported | |
| 22 | +-- as it was then, so editing or deleting it afterwards hides nothing from the administrators. | |
| 23 | +CREATE TABLE IF NOT EXISTS abuse_reports ( | |
| 24 | + id TEXT PRIMARY KEY, | |
| 25 | + reporter_id TEXT NOT NULL REFERENCES accounts(id), | |
| 26 | + kind TEXT NOT NULL, -- user, repository, issue, pull_request or comment | |
| 27 | + target TEXT NOT NULL, -- login, owner/name, owner/name#12, or owner/name#12 and the comment id | |
| 28 | + url TEXT NOT NULL, | |
| 29 | + account_id TEXT, -- the person reported, or who wrote the content | |
| 30 | + repo_id TEXT, | |
| 31 | + excerpt TEXT, | |
| 32 | + reason TEXT NOT NULL, | |
| 33 | + body TEXT NOT NULL DEFAULT '', | |
| 34 | + state TEXT NOT NULL DEFAULT 'open', -- open or resolved | |
| 35 | + created_at TEXT NOT NULL, | |
| 36 | + resolved_at TEXT, | |
| 37 | + resolved_by TEXT, | |
| 38 | + resolution TEXT | |
| 39 | +); | |
| 40 | +CREATE INDEX IF NOT EXISTS abuse_reports_state ON abuse_reports(state, created_at); | |
| 41 | +CREATE INDEX IF NOT EXISTS abuse_reports_reporter ON abuse_reports(reporter_id, created_at); |
modifiedapps/diffinery/scripts/make-ui-icons.mjs+4−0View file
| @@ -59,6 +59,10 @@ | ||
| 59 | 59 | caret: ["CaretDown", "regular"], |
| 60 | 60 | link: ["LinkSimple", "regular"], |
| 61 | 61 | rocket: ["RocketLaunch", "regular"], |
| 62 | + more: ["DotsThree", "bold"], | |
| 63 | + flag: ["Flag", "regular"], | |
| 64 | + prohibit: ["Prohibit", "regular"], | |
| 65 | + eyeSlash: ["EyeSlash", "regular"], | |
| 62 | 66 | }; |
| 63 | 67 | |
| 64 | 68 | const out = {}; |
modifiedapps/diffinery/src/auth/principal.ts+11−1View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import type { Caller, Role } from "../repo/types.js"; |
| 8 | 8 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 9 | 9 | import { taskPersonAllowed } from "../agentic/access.js"; |
| 10 | +import { blockSubjects, isBlocked } from "../lib/moderation.js"; | |
| 10 | 11 | |
| 11 | 12 | export type Principal = |
| 12 | 13 | | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string; sessionAt?: string; importId?: string; tokenId?: string } |
| @@ -18,6 +19,8 @@ | ||
| 18 | 19 | dir: Directory; |
| 19 | 20 | /** Set for MCP tools that only read, so POST-carried reads count as reads. */ |
| 20 | 21 | readOnly?: boolean; |
| 22 | + /** Suspended people whose content this request hides, once looked up (http/common.ts hiddenAuthors). */ | |
| 23 | + hidden?: Promise<string[]>; | |
| 21 | 24 | } |
| 22 | 25 | |
| 23 | 26 | export type AppContext = Context<{ Bindings: Env; Variables: AppVars }>; |
| @@ -194,6 +197,9 @@ | ||
| 194 | 197 | export async function callerFor(c: AppContext, repo: Repo): Promise<Caller | null> { |
| 195 | 198 | const p = c.get("principal"); |
| 196 | 199 | const dir = c.get("dir"); |
| 200 | + // A suspended person's repositories are hidden from everyone but site administrators, and their | |
| 201 | + // job, sandbox and runner tokens stop working (lib/moderation.ts). | |
| 202 | + if (repo.owner_suspended_at && !(p.type === "account" && p.account.site_admin)) return null; | |
| 197 | 203 | if (p.type === "repo-token") { |
| 198 | 204 | // Like GITHUB_TOKEN, a job or sandbox token reads other repositories only when they are public. |
| 199 | 205 | if (p.repoId !== repo.id) return repo.visibility === "public" ? { id: "anonymous", login: "anonymous", kind: "user", role: "read" } : null; |
| @@ -228,5 +234,9 @@ | ||
| 228 | 234 | const a = p.account; |
| 229 | 235 | let onBehalfOf: string | undefined; |
| 230 | 236 | if (a.kind === "agent" && a.owner_id) onBehalfOf = (await dir.account(a.owner_id))?.login; |
| 231 | - return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role }; | |
| 237 | + // Someone the repository's owner blocked (or their agent) can still read, but not take part (lib/moderation.ts). | |
| 238 | + // Checked for changes only: reads stay as fast as they were. | |
| 239 | + const writing = c.req.method !== "GET" && c.req.method !== "HEAD"; | |
| 240 | + const blocked = writing && repo.owner_id !== a.id && (await isBlocked(c.env, repo.owner_id, blockSubjects(a))); | |
| 241 | + return { id: a.id, login: a.login, kind: a.kind === "agent" ? "agent" : "user", onBehalfOf, role, ...(blocked ? { blockedBy: repo.owner_login } : {}) }; | |
| 232 | 242 | } |
modifiedapps/diffinery/src/docs/api-meta.ts+73−1View file
| @@ -116,6 +116,20 @@ | ||
| 116 | 116 | "POST /diffinery/user/linked-accounts/github": { scope: "admin" }, |
| 117 | 117 | "DELETE /diffinery/user/linked-accounts/{id}": { scope: "admin" }, |
| 118 | 118 | "DELETE /diffinery/admin/linked-accounts/{id}": { scope: "admin" }, |
| 119 | + "GET /user/blocks": { scope: "read" }, | |
| 120 | + "GET /user/blocks/{username}": { scope: "read" }, | |
| 121 | + "PUT /user/blocks/{username}": { scope: "write" }, | |
| 122 | + "DELETE /user/blocks/{username}": { scope: "write" }, | |
| 123 | + "GET /orgs/{org}/blocks": { scope: "read" }, | |
| 124 | + "GET /orgs/{org}/blocks/{username}": { scope: "read" }, | |
| 125 | + "PUT /orgs/{org}/blocks/{username}": { scope: "admin" }, | |
| 126 | + "DELETE /orgs/{org}/blocks/{username}": { scope: "admin" }, | |
| 127 | + "POST /diffinery/reports": { scope: "write" }, | |
| 128 | + "GET /admin/reports": { scope: "read" }, | |
| 129 | + "GET /admin/reports/{id}": { scope: "read" }, | |
| 130 | + "PATCH /admin/reports/{id}": { scope: "admin" }, | |
| 131 | + "PUT /users/{username}/suspended": { scope: "admin" }, | |
| 132 | + "DELETE /users/{username}/suspended": { scope: "admin" }, | |
| 119 | 133 | }; |
| 120 | 134 | |
| 121 | 135 | const page: Record<string, Field> = { |
| @@ -140,7 +154,7 @@ | ||
| 140 | 154 | access: "account", |
| 141 | 155 | }, |
| 142 | 156 | "DELETE /user/avatar": { summary: "Remove your profile picture", description: "Diffinery only. Goes back to generated initials and returns the `avatar_url`. Same token rules as setting one. Audited.", access: "account" }, |
| 143 | - "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login.", access: "account" }, | |
| 157 | + "GET /users/{login}": { summary: "Get an account", description: "A user, organization or agent by login. A suspended person is `404` except to site administrators, who also see `suspended_at` and `suspension_reason`.", access: "account" }, | |
| 144 | 158 | "GET /user/usage": { |
| 145 | 159 | summary: "Get your quotas and usage", |
| 146 | 160 | description: "Hosted minutes used this month, hosted jobs running, sandboxes open, repositories owned and bytes stored, each with its `limit` (`-1` is unlimited), and `exempt` for accounts run by a site administrator. An agent gets its owner's. See [Limits](/docs/reference/limits#account-quotas).", |
| @@ -255,6 +269,64 @@ | ||
| 255 | 269 | "GET /invitations": { summary: "List pending invitations to this instance", description: "Site-wide invites only, not those to an organization or repository. Site admins only.", access: "site-admin" }, |
| 256 | 270 | "DELETE /invitations/{id}": { summary: "Revoke an invitation to this instance", description: "Site admins only. 404 when no pending site-wide invitation has this id. Audited.", access: "site-admin", status: 204 }, |
| 257 | 271 | |
| 272 | + // ------------------------------------------------------------ moderation (lib/moderation.ts) | |
| 273 | + "GET /user/blocks": { summary: "List the people you blocked", description: "People only. See [Blocking and reporting](/docs/accounts/blocking-and-reporting).", access: "account" }, | |
| 274 | + "GET /user/blocks/{username}": { summary: "Check whether you blocked a person", description: "`204` if you did, `404` if not.", access: "account", status: 204 }, | |
| 275 | + "PUT /user/blocks/{username}": { | |
| 276 | + summary: "Block a person", | |
| 277 | + description: "In your repositories they (and their agents) can't open issues or pull requests, comment, review or react; they stop being a collaborator, pending invitations between you are cancelled and you get no notifications from them. Audited as `user.block`.", | |
| 278 | + access: "account", | |
| 279 | + status: 204, | |
| 280 | + }, | |
| 281 | + "DELETE /user/blocks/{username}": { summary: "Unblock a person", description: "Audited as `user.unblock`.", access: "account", status: 204 }, | |
| 282 | + "GET /orgs/{org}/blocks": { summary: "List the people an organization blocked", description: "Organization owners and site administrators.", access: "org-owner" }, | |
| 283 | + "GET /orgs/{org}/blocks/{username}": { summary: "Check whether an organization blocked a person", description: "`204` if it did, `404` if not.", access: "org-owner", status: 204 }, | |
| 284 | + "PUT /orgs/{org}/blocks/{username}": { summary: "Block a person from an organization", description: "As blocking for yourself, for every repository the organization owns. Members can't be blocked. Audited as `org.block`.", access: "org-owner", status: 204 }, | |
| 285 | + "DELETE /orgs/{org}/blocks/{username}": { summary: "Unblock a person from an organization", description: "Audited as `org.unblock`.", access: "org-owner", status: 204 }, | |
| 286 | + "POST /diffinery/reports": { | |
| 287 | + summary: "Report content to the site administrators", | |
| 288 | + description: "Diffinery only. People only, at most 20 a day and one open report per thing. Returns the report (`id`, `kind`, `target`, `html_url`, `reason`, `body`, `state`, `created_at`). Audited as `report.create`.", | |
| 289 | + access: "account", | |
| 290 | + status: 201, | |
| 291 | + body: { | |
| 292 | + kind: ["string", "`user`, `repository`, `issue`, `pull_request` or `comment`.", true], | |
| 293 | + login: ["string", "The person, for `user`."], | |
| 294 | + repository: ["string", "`owner/name`, for everything but `user`."], | |
| 295 | + number: ["integer", "The issue or pull request, for `issue` and `pull_request`."], | |
| 296 | + comment_id: ["string", "The comment, for `comment`."], | |
| 297 | + reason: ["string", "`spam`, `harassment`, `hate`, `violence`, `sexual`, `malware`, `impersonation` or `other`.", true], | |
| 298 | + body: ["string", "A note for the administrators (up to 2,000 characters; required for `other`)."], | |
| 299 | + }, | |
| 300 | + }, | |
| 301 | + "GET /admin/reports": { | |
| 302 | + summary: "List reports", | |
| 303 | + description: "Site administrators only. Newest first, with the reporter, the account reported, the excerpt kept when it was reported and the resolution. The total is in `x-total-count`.", | |
| 304 | + access: "site-admin", | |
| 305 | + query: { state: ["string", "`open` (default), `resolved` or `all`."], ...page }, | |
| 306 | + }, | |
| 307 | + "GET /admin/reports/{id}": { summary: "Get a report", description: "Site administrators only.", access: "site-admin" }, | |
| 308 | + "PATCH /admin/reports/{id}": { | |
| 309 | + summary: "Resolve or reopen a report", | |
| 310 | + description: "Site administrators only. Audited as `report.resolve` or `report.reopen`.", | |
| 311 | + access: "site-admin", | |
| 312 | + body: { state: ["string", "`resolved` or `open`.", true], resolution: ["string", "What you did, for `resolved` (up to 2,000 characters)."] }, | |
| 313 | + }, | |
| 314 | + "PUT /users/{username}/suspended": { | |
| 315 | + summary: "Suspend a person", | |
| 316 | + description: "Site administrators only (GitHub Enterprise's endpoint). Their sessions end, their tokens, agents and runners are refused, Actions in their repositories stop, and their profile, repositories and conversations are hidden from everyone else until they are unsuspended. Audited as `user.suspend`.", | |
| 317 | + access: "site-admin", | |
| 318 | + status: 204, | |
| 319 | + body: { reason: ["string", "Why (up to 500 characters). Emailed to them and kept in the audit log.", true] }, | |
| 320 | + }, | |
| 321 | + "DELETE /users/{username}/suspended": { summary: "Unsuspend a person", description: "Site administrators only. Everything comes back, tokens included. Audited as `user.unsuspend`.", access: "site-admin", status: 204 }, | |
| 322 | + "PUT /repos/{owner}/{repo}/diffinery/comments/{id}/minimized": { | |
| 323 | + summary: "Hide (minimize) a comment", | |
| 324 | + description: "Diffinery only (GitHub's GraphQL `minimizeComment`). Write access, people only. The comment is shown collapsed with its reason. Audited as `comment.minimize`.", | |
| 325 | + access: "write", | |
| 326 | + body: { reason: ["string", "`spam`, `abuse`, `off_topic`, `outdated`, `duplicate` or `resolved` (or GitHub's `classifier`, such as `OFF_TOPIC`).", true] }, | |
| 327 | + }, | |
| 328 | + "DELETE /repos/{owner}/{repo}/diffinery/comments/{id}/minimized": { summary: "Show a hidden comment again", description: "Diffinery only. Write access, people only. Audited as `comment.unminimize`.", access: "write" }, | |
| 329 | + | |
| 258 | 330 | // ------------------------------------------------------------ search |
| 259 | 331 | "GET /search/issues": { |
| 260 | 332 | summary: "Search issues and pull requests", |
modifiedapps/diffinery/src/http/api.ts+13−9View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { serverErrors } from "../lib/server-errors.js"; |
| 9 | 9 | import { changePause, changeSuspension, cleanReason, computePause, killSwitches, suspensionOf, type Stopped } from "../lib/killswitch.js"; |
| 10 | 10 | import { publicJwk } from "../lib/jwt.js"; |
| 11 | +import { visibleTo } from "../lib/moderation.js"; | |
| 11 | 12 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 12 | 13 | import { atLeast, ROLE_LEVEL, type ProtectionRules, type Role } from "../repo/types.js"; |
| 13 | 14 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| @@ -16,7 +17,7 @@ | ||
| 16 | 17 | import { imports } from "./imports.js"; |
| 17 | 18 | import { schedulerStub } from "../lib/schedules.js"; |
| 18 | 19 | import { agenticApi } from "../agentic/api.js"; |
| 19 | -import { auditRepo, loadRepo, refuseAgent, repoStub } from "./common.js"; | |
| 20 | +import { auditRepo, hiddenAuthors, loadRepo, refuseAgent, repoStub } from "./common.js"; | |
| 20 | 21 | import { repoCreated } from "../mail/notify.js"; |
| 21 | 22 | import { actionsRest } from "./actions-runtime.js"; |
| 22 | 23 | import { compat } from "./compat.js"; |
| @@ -88,10 +89,12 @@ | ||
| 88 | 89 | return c.json({ ...userJson(c, a), email: a.email, site_admin: !!a.site_admin }); |
| 89 | 90 | }); |
| 90 | 91 | api.get("/users/:login", async (c) => { |
| 91 | - requireAccount(c); | |
| 92 | + const me = requireAccount(c); | |
| 92 | 93 | const a = await c.get("dir").accountByLogin(c.req.param("login")); |
| 93 | - if (!a || a.deleted_at) throw new HttpError(404, "not found"); | |
| 94 | - return c.json(userJson(c, a)); | |
| 94 | + // A suspended person's profile is hidden from everyone but site administrators (lib/moderation.ts). | |
| 95 | + if (!a || a.deleted_at || !visibleTo(a, me)) throw new HttpError(404, "not found"); | |
| 96 | + // Site administrators also see a suspension, as in GitHub Enterprise. | |
| 97 | + return c.json({ ...userJson(c, a), ...(me.site_admin && a.kind === "user" ? { suspended_at: isoSeconds(a.suspended_at ?? null), suspension_reason: a.suspended_reason ?? null } : {}) }); | |
| 95 | 98 | }); |
| 96 | 99 | // Quotas and what an account uses of them (lib/quotas.ts): your own, an organization's for its owners |
| 97 | 100 | // and admins, and any account's for site administrators, who can also change them. |
| @@ -376,7 +379,7 @@ | ||
| 376 | 379 | api.get("/users/:login/repos", async (c) => { |
| 377 | 380 | const a = requireAccount(c); |
| 378 | 381 | const owner = await c.get("dir").accountByLogin(c.req.param("login")); |
| 379 | - if (!owner) throw new HttpError(404, "not found"); | |
| 382 | + if (!owner || !visibleTo(owner, a)) throw new HttpError(404, "not found"); | |
| 380 | 383 | return c.json(paginate(c, tokenRepos(c, await c.get("dir").reposVisibleTo(a, owner.id)).map((r) => repoJson(c, r)))); |
| 381 | 384 | }); |
| 382 | 385 | api.get("/orgs/:org/repos", async (c) => { |
| @@ -794,6 +797,7 @@ | ||
| 794 | 797 | q: q("q"), |
| 795 | 798 | limit: p.perPage, |
| 796 | 799 | offset: p.offset, |
| 800 | + hide: await hiddenAuthors(c), | |
| 797 | 801 | }); |
| 798 | 802 | c.header("x-total-count", String(res.total)); |
| 799 | 803 | setLinks(c, p, res.total); |
| @@ -809,7 +813,7 @@ | ||
| 809 | 813 | }); |
| 810 | 814 | api.get("/repos/:owner/:repo/issues/:number", async (c) => { |
| 811 | 815 | const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 812 | - return c.json((await issuesOut(c, repo, stub, [(await stub.issue(num(c))).issue]))[0]); | |
| 816 | + return c.json((await issuesOut(c, repo, stub, [(await stub.issue(num(c), await hiddenAuthors(c))).issue]))[0]); | |
| 813 | 817 | }); |
| 814 | 818 | api.patch("/repos/:owner/:repo/issues/:number", async (c) => { |
| 815 | 819 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| @@ -829,7 +833,7 @@ | ||
| 829 | 833 | api.get("/repos/:owner/:repo/issues/:number/comments", async (c) => { |
| 830 | 834 | const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 831 | 835 | const n = num(c); |
| 832 | - const { issue, comments } = await stub.issue(n); | |
| 836 | + const { issue, comments } = await stub.issue(n, await hiddenAuthors(c)); | |
| 833 | 837 | const since = c.req.query("since"); |
| 834 | 838 | const list = paginate(c, comments.filter((x) => !x.reviewId && (!since || x.updatedAt >= since))); |
| 835 | 839 | const x = await issueCtx(c, repo, stub, [], list.map((cm) => `comment:${cm.id}`)); |
| @@ -960,7 +964,7 @@ | ||
| 960 | 964 | const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 961 | 965 | const n = num(c); |
| 962 | 966 | const x = await issueCtx(c, repo, stub, []); |
| 963 | - return c.json(paginate(c, (await stub.issue(n)).reviews.map((r) => ghReview(x, n, r)))); | |
| 967 | + return c.json(paginate(c, (await stub.issue(n, await hiddenAuthors(c))).reviews.map((r) => ghReview(x, n, r)))); | |
| 964 | 968 | }); |
| 965 | 969 | api.post("/repos/:owner/:repo/pulls/:number/reviews", async (c) => { |
| 966 | 970 | const { stub, caller, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| @@ -975,7 +979,7 @@ | ||
| 975 | 979 | api.get("/repos/:owner/:repo/pulls/:number/comments", async (c) => { |
| 976 | 980 | const { stub, repo } = await loadRepo(c, P(c).owner, P(c).repo); |
| 977 | 981 | const n = num(c); |
| 978 | - const list = paginate(c, (await stub.issue(n)).comments.filter((x) => x.reviewId)); | |
| 982 | + const list = paginate(c, (await stub.issue(n, await hiddenAuthors(c))).comments.filter((x) => x.reviewId)); | |
| 979 | 983 | const x = await issueCtx(c, repo, stub, [], list.map((cm) => `review_comment:${cm.id}`)); |
| 980 | 984 | return c.json(list.map((cm) => ghReviewComment(x, n, cm))); |
| 981 | 985 | }); |
modifiedapps/diffinery/src/http/common.ts+16−0View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { GITHUB_DOCS } from "./gh.js"; |
| 8 | 8 | import { callerFor, requireScope, type AppContext } from "../auth/principal.js"; |
| 9 | 9 | import { atLeast, type Caller, type Role } from "../repo/types.js"; |
| 10 | +import { suspendedIds } from "../lib/moderation.js"; | |
| 10 | 11 | |
| 11 | 12 | export const repoStub = (env: Env, repoId: string) => env.REPO.get(env.REPO.idFromName(repoId)); |
| 12 | 13 | |
| @@ -80,6 +81,21 @@ | ||
| 80 | 81 | return { repo, caller, stub, moved }; |
| 81 | 82 | } |
| 82 | 83 | |
| 84 | +/** | |
| 85 | + * Suspended people (lib/moderation.ts): the issues, pull requests, reviews and comments they wrote are | |
| 86 | + * left out for everyone but site administrators. Looked up once per request. | |
| 87 | + */ | |
| 88 | +export function hiddenAuthors(c: AppContext): Promise<string[]> { | |
| 89 | + const p = c.get("principal"); | |
| 90 | + if (p.type === "account" && p.account.site_admin) return Promise.resolve([]); | |
| 91 | + let v = c.get("hidden"); | |
| 92 | + if (!v) { | |
| 93 | + v = suspendedIds(c.env); | |
| 94 | + c.set("hidden", v); | |
| 95 | + } | |
| 96 | + return v; | |
| 97 | +} | |
| 98 | + | |
| 83 | 99 | /** Record a repository settings change made through the API, like the web settings pages do. */ |
| 84 | 100 | export function auditRepo(c: AppContext, caller: Caller, repo: Pick<Repo, "owner_login" | "name">, action: string, detail: Record<string, unknown> = {}): void { |
| 85 | 101 | c.executionCtx.waitUntil( |
modifiedapps/diffinery/src/http/compat.ts+2−0View file
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | import { gitdata } from "./gitdata.js"; |
| 6 | 6 | import { issues } from "./issues.js"; |
| 7 | 7 | import { misc } from "./misc.js"; |
| 8 | +import { moderation } from "./moderation.js"; | |
| 8 | 9 | import { releases } from "./releases.js"; |
| 9 | 10 | import { search } from "./search.js"; |
| 10 | 11 | import { statuses } from "./statuses.js"; |
| @@ -38,3 +39,4 @@ | ||
| 38 | 39 | compat.route("/", deployments); |
| 39 | 40 | compat.route("/", search); |
| 40 | 41 | compat.route("/", misc); |
| 42 | +compat.route("/", moderation); |
modifiedapps/diffinery/src/http/ghctx.ts+2−2View file
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import { HttpError } from "../lib/util.js"; |
| 9 | 9 | import { isPerson } from "../repo/auto-merge.js"; |
| 10 | 10 | import type { Caller, Role } from "../repo/types.js"; |
| 11 | -import { loadRepo, type repoStub } from "./common.js"; | |
| 11 | +import { hiddenAuthors, loadRepo, type repoStub } from "./common.js"; | |
| 12 | 12 | import { baseOf, ghIssue, ghPull, ghRepo, type IssueCtx, type IssueLike, type Who } from "./gh.js"; |
| 13 | 13 | |
| 14 | 14 | export type Stub = ReturnType<typeof repoStub>; |
| @@ -111,7 +111,7 @@ | ||
| 111 | 111 | |
| 112 | 112 | /** A single pull request with mergeability (GitHub fields plus Diffinery's merge_blockers, approvals and checks). */ |
| 113 | 113 | export async function pullDetail(c: AppContext, repo: Repo, stub: Stub, number: number, caller: Caller) { |
| 114 | - const { issue, comments } = await stub.issue(number); | |
| 114 | + const { issue, comments } = await stub.issue(number, await hiddenAuthors(c)); | |
| 115 | 115 | if (issue.kind !== "pull") throw new HttpError(404, "not a pull request"); |
| 116 | 116 | const [pull] = await pullsOut(c, repo, stub, [issue]); |
| 117 | 117 | // For agents and workflow tokens, also what Diffinery itself would need to land it for them (auto-merge, the merge queue). |
modifiedapps/diffinery/src/http/issues.ts+8−6View file
| @@ -6,6 +6,7 @@ | ||
| 6 | 6 | import type { IssueEvent, Reaction } from "../repo/social.js"; |
| 7 | 7 | import { baseOf, ghComment, ghIssue, ghLabel, ghReview, ghReviewComment, ghUser, isoSeconds, nodeId, pageOf, paginate, repoApi, setLinks, type IssueCtx } from "./gh.js"; |
| 8 | 8 | import { intParam, issueCtx, jsonBody, loadTarget, pullsOut, repoParams, type Stub } from "./ghctx.js"; |
| 9 | +import { hiddenAuthors } from "./common.js"; | |
| 9 | 10 | |
| 10 | 11 | /** |
| 11 | 12 | * Issue and pull request sub-resources bots and workflows use: labels and |
| @@ -119,14 +120,14 @@ | ||
| 119 | 120 | issues.get(`${R}/issues/comments`, async (c) => { |
| 120 | 121 | const { stub, repo } = await load(c); |
| 121 | 122 | const p = pageOf(c); |
| 122 | - const res = await stub.repoComments({ review: false, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset }); | |
| 123 | + const res = await stub.repoComments({ review: false, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset, hide: await hiddenAuthors(c) }); | |
| 123 | 124 | setLinks(c, p, res.total); |
| 124 | 125 | const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `comment:${i.comment.id}`)); |
| 125 | 126 | return c.json(res.items.map((i) => ghComment(x, i.number, i.isPull, i.comment))); |
| 126 | 127 | }); |
| 127 | 128 | issues.get(`${R}/issues/comments/:id`, async (c) => { |
| 128 | 129 | const { stub, repo } = await load(c); |
| 129 | - const found = await stub.comment(c.req.param("id")); | |
| 130 | + const found = await stub.comment(c.req.param("id"), false, await hiddenAuthors(c)); | |
| 130 | 131 | return c.json(ghComment(await issueCtx(c, repo, stub, [], [`comment:${found.comment.id}`]), found.number, found.isPull, found.comment)); |
| 131 | 132 | }); |
| 132 | 133 | issues.delete(`${R}/issues/comments/:id`, async (c) => { |
| @@ -140,14 +141,14 @@ | ||
| 140 | 141 | issues.get(`${R}/pulls/comments`, async (c) => { |
| 141 | 142 | const { stub, repo } = await load(c); |
| 142 | 143 | const p = pageOf(c); |
| 143 | - const res = await stub.repoComments({ review: true, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset }); | |
| 144 | + const res = await stub.repoComments({ review: true, since: c.req.query("since"), sort: c.req.query("sort"), direction: c.req.query("direction"), limit: p.perPage, offset: p.offset, hide: await hiddenAuthors(c) }); | |
| 144 | 145 | setLinks(c, p, res.total); |
| 145 | 146 | const x = await issueCtx(c, repo, stub, [], res.items.map((i) => `review_comment:${i.comment.id}`)); |
| 146 | 147 | return c.json(res.items.map((i) => ghReviewComment(x, i.number, i.comment))); |
| 147 | 148 | }); |
| 148 | 149 | issues.get(`${R}/pulls/comments/:id`, async (c) => { |
| 149 | 150 | const { stub, repo } = await load(c); |
| 150 | - const found = await stub.comment(c.req.param("id"), true); | |
| 151 | + const found = await stub.comment(c.req.param("id"), true, await hiddenAuthors(c)); | |
| 151 | 152 | return c.json(ghReviewComment(await issueCtx(c, repo, stub, [], [`review_comment:${found.comment.id}`]), found.number, found.comment)); |
| 152 | 153 | }); |
| 153 | 154 | issues.patch(`${R}/pulls/comments/:id`, async (c) => { |
| @@ -316,8 +317,9 @@ | ||
| 316 | 317 | issues.get(`${R}/issues/:number/timeline`, async (c) => { |
| 317 | 318 | const { stub, repo } = await load(c); |
| 318 | 319 | const n = intParam(c, "number"); |
| 319 | - const { issue } = await stub.issue(n); | |
| 320 | - const items = await stub.issueTimeline(n); | |
| 320 | + const hide = await hiddenAuthors(c); | |
| 321 | + const { issue } = await stub.issue(n, hide); | |
| 322 | + const items = await stub.issueTimeline(n, hide); | |
| 321 | 323 | const x = await issueCtx(c, repo, stub, [], items.flatMap((i) => (i.kind === "comment" ? [`comment:${i.comment.id}`] : []))); |
| 322 | 324 | const isPull = issue.kind === "pull"; |
| 323 | 325 | return c.json( |
addedapps/diffinery/src/http/moderation.ts+275−0View file
| @@ -1,0 +1,275 @@ | ||
| 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 | +}); |
modifiedapps/diffinery/src/http/search.ts+2−2View file
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | import { matchesText, parseIssueQuery, parseRepoQuery } from "../lib/search-query.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | 7 | import type { Issue } from "../repo/collab.js"; |
| 8 | -import { repoStub } from "./common.js"; | |
| 8 | +import { hiddenAuthors, repoStub } from "./common.js"; | |
| 9 | 9 | import { baseOf, ghIssue, ghRepo, pageOf, setLinks, topicsOf } from "./gh.js"; |
| 10 | 10 | import { issueCtx } from "./ghctx.js"; |
| 11 | 11 | |
| @@ -94,7 +94,7 @@ | ||
| 94 | 94 | const shaped = new Map<string, ReturnType<typeof ghIssue>>(); |
| 95 | 95 | for (const { repo, numbers } of byRepo.values()) { |
| 96 | 96 | const stub = repoStub(c.env, repo.id); |
| 97 | - const issues = (await stub.issuesByNumber(numbers)) as unknown as Issue[]; | |
| 97 | + const issues = (await stub.issuesByNumber(numbers, await hiddenAuthors(c))) as unknown as Issue[]; | |
| 98 | 98 | const x = await issueCtx(c, repo, stub, issues); |
| 99 | 99 | for (const i of issues) shaped.set(`${repo.id}#${i.number}`, ghIssue(x, i)); |
| 100 | 100 | } |
modifiedapps/diffinery/src/lib/directory.ts+29−5View file
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | import { archivePrefix, deletePrefixes } from "./storage-keys.js"; |
| 8 | 8 | import { inviteProof } from "../auth/invite-proof.js"; |
| 9 | 9 | import { checkRepositoryQuota } from "./quotas.js"; |
| 10 | +import { blockSubjects, isBlocked } from "./moderation.js"; | |
| 10 | 11 | |
| 11 | 12 | export interface Account { |
| 12 | 13 | id: string; |
| @@ -24,6 +25,10 @@ | ||
| 24 | 25 | appearance: string | null; |
| 25 | 26 | /** Organizations: the role every member gets on its repositories ("none", "read", "write" or "admin"). */ |
| 26 | 27 | base_role: string | null; |
| 28 | + /** Set while a site administrator suspends the person (lib/moderation.ts); status is then `disabled`. */ | |
| 29 | + suspended_at?: string | null; | |
| 30 | + suspended_by?: string | null; | |
| 31 | + suspended_reason?: string | null; | |
| 27 | 32 | created_at: string; |
| 28 | 33 | /** When the account was deleted (accounts/lifecycle.ts); the row stays under a placeholder login. */ |
| 29 | 34 | deleted_at?: string | null; |
| @@ -46,6 +51,8 @@ | ||
| 46 | 51 | topics?: string; |
| 47 | 52 | /** Project website (GitHub's `homepage`). */ |
| 48 | 53 | homepage?: string; |
| 54 | + /** Set while the owner is suspended (lib/moderation.ts): the repository is hidden from everyone but site administrators. */ | |
| 55 | + owner_suspended_at?: string | null; | |
| 49 | 56 | } |
| 50 | 57 | |
| 51 | 58 | export type TokenScope = "read" | "write" | "admin"; |
| @@ -303,6 +310,7 @@ | ||
| 303 | 310 | if (user.kind !== "user") throw new HttpError(422, `${user.login} is not a person; agents belong to an organization through Settings → Agents`); |
| 304 | 311 | if (user.status === "disabled") throw new HttpError(422, `${user.login}'s account is disabled`); |
| 305 | 312 | if (await this.orgRole(org.id, user.id)) throw new HttpError(422, `${user.login} is already a member of ${org.login}`); |
| 313 | + await this.refuseBlocked(org, user, by); | |
| 306 | 314 | if (user.id === by.id) { |
| 307 | 315 | await this.setOrgMember(org.id, user.id, role, by); |
| 308 | 316 | return { added: user }; |
| @@ -336,6 +344,7 @@ | ||
| 336 | 344 | async grantRepoAccess(repo: Repo, by: Account, account: Account, role: Role): Promise<{ added: Account } | { invitedUser: Account; id: string; renewed: boolean }> { |
| 337 | 345 | if (account.kind === "org") throw new HttpError(422, `${account.login} is an organization; add its people instead`); |
| 338 | 346 | if (account.status === "disabled") throw new HttpError(422, `${account.login}'s account is disabled`); |
| 347 | + await this.refuseBlocked({ id: repo.owner_id, login: repo.owner_login }, account, by); | |
| 339 | 348 | const direct = |
| 340 | 349 | account.kind === "agent" || |
| 341 | 350 | account.id === by.id || |
| @@ -356,6 +365,16 @@ | ||
| 356 | 365 | return (r.meta?.changes ?? 0) > 0; |
| 357 | 366 | } |
| 358 | 367 | |
| 368 | + /** | |
| 369 | + * Blocks keep people apart, as on GitHub: an owner (a person or an organization) can't invite or add | |
| 370 | + * someone it blocked, and nobody can invite or add a person who blocked them. | |
| 371 | + */ | |
| 372 | + private async refuseBlocked(owner: Pick<Account, "id" | "login">, user: Account, by: Account): Promise<void> { | |
| 373 | + // An agent is blocked along with its owner. | |
| 374 | + if (await isBlocked(this.env, owner.id, blockSubjects(user))) throw new HttpError(422, `${owner.login} blocked ${user.kind === "agent" ? `${user.login}'s owner` : user.login}; unblock them first`); | |
| 375 | + if (await isBlocked(this.env, user.id, blockSubjects(by))) throw new HttpError(422, `you can't invite ${user.login}`); | |
| 376 | + } | |
| 377 | + | |
| 359 | 378 | private async writeOrgMember(orgId: string, userId: string, role: "owner" | "admin" | "member" | null): Promise<void> { |
| 360 | 379 | // Removing or demoting the last owner would leave nobody able to manage the organization. |
| 361 | 380 | if (role !== "owner" && (await this.orgRole(orgId, userId)) === "owner") { |
| @@ -382,7 +401,8 @@ | ||
| 382 | 401 | |
| 383 | 402 | async setStatus(id: string, status: Account["status"]): Promise<void> { |
| 384 | 403 | const before = await this.account(id); |
| 385 | - await this.db.prepare("UPDATE accounts SET status = ? WHERE id = ?").bind(status, id).run(); | |
| 404 | + // Any status but disabled also lifts a suspension (lib/moderation.ts). | |
| 405 | + await this.db.prepare(`UPDATE accounts SET status = ?${status === "disabled" ? "" : ", suspended_at = NULL, suspended_by = NULL, suspended_reason = NULL"} WHERE id = ?`).bind(status, id).run(); | |
| 386 | 406 | if (status === "disabled") await this.db.prepare("UPDATE tokens SET revoked_at = ? WHERE account_id = ? AND revoked_at IS NULL").bind(now(), id).run(); |
| 387 | 407 | // A disabled account's browser sessions end too. |
| 388 | 408 | if (status === "disabled") await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(id).run(); |
| @@ -451,6 +471,8 @@ | ||
| 451 | 471 | if (!row || row.revoked_at || row.expires_at! < now()) return null; |
| 452 | 472 | const account = await this.account(row.account_id!); |
| 453 | 473 | if (!account || account.status !== "active") return null; |
| 474 | + // An agent's tokens stop working while its owner is suspended (lib/moderation.ts). | |
| 475 | + if (account.kind === "agent" && account.owner_id && (await this.account(account.owner_id))?.suspended_at) return null; | |
| 454 | 476 | if (!row.last_used_at || Date.now() - Date.parse(row.last_used_at) > 3600_000) { |
| 455 | 477 | await this.db.prepare("UPDATE tokens SET last_used_at = ? WHERE id = ?").bind(now(), row.id!).run(); |
| 456 | 478 | } |
| @@ -653,13 +675,13 @@ | ||
| 653 | 675 | |
| 654 | 676 | async repo(owner: string, name: string): Promise<Repo | null> { |
| 655 | 677 | return this.db |
| 656 | - .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?") | |
| 678 | + .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind, a.suspended_at AS owner_suspended_at FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE a.login = ? AND r.name = ?") | |
| 657 | 679 | .bind(owner, name.replace(/\.git$/, "")) |
| 658 | 680 | .first<Repo>(); |
| 659 | 681 | } |
| 660 | 682 | |
| 661 | 683 | async repoById(id: string): Promise<Repo | null> { |
| 662 | - return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>(); | |
| 684 | + return this.db.prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind, a.suspended_at AS owner_suspended_at FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.id = ?").bind(id).first<Repo>(); | |
| 663 | 685 | } |
| 664 | 686 | |
| 665 | 687 | /** Repositories an account can see. */ |
| @@ -670,11 +692,13 @@ | ||
| 670 | 692 | if (!owner || owner.status !== "active") return []; |
| 671 | 693 | } |
| 672 | 694 | const params: unknown[] = []; |
| 673 | - let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id"; | |
| 695 | + let q = "SELECT r.*, a.login AS owner_login, a.kind AS owner_kind, a.suspended_at AS owner_suspended_at FROM repos r JOIN accounts a ON a.id = r.owner_id"; | |
| 674 | 696 | const subjectId = account.kind === "agent" ? account.owner_id! : account.id; |
| 675 | 697 | if (!account.site_admin) { |
| 676 | 698 | q += ` WHERE (r.visibility = 'public' OR r.owner_id = ? OR r.owner_id IN (SELECT m.org_id FROM org_members m JOIN accounts o ON o.id = m.org_id WHERE m.user_id = ? AND (m.role != 'member' OR COALESCE(o.base_role, 'read') != 'none')) OR r.id IN (SELECT repo_id FROM repo_access WHERE account_id IN (?, ?)))`; |
| 677 | 699 | params.push(subjectId, subjectId, account.id, subjectId); |
| 700 | + // A suspended person's repositories are hidden from everyone but site administrators. | |
| 701 | + q += " AND a.suspended_at IS NULL"; | |
| 678 | 702 | } else q += " WHERE 1 = 1"; |
| 679 | 703 | if (ownerId) { |
| 680 | 704 | q += " AND r.owner_id = ?"; |
| @@ -688,7 +712,7 @@ | ||
| 688 | 712 | async publicRepos(limit: number): Promise<Repo[]> { |
| 689 | 713 | return ( |
| 690 | 714 | await this.db |
| 691 | - .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.visibility = 'public' ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ?") | |
| 715 | + .prepare("SELECT r.*, a.login AS owner_login, a.kind AS owner_kind, a.suspended_at AS owner_suspended_at FROM repos r JOIN accounts a ON a.id = r.owner_id WHERE r.visibility = 'public' AND a.suspended_at IS NULL ORDER BY COALESCE(r.pushed_at, r.updated_at) DESC LIMIT ?") | |
| 692 | 716 | .bind(limit) |
| 693 | 717 | .all<Repo>() |
| 694 | 718 | ).results; |
modifiedapps/diffinery/src/lib/killswitch.ts+5−2View file
| @@ -15,7 +15,8 @@ | ||
| 15 | 15 | * self-hosted) fail instead of starting, and no sandbox opens. While the instance is paused hosted |
| 16 | 16 | * jobs fail instead of starting and no sandbox opens. Suspending or pausing also stops what is |
| 17 | 17 | * running then (RepoDO.stopActions); lifting one only allows new work again. Nobody is exempt, |
| 18 | - * not even site administrators' own accounts. | |
| 18 | + * not even site administrators' own accounts. A suspended person (lib/moderation.ts) counts as an | |
| 19 | + * account whose Actions are suspended, with the suspension's reason. | |
| 19 | 20 | */ |
| 20 | 21 | |
| 21 | 22 | export const PAUSE_FLAG = "hosted_compute_paused"; |
| @@ -45,6 +46,7 @@ | ||
| 45 | 46 | const r = await env.DB.prepare( |
| 46 | 47 | `SELECT r.actions_suspended_at AS r_at, r.actions_suspended_by AS r_by, r.actions_suspended_reason AS r_reason, |
| 47 | 48 | a.login AS login, a.actions_suspended_at AS a_at, a.actions_suspended_by AS a_by, a.actions_suspended_reason AS a_reason, |
| 49 | + a.suspended_at AS s_at, a.suspended_by AS s_by, a.suspended_reason AS s_reason, | |
| 48 | 50 | f.value AS p_reason, f.updated_at AS p_at, f.updated_by AS p_by |
| 49 | 51 | FROM repos r JOIN accounts a ON a.id = r.owner_id LEFT JOIN site_flags f ON f.key = ? |
| 50 | 52 | WHERE r.id = ?`, |
| @@ -54,7 +56,8 @@ | ||
| 54 | 56 | if (!r) return { repo: null, account: null, paused: await computePause(env) }; |
| 55 | 57 | return { |
| 56 | 58 | repo: r.r_at ? { at: r.r_at, by: r.r_by ?? null, reason: r.r_reason ?? null } : null, |
| 57 | - account: r.a_at ? { at: r.a_at, by: r.a_by ?? null, reason: r.a_reason ?? null, login: r.login! } : null, | |
| 59 | + // A suspended owner (lib/moderation.ts) counts as an account whose Actions are suspended. | |
| 60 | + account: r.a_at ? { at: r.a_at, by: r.a_by ?? null, reason: r.a_reason ?? null, login: r.login! } : r.s_at ? { at: r.s_at, by: r.s_by ?? null, reason: r.s_reason ?? null, login: r.login! } : null, | |
| 58 | 61 | paused: r.p_at ? { at: r.p_at, by: r.p_by ?? null, reason: r.p_reason || null } : null, |
| 59 | 62 | }; |
| 60 | 63 | } |
addedapps/diffinery/src/lib/moderation.ts+297−0View file
| @@ -1,0 +1,297 @@ | ||
| 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."; |
modifiedapps/diffinery/src/lib/rate-limit.ts+5−1View file
| @@ -12,7 +12,8 @@ | ||
| 12 | 12 | * - RL_ANON: everything else from someone who isn't signed in, per IP address. |
| 13 | 13 | * - RL_ACCOUNT: everything from a signed-in person, agent or personal token, per account. |
| 14 | 14 | * - RL_CREATE: creating things other people see or that cost storage (repositories, |
| 15 | - * organizations, issues, pull requests, comments, reviews, releases), per account. | |
| 15 | + * organizations, issues, pull requests, comments, reviews, releases), and reports to the site | |
| 16 | + * administrators, per account. | |
| 16 | 17 | * |
| 17 | 18 | * Job, sandbox, runner and import credentials aren't counted here: their work is bounded by |
| 18 | 19 | * the job, sandbox and import limits. |
| @@ -41,6 +42,9 @@ | ||
| 41 | 42 | ["POST", /^\/settings\/orgs$/], |
| 42 | 43 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues|pulls|releases)$/], |
| 43 | 44 | ["POST", /^\/[^/]+\/[^/]+\/(?:issues\/\d+\/comment|pull\/\d+\/(?:review|line-comment))$/], |
| 45 | + // Reports to the site administrators (lib/moderation.ts also caps them per day). | |
| 46 | + ["POST", /^\/api\/v3\/diffinery\/reports$/], | |
| 47 | + ["POST", /^\/-\/report$/], | |
| 44 | 48 | ]; |
| 45 | 49 | |
| 46 | 50 | /** Which buckets a request counts against, with their keys. */ |
modifiedapps/diffinery/src/mail/notify.ts+6−1View file
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * - Ignoring a repository or muting a thread stops them (a direct @mention, |
| 9 | 9 | * assignment or review request in the same event still gets through a mute). |
| 10 | 10 | * - Your own actions never notify you, unless you turn on "Include your own updates" for email. |
| 11 | + * - People you blocked never notify you, @mentions included (lib/moderation.ts). | |
| 11 | 12 | * - Workflow runs notify whoever triggered them (an agent's owner for agents; |
| 12 | 13 | * watchers for scheduled runs), for failures unless they ask for every run, |
| 13 | 14 | * and not while they ignore the repository. |
| @@ -20,6 +21,7 @@ | ||
| 20 | 21 | import { repoStub } from "../http/common.js"; |
| 21 | 22 | import type { Account, Directory, Repo } from "../lib/directory.js"; |
| 22 | 23 | import { sha256 } from "../lib/util.js"; |
| 24 | +import { blockersOf } from "../lib/moderation.js"; | |
| 23 | 25 | import { MailStore, type NotificationPrefs } from "./prefs.js"; |
| 24 | 26 | import { enqueueMail } from "./send.js"; |
| 25 | 27 | import type { JobInfo, RunInfo } from "../repo/actions.js"; |
| @@ -144,7 +146,10 @@ | ||
| 144 | 146 | if (!participantsOnly) for (const id of watch.all) r.add(id, "subscribed"); |
| 145 | 147 | const direct = new Set([...(ev.type === "pull_request" && ev.action === "review_requested" ? d.reviewers ?? [] : []), ...(ev.action === "assigned" ? d.assignees ?? [] : []), ...mentions(text)].map((x) => x.toLowerCase())); |
| 146 | 148 | |
| 147 | - const candidates = [...r.map.keys()].filter((id) => !watch.ignore.has(id)); | |
| 149 | + // Whoever blocked the actor (or the person an agent acts for) hears nothing from them. | |
| 150 | + const actorIds = [ev.actor.id, ev.actor.kind === "agent" && ev.actor.onBehalfOf ? (await dir.accountByLogin(ev.actor.onBehalfOf))?.id : null]; | |
| 151 | + const blockers = await blockersOf(env, actorIds); | |
| 152 | + const candidates = [...r.map.keys()].filter((id) => !watch.ignore.has(id) && !blockers.has(id)); | |
| 148 | 153 | const accounts = await readable(dir, repo, candidates); |
| 149 | 154 | const prefs = await store.prefsFor(accounts.map((a) => a.id)); |
| 150 | 155 | const web = new Map<Reason, string[]>(); |
modifiedapps/diffinery/src/mail/samples.ts+2−0View file
| @@ -76,6 +76,8 @@ | ||
| 76 | 76 | item("site-admin-granted", "Made a site administrator", { kind: "site_admin", reason: "security_alert", data: { granted: true, by: "admin" } }), |
| 77 | 77 | item("site-admin-revoked", "No longer a site administrator", { kind: "site_admin", reason: "security_alert", data: { granted: false, by: "admin" } }), |
| 78 | 78 | item("export-ready", "Account export ready", { kind: "export_ready", reason: "account", data: { url: `${pub}/settings/account`, size: 48_300_000, expiresAt: exp } }), |
| 79 | + item("account-suspended", "Account suspended", { kind: "account_status", reason: "account", data: { status: "suspended", reason: "Spam in issues across several repositories" } }), | |
| 80 | + item("account-unsuspended", "Account unsuspended", { kind: "account_status", reason: "account", data: { status: "unsuspended" } }), | |
| 79 | 81 | item("invite-org", "Invitation (organization)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, org: repo.split("/")[0], role: "member", expiresAt: exp } }), |
| 80 | 82 | item("invite-repo", "Invitation (repository)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/invite/sample`, repo, role: "write", expiresAt: exp } }), |
| 81 | 83 | item("invite-account", "Invitation (by username)", { kind: "invite", reason: "invitation", data: { inviter: me, url: `${pub}/settings/invitations`, repo, role: "write", expiresAt: exp, existing: true } }), |
modifiedapps/diffinery/src/mail/templates.ts+21−0View file
| @@ -545,6 +545,27 @@ | ||
| 545 | 545 | }; |
| 546 | 546 | } |
| 547 | 547 | case "account_status": |
| 548 | + if (job.data.status === "suspended") | |
| 549 | + return { | |
| 550 | + subject: "[Diffinery] Your account was suspended", | |
| 551 | + preheader: "You can't sign in, and your tokens, agents and runners don't work while it lasts.", | |
| 552 | + eyebrow: { text: "Account", tone: "red" }, | |
| 553 | + title: ["Your Diffinery account was suspended"], | |
| 554 | + blocks: [ | |
| 555 | + { p: ["A site administrator suspended your account. While it is suspended you can't sign in, your access tokens, agents and runners don't work, and your repositories, profile and comments are hidden from other people."] }, | |
| 556 | + ...(job.data.reason ? [{ kv: [["Reason", job.data.reason]] as Array<[string, string]> }] : []), | |
| 557 | + { p: ["If you think this is a mistake, contact an administrator of this Diffinery instance."], muted: true }, | |
| 558 | + ], | |
| 559 | + }; | |
| 560 | + if (job.data.status === "unsuspended") | |
| 561 | + return { | |
| 562 | + subject: "[Diffinery] Your account is no longer suspended", | |
| 563 | + preheader: "You can sign in again; your tokens work again.", | |
| 564 | + eyebrow: { text: "Account", tone: "green" }, | |
| 565 | + title: ["Your Diffinery account is no longer suspended"], | |
| 566 | + blocks: [{ p: ["A site administrator lifted the suspension of your account. Sign in with your passkey; your access tokens, agents and runners work again, and your repositories and comments are visible again."] }, { button: { label: "Sign in", url: `${pub}/login` } }], | |
| 567 | + url: `${pub}/login`, | |
| 568 | + }; | |
| 548 | 569 | return job.data.status === "disabled" |
| 549 | 570 | ? { |
| 550 | 571 | subject: "[Diffinery] Your account was disabled", |
modifiedapps/diffinery/src/mail/types.ts+1−1View file
| @@ -82,7 +82,7 @@ | ||
| 82 | 82 | | { kind: "deployment_review_requested"; data: { repo: string; environment: string; workflow: string; runNumber: number; title: string; branch: string; sha: string; requestor: string; url: string } } |
| 83 | 83 | | { kind: "push_bypassed"; data: { repo: string; pusher: string; kinds: string[]; reason: string; refs?: string[] } } |
| 84 | 84 | | { kind: "push_blocked"; data: { repo: string; kinds: string[]; refs?: string[]; paths?: string[] } } |
| 85 | - | { kind: "account_status"; data: { status: "disabled" | "active" } } | |
| 85 | + | { kind: "account_status"; data: { status: "disabled" | "active" | "suspended" | "unsuspended"; reason?: string } } | |
| 86 | 86 | | { kind: "account_deleted"; data: { login: string; by?: string; cooldownDays: number } } |
| 87 | 87 | | { kind: "site_admin"; data: { granted: boolean; by: string } } |
| 88 | 88 | | { kind: "export_ready"; data: { url: string; size: number; expiresAt: string } } |
modifiedapps/diffinery/src/mcp/server.ts+5−5View file
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | import { HttpError, b64, fromB64 } from "../lib/util.js"; |
| 6 | 6 | import type { Role } from "../repo/types.js"; |
| 7 | 7 | import { baseRole, type TokenScope } from "../lib/directory.js"; |
| 8 | -import { loadRepo } from "../http/common.js"; | |
| 8 | +import { hiddenAuthors, loadRepo } from "../http/common.js"; | |
| 9 | 9 | import { RELEASE_TOOLS } from "./releases.js"; |
| 10 | 10 | import { DOCS_TOOLS } from "./docs.js"; |
| 11 | 11 | import { AGENTIC_TOOLS } from "../agentic/mcp.js"; |
| @@ -175,7 +175,7 @@ | ||
| 175 | 175 | readOnly: true, |
| 176 | 176 | run: async (c, a) => { |
| 177 | 177 | const { stub } = await repoOf(c, a); |
| 178 | - const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100 }); | |
| 178 | + const { items, total } = await stub.issues({ kind: "pull", state: (optStr(a, "state") as "open") ?? "open", limit: 100, hide: await hiddenAuthors(c) }); | |
| 179 | 179 | return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, head: i.pull?.head, base: i.pull?.base, draft: i.pull?.draft, merged: !!i.pull?.mergedAt })) }; |
| 180 | 180 | }, |
| 181 | 181 | }, |
| @@ -189,7 +189,7 @@ | ||
| 189 | 189 | const { stub, caller } = await repoOf(c, a); |
| 190 | 190 | const n = int(a, "number"); |
| 191 | 191 | // An agent may not merge into a protected branch itself, but auto-merge may land it for the agent: say what each needs. |
| 192 | - const [detail, mergeability, automatic] = await Promise.all([stub.issue(n), stub.mergeability(n, caller).catch(() => null), isPerson(caller) ? null : stub.mergeability(n, caller, { automatic: true }).catch(() => null)]); | |
| 192 | + const [detail, mergeability, automatic] = await Promise.all([stub.issue(n, await hiddenAuthors(c)), stub.mergeability(n, caller).catch(() => null), isPerson(caller) ? null : stub.mergeability(n, caller, { automatic: true }).catch(() => null)]); | |
| 193 | 193 | const files = a.include_diff ? await stub.pullFiles(n) : undefined; |
| 194 | 194 | return { ...detail, mergeability, ...(automatic ? { auto_merge_blockers: automatic.blockers } : {}), files }; |
| 195 | 195 | }, |
| @@ -285,7 +285,7 @@ | ||
| 285 | 285 | readOnly: true, |
| 286 | 286 | run: async (c, a) => { |
| 287 | 287 | const { stub } = await repoOf(c, a); |
| 288 | - const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100 }); | |
| 288 | + const { items, total } = await stub.issues({ kind: "issue", state: (optStr(a, "state") as "open") ?? "open", q: optStr(a, "q"), label: optStr(a, "label"), limit: 100, hide: await hiddenAuthors(c) }); | |
| 289 | 289 | return { total, items: items.map((i) => ({ number: i.number, title: i.title, state: i.state, author: i.author, labels: i.labels })) }; |
| 290 | 290 | }, |
| 291 | 291 | }, |
| @@ -294,7 +294,7 @@ | ||
| 294 | 294 | input: { ...OWNER_REPO, number: { type: "integer" } }, |
| 295 | 295 | required: ["owner", "repo", "number"], |
| 296 | 296 | readOnly: true, |
| 297 | - run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number")), | |
| 297 | + run: async (c, a) => (await repoOf(c, a)).stub.issue(int(a, "number"), await hiddenAuthors(c)), | |
| 298 | 298 | }, |
| 299 | 299 | create_issue: { |
| 300 | 300 | description: "Open an issue.", |
modifiedapps/diffinery/src/registry/access.ts+3−0View file
| @@ -43,6 +43,9 @@ | ||
| 43 | 43 | const p = c.get("principal"); |
| 44 | 44 | const dir = c.get("dir"); |
| 45 | 45 | |
| 46 | + // A suspended person's packages are hidden like their repositories (lib/moderation.ts). | |
| 47 | + if (owner.suspended_at && !(p.type === "account" && p.account.site_admin)) return { role: "none", linkRepo: null, actor: null }; | |
| 48 | + | |
| 46 | 49 | if (p.type === "anonymous") return { role: pkg?.visibility === "public" ? "pull" : "none", linkRepo: null, actor: null }; |
| 47 | 50 | |
| 48 | 51 | if (p.type === "repo-token") { |
modifiedapps/diffinery/src/repo/RepoDO.ts+5−2View file
| @@ -1076,6 +1076,9 @@ | ||
| 1076 | 1076 | editComment(...a: Parameters<Collab["editComment"]>) { |
| 1077 | 1077 | return this.collab.editComment(...a); |
| 1078 | 1078 | } |
| 1079 | + minimizeComment(...a: Parameters<Collab["minimizeComment"]>) { | |
| 1080 | + return this.collab.minimizeComment(...a); | |
| 1081 | + } | |
| 1079 | 1082 | createPull(...a: Parameters<Collab["createPull"]>) { |
| 1080 | 1083 | return this.collab.createPull(...a); |
| 1081 | 1084 | } |
| @@ -1651,10 +1654,10 @@ | ||
| 1651 | 1654 | return searchIssues(this, query, limit); |
| 1652 | 1655 | } |
| 1653 | 1656 | /** The issues for one page of search hits, in the order given (missing numbers are skipped). */ |
| 1654 | - issuesByNumber(numbers: number[]) { | |
| 1657 | + issuesByNumber(numbers: number[], hide?: string[]) { | |
| 1655 | 1658 | return numbers.slice(0, 100).flatMap((n) => { |
| 1656 | 1659 | try { |
| 1657 | - return [this.collab.get(n).issue]; | |
| 1660 | + return [this.collab.get(n, hide).issue]; | |
| 1658 | 1661 | } catch { |
| 1659 | 1662 | return []; |
| 1660 | 1663 | } |
modifiedapps/diffinery/src/repo/collab.ts+34−2View file
| @@ -4,7 +4,7 @@ | ||
| 4 | 4 | import { HttpError, checkText, newId, now, randomToken } from "../lib/util.js"; |
| 5 | 5 | import type { RepoDO } from "./RepoDO.js"; |
| 6 | 6 | import { toInfo } from "./RepoDO.js"; |
| 7 | -import { atLeast, type Caller } from "./types.js"; | |
| 7 | +import { atLeast, refuseBlocked, type Caller } from "./types.js"; | |
| 8 | 8 | import { COMMENT_SELECT } from "./social.js"; |
| 9 | 9 | import { requiredCheck } from "./required.js"; |
| 10 | 10 | import { AutoMerge, automaticLandingRefusal, isPerson, landsByAgentRule, type AutoMergeInfo } from "./auto-merge.js"; |
| @@ -107,6 +107,8 @@ | ||
| 107 | 107 | seq?: number; |
| 108 | 108 | reviewSeq?: number | null; |
| 109 | 109 | inReplyToSeq?: number | null; |
| 110 | + /** Set while the comment is hidden (minimized) by someone with write access: why, by whom, when (lib/moderation.ts). */ | |
| 111 | + minimized?: { reason: string; by: string; at: string } | null; | |
| 110 | 112 | }; |
| 111 | 113 | |
| 112 | 114 | export type Review = { |
| @@ -247,10 +249,16 @@ | ||
| 247 | 249 | base?: string; |
| 248 | 250 | sort?: "created" | "updated" | "comments"; |
| 249 | 251 | direction?: "asc" | "desc"; |
| 252 | + /** Authors whose issues and pull requests are left out (suspended people, lib/moderation.ts). */ | |
| 253 | + hide?: string[]; | |
| 250 | 254 | } = {}, |
| 251 | 255 | ): { items: Issue[]; total: number } { |
| 252 | 256 | const where: string[] = []; |
| 253 | 257 | const args: unknown[] = []; |
| 258 | + if (opts.hide?.length) { | |
| 259 | + where.push("author_id NOT IN (SELECT value FROM json_each(?))"); | |
| 260 | + args.push(JSON.stringify(opts.hide)); | |
| 261 | + } | |
| 254 | 262 | if (opts.kind) { |
| 255 | 263 | where.push("kind = ?"); |
| 256 | 264 | args.push(opts.kind); |
| @@ -312,8 +320,10 @@ | ||
| 312 | 320 | return { items: rows.map((r) => this.toIssue(r)), total }; |
| 313 | 321 | } |
| 314 | 322 | |
| 315 | - get(number: number): { issue: Issue; comments: Comment[]; reviews: Review[] } { | |
| 323 | + /** An issue or pull request with its comments and reviews. `hide`: authors left out (suspended people, lib/moderation.ts); their own issue is a 404. */ | |
| 324 | + get(number: number, hide?: string[]): { issue: Issue; comments: Comment[]; reviews: Review[] } { | |
| 316 | 325 | const issue = this.toIssue(this.row(number)); |
| 326 | + if (hide?.includes(issue.author.id)) throw new HttpError(404, `#${number} not found`); | |
| 317 | 327 | const comments = this.sql |
| 318 | 328 | .exec<Record<string, SqlStorageValue>>(`SELECT ${COMMENT_SELECT} FROM comments c WHERE c.number = ? ORDER BY c.created_at`, number) |
| 319 | 329 | .toArray() |
| @@ -334,9 +344,11 @@ | ||
| 334 | 344 | seq: c.seq as number, |
| 335 | 345 | reviewSeq: (c.review_seq as number) ?? null, |
| 336 | 346 | inReplyToSeq: (c.reply_seq as number) ?? null, |
| 347 | + minimized: c.minimized_reason ? { reason: c.minimized_reason as string, by: (c.minimized_by as string) ?? "", at: (c.minimized_at as string) ?? "" } : null, | |
| 337 | 348 | }), |
| 338 | 349 | ); |
| 339 | 350 | const reviews = this.reviews(number); |
| 351 | + if (hide?.length) return { issue, comments: comments.filter((c) => !hide.includes(c.author.id)), reviews: reviews.filter((r) => !hide.includes(r.author.id)) }; | |
| 340 | 352 | return { issue, comments, reviews }; |
| 341 | 353 | } |
| 342 | 354 | |
| @@ -357,6 +369,7 @@ | ||
| 357 | 369 | } |
| 358 | 370 | |
| 359 | 371 | createIssue(input: { title: string; body?: string; labels?: string[]; assignees?: string[] }, caller: Caller): Issue { |
| 372 | + refuseBlocked(caller); | |
| 360 | 373 | if (!input.title?.trim()) throw new HttpError(422, "title is required"); |
| 361 | 374 | checkText("Issue", input); |
| 362 | 375 | // As on GitHub, labels and assignees from someone without triage access are silently dropped. |
| @@ -460,6 +473,7 @@ | ||
| 460 | 473 | } |
| 461 | 474 | |
| 462 | 475 | addComment(number: number, body: string, caller: Caller): Comment { |
| 476 | + refuseBlocked(caller); | |
| 463 | 477 | const r = this.row(number); |
| 464 | 478 | if (r.locked && !atLeast(caller.role, "triage")) throw new HttpError(403, "conversation is locked"); |
| 465 | 479 | if (!body?.trim()) throw new HttpError(422, "body is required"); |
| @@ -474,6 +488,22 @@ | ||
| 474 | 488 | return this.get(number).comments.find((c) => c.id === id)!; |
| 475 | 489 | } |
| 476 | 490 | |
| 491 | + /** | |
| 492 | + * Hide (minimize) a comment with one of GitHub's reasons, or (null) show it again. Needs write | |
| 493 | + * access, as on GitHub; agents can't. Works for issue and review comments, by either id form. | |
| 494 | + */ | |
| 495 | + minimizeComment(id: string, reason: string | null, caller: Caller): { number: number; reason: string | null } { | |
| 496 | + if (!atLeast(caller.role, "write")) throw new HttpError(403, "write access is required to hide comments"); | |
| 497 | + if (caller.kind !== "user") throw new HttpError(403, "only people can hide comments"); | |
| 498 | + id = this.repo.social.commentKey(id); | |
| 499 | + const c = this.sql.exec<{ number: number }>("SELECT number FROM comments WHERE id = ?", id).toArray()[0]; | |
| 500 | + if (!c) throw new HttpError(404, "comment not found"); | |
| 501 | + if (reason) this.sql.exec("UPDATE comments SET minimized_reason = ?, minimized_by = ?, minimized_at = ? WHERE id = ?", reason, caller.login, now(), id); | |
| 502 | + else this.sql.exec("UPDATE comments SET minimized_reason = NULL, minimized_by = NULL, minimized_at = NULL WHERE id = ?", id); | |
| 503 | + this.repo.broadcast(`pr:${c.number}`, { action: "comment", id }); | |
| 504 | + return { number: c.number, reason }; | |
| 505 | + } | |
| 506 | + | |
| 477 | 507 | editComment(id: string, body: string, caller: Caller): void { |
| 478 | 508 | checkText("IssueComment", { body }); |
| 479 | 509 | id = this.repo.social.commentKey(id); |
| @@ -495,6 +525,7 @@ | ||
| 495 | 525 | // ---------------------------------------------------------------- pull requests |
| 496 | 526 | |
| 497 | 527 | async createPull(input: { title: string; body?: string; head: string; base?: string; draft?: boolean; intent?: string; reviewers?: string[] }, caller: Caller): Promise<Issue> { |
| 528 | + refuseBlocked(caller); | |
| 498 | 529 | this.repo.policy.assertMayCreateOrApprove(caller, "create"); |
| 499 | 530 | checkText("PullRequest", input); |
| 500 | 531 | const base = input.base ?? this.repo.meta.defaultBranch; |
| @@ -633,6 +664,7 @@ | ||
| 633 | 664 | input: { event: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; body?: string; comments?: Array<{ path: string; line: number; side?: "LEFT" | "RIGHT"; body: string; inReplyTo?: string }> }, |
| 634 | 665 | caller: Caller, |
| 635 | 666 | ): Review { |
| 667 | + refuseBlocked(caller); | |
| 636 | 668 | checkText("PullRequestReview", input); |
| 637 | 669 | for (const c of input.comments ?? []) checkText("PullRequestReviewComment", c); |
| 638 | 670 | const r = this.row(number); |
modifiedapps/diffinery/src/repo/schema.ts+5−0View file
| @@ -233,6 +233,11 @@ | ||
| 233 | 233 | ["jobs", "carried_from", "TEXT"], |
| 234 | 234 | // Bytes an object takes as stored (compressed, in SQLite or R2); null in objects stored before it was kept. |
| 235 | 235 | ["objects", "stored", "INTEGER"], |
| 236 | + // A comment hidden (minimized) by someone with write access: GitHub's reason (spam, abuse, off_topic, outdated, | |
| 237 | + // duplicate, resolved), who hid it and when (lib/moderation.ts). Null while it is shown. | |
| 238 | + ["comments", "minimized_reason", "TEXT"], | |
| 239 | + ["comments", "minimized_by", "TEXT"], | |
| 240 | + ["comments", "minimized_at", "TEXT"], | |
| 236 | 241 | ]; |
| 237 | 242 | |
| 238 | 243 | export function migrateRepoSchema(sql: SqlStorage): void { |
modifiedapps/diffinery/src/repo/social.ts+16−5View file
| @@ -2,7 +2,7 @@ | ||
| 2 | 2 | import { HttpError, checkText, now } from "../lib/util.js"; |
| 3 | 3 | import type { Comment, IssueRow, Review } from "./collab.js"; |
| 4 | 4 | import type { RepoDO } from "./RepoDO.js"; |
| 5 | -import { atLeast, type Caller } from "./types.js"; | |
| 5 | +import { atLeast, refuseBlocked, type Caller } from "./types.js"; | |
| 6 | 6 | |
| 7 | 7 | export const REACTION_CONTENT = ["+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"]; |
| 8 | 8 | |
| @@ -35,6 +35,9 @@ | ||
| 35 | 35 | seq: number; |
| 36 | 36 | review_seq: number | null; |
| 37 | 37 | reply_seq: number | null; |
| 38 | + minimized_reason: string | null; | |
| 39 | + minimized_by: string | null; | |
| 40 | + minimized_at: string | null; | |
| 38 | 41 | }; |
| 39 | 42 | |
| 40 | 43 | /** |
| @@ -59,6 +62,7 @@ | ||
| 59 | 62 | seq: c.seq, |
| 60 | 63 | reviewSeq: c.review_seq, |
| 61 | 64 | inReplyToSeq: c.reply_seq, |
| 65 | + minimized: c.minimized_reason ? { reason: c.minimized_reason, by: c.minimized_by ?? "", at: c.minimized_at ?? "" } : null, | |
| 62 | 66 | }); |
| 63 | 67 | |
| 64 | 68 | const LABEL_COLOR = /^[0-9a-fA-F]{6}$/; |
| @@ -127,8 +131,8 @@ | ||
| 127 | 131 | } |
| 128 | 132 | |
| 129 | 133 | /** Comments, reviews and events of an issue in time order (GitHub's timeline, basic). */ |
| 130 | - timeline(number: number): TimelineItem[] { | |
| 131 | - const { comments, reviews } = this.repo.collab.get(number); | |
| 134 | + timeline(number: number, hide?: string[]): TimelineItem[] { | |
| 135 | + const { comments, reviews } = this.repo.collab.get(number, hide); | |
| 132 | 136 | const items: TimelineItem[] = [ |
| 133 | 137 | ...this.events(number).map((e) => ({ kind: "event" as const, ...e })), |
| 134 | 138 | ...comments.filter((c) => !c.reviewId).map((c) => ({ kind: "comment" as const, comment: c, createdAt: c.createdAt })), |
| @@ -285,8 +289,9 @@ | ||
| 285 | 289 | } |
| 286 | 290 | |
| 287 | 291 | /** One issue comment (or, with `review`, one review comment) and the issue it belongs to. */ |
| 288 | - comment(id: string, review = false): { number: number; isPull: boolean; comment: Comment } { | |
| 292 | + comment(id: string, review = false, hide?: string[]): { number: number; isPull: boolean; comment: Comment } { | |
| 289 | 293 | const c = this.commentRow(id, review); |
| 294 | + if (hide?.includes(c.author_id)) throw new HttpError(404, "comment not found"); | |
| 290 | 295 | return { number: c.number, isPull: this.issueRow(c.number).kind === "pull", comment: toComment(c) }; |
| 291 | 296 | } |
| 292 | 297 | |
| @@ -299,9 +304,13 @@ | ||
| 299 | 304 | } |
| 300 | 305 | |
| 301 | 306 | /** Comments across the repository (issue comments, or review comments with `review`). */ |
| 302 | - repoComments(opts: { review: boolean; since?: string; sort?: string; direction?: string; limit: number; offset: number }): { items: Array<{ number: number; isPull: boolean; comment: Comment }>; total: number } { | |
| 307 | + repoComments(opts: { review: boolean; since?: string; sort?: string; direction?: string; limit: number; offset: number; hide?: string[] }): { items: Array<{ number: number; isPull: boolean; comment: Comment }>; total: number } { | |
| 303 | 308 | const where = [`c.review_id IS ${opts.review ? "NOT " : ""}NULL`]; |
| 304 | 309 | const args: unknown[] = []; |
| 310 | + if (opts.hide?.length) { | |
| 311 | + where.push("c.author_id NOT IN (SELECT value FROM json_each(?))"); | |
| 312 | + args.push(JSON.stringify(opts.hide)); | |
| 313 | + } | |
| 305 | 314 | if (opts.since) { |
| 306 | 315 | where.push("c.updated_at >= ?"); |
| 307 | 316 | args.push(opts.since); |
| @@ -316,6 +325,7 @@ | ||
| 316 | 325 | |
| 317 | 326 | /** A single review comment, or a reply to one (`in_reply_to`), as a one-comment COMMENTED review. */ |
| 318 | 327 | createReviewComment(number: number, input: { body?: string; commit_id?: string; path?: string; line?: number; position?: number; side?: "LEFT" | "RIGHT"; in_reply_to?: string }, caller: Caller): Comment { |
| 328 | + refuseBlocked(caller); | |
| 319 | 329 | if (!input.body?.trim()) throw new HttpError(422, "body is required", { errors: [{ resource: "PullRequestReviewComment", code: "missing_field", field: "body" }] }); |
| 320 | 330 | checkText("PullRequestReviewComment", input); |
| 321 | 331 | let path = input.path; |
| @@ -438,6 +448,7 @@ | ||
| 438 | 448 | react(subject: string, content: string, caller: Caller): { reaction: Reaction; created: boolean } { |
| 439 | 449 | subject = this.subjectKey(subject); |
| 440 | 450 | if (caller.id === "anonymous") throw new HttpError(401, "sign in to react"); |
| 451 | + refuseBlocked(caller); | |
| 441 | 452 | if (!REACTION_CONTENT.includes(content)) throw new HttpError(422, `content must be one of ${REACTION_CONTENT.join(", ")}`, { errors: [{ resource: "Reaction", code: "invalid", field: "content" }] }); |
| 442 | 453 | if (!this.subjectExists(subject)) throw new HttpError(404, "not found"); |
| 443 | 454 | const number = this.subjectIssue(subject); |
modifiedapps/diffinery/src/repo/types.ts+11−0View file
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import type { Actor } from "@diffinery/shared"; |
| 2 | +import { HttpError } from "../lib/util.js"; | |
| 2 | 3 | |
| 3 | 4 | export type Role = "read" | "triage" | "write" | "maintain" | "admin"; |
| 4 | 5 | export const ROLE_LEVEL: Record<Role, number> = { read: 1, triage: 2, write: 3, maintain: 4, admin: 5 }; |
| @@ -11,8 +12,18 @@ | ||
| 11 | 12 | permissions?: Record<string, "none" | "read" | "write">; |
| 12 | 13 | /** Set only for a running import's own push token, after the Worker verified it (see http/git.ts). */ |
| 13 | 14 | importId?: string; |
| 15 | + /** The repository owner's login when it blocked this caller (lib/moderation.ts): they read, but can't take part. */ | |
| 16 | + blockedBy?: string; | |
| 14 | 17 | } |
| 15 | 18 | |
| 19 | +/** | |
| 20 | + * Someone the repository's owner blocked can't open issues or pull requests, comment, review or | |
| 21 | + * react there (lib/moderation.ts), as on GitHub. | |
| 22 | + */ | |
| 23 | +export function refuseBlocked(caller: Caller): void { | |
| 24 | + if (caller.blockedBy) throw new HttpError(403, `you can't open issues or pull requests, comment, review or react in ${caller.blockedBy}'s repositories: ${caller.blockedBy} blocked you`); | |
| 25 | +} | |
| 26 | + | |
| 16 | 27 | export interface RepoMeta { |
| 17 | 28 | repoId: string; |
| 18 | 29 | ownerId: string; |
modifiedapps/diffinery/src/web/account.tsx+108−4View file
| @@ -23,6 +23,8 @@ | ||
| 23 | 23 | import { serverErrors } from "../lib/server-errors.js"; |
| 24 | 24 | import { ComputePausedBanner, KillSwitchesPage } from "./kill-switches.js"; |
| 25 | 25 | import { changePause, changeSuspension, cleanReason, type Stopped } from "../lib/killswitch.js"; |
| 26 | +import { blockList, cleanResolution, openReportCount, report, reports, setReportState, suspend, suspendReason, unsuspend, visibleTo } from "../lib/moderation.js"; | |
| 27 | +import { BlockList, hasBlocked, ProfileMenu, ReportDetail, ReportsList, SuspendControl } from "./moderation.js"; | |
| 26 | 28 | |
| 27 | 29 | // ---------------------------------------------------------------- shared bits |
| 28 | 30 | |
| @@ -644,9 +646,9 @@ | ||
| 644 | 646 | |
| 645 | 647 | // ---------------------------------------------------------------- /settings |
| 646 | 648 | |
| 647 | -export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "linked" | "admin" | "email" | "audit" | "kill-switches" | "errors"; | |
| 649 | +export type SettingsTab = "profile" | "account" | "appearance" | "security" | "tokens" | "agents" | "model-access" | "orgs" | "invitations" | "notifications" | "email-address" | "blocked" | "linked" | "admin" | "reports" | "email" | "audit" | "kill-switches" | "errors"; | |
| 648 | 650 | /** The site administration pages: they show when hosted compute is paused. */ |
| 649 | -export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "email", "audit", "kill-switches", "errors"]; | |
| 651 | +export const ADMIN_TABS: readonly SettingsTab[] = ["admin", "reports", "email", "audit", "kill-switches", "errors"]; | |
| 650 | 652 | |
| 651 | 653 | export const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( |
| 652 | 654 | <aside class="side vt-side"> |
| @@ -692,6 +694,9 @@ | ||
| 692 | 694 | <a href="/settings/email" class={tab === "email-address" ? "on" : ""}> |
| 693 | 695 | |
| 694 | 696 | </a> |
| 697 | + <a href="/settings/blocked-users" class={tab === "blocked" ? "on" : ""}> | |
| 698 | + Blocked users | |
| 699 | + </a> | |
| 695 | 700 | <a href="/settings/linked-accounts" class={tab === "linked" ? "on" : ""}> |
| 696 | 701 | Linked accounts |
| 697 | 702 | </a> |
| @@ -702,6 +707,9 @@ | ||
| 702 | 707 | <a href="/settings/admin" class={tab === "admin" ? "on" : ""}> |
| 703 | 708 | People and invitations |
| 704 | 709 | </a> |
| 710 | + <a href="/settings/admin/reports" class={tab === "reports" ? "on" : ""}> | |
| 711 | + Reports | |
| 712 | + </a> | |
| 705 | 713 | <a href="/settings/admin/email" class={tab === "email" ? "on" : ""}> |
| 706 | 714 | |
| 707 | 715 | </a> |
| @@ -1321,6 +1329,30 @@ | ||
| 1321 | 1329 | |
| 1322 | 1330 | // Organizations |
| 1323 | 1331 | // Invitations to organizations and repositories: someone added by username joins once they accept. |
| 1332 | + // Blocked users (lib/moderation.ts): the people you blocked, and a form to block someone. | |
| 1333 | + web.get("/settings/blocked-users", async (c) => { | |
| 1334 | + const me = requireAccount(c); | |
| 1335 | + const t = csrfToken(c); | |
| 1336 | + return page( | |
| 1337 | + c, | |
| 1338 | + me, | |
| 1339 | + "blocked", | |
| 1340 | + "Blocked users", | |
| 1341 | + <> | |
| 1342 | + <h1>Blocked users</h1> | |
| 1343 | + <BlockList | |
| 1344 | + blocks={await blockList(c.env, me.id)} | |
| 1345 | + add="/settings/blocked-users" | |
| 1346 | + unblock={(login) => `/settings/blocked-users/${encodeURIComponent(login)}/unblock`} | |
| 1347 | + t={t} | |
| 1348 | + title="People you blocked" | |
| 1349 | + hint="They can still see your public repositories, but can't open issues or pull requests, comment or react in them, aren't collaborators on them, and can't invite you. You get no notifications from them. They aren't told." | |
| 1350 | + empty="You haven't blocked anyone." | |
| 1351 | + /> | |
| 1352 | + </>, | |
| 1353 | + ); | |
| 1354 | + }); | |
| 1355 | + | |
| 1324 | 1356 | web.get("/settings/invitations", async (c) => { |
| 1325 | 1357 | const p = c.get("principal"); |
| 1326 | 1358 | // Someone still waiting for an invite sees theirs on the home page. |
| @@ -1530,7 +1562,11 @@ | ||
| 1530 | 1562 | <td class="small">{u.email ?? "—"}</td> |
| 1531 | 1563 | <td class="small">{ago(u.created_at)}</td> |
| 1532 | 1564 | <td> |
| 1533 | - {u.id === me.id ? ( | |
| 1565 | + {u.suspended_at ? ( | |
| 1566 | + <span class="badge red" title={`Suspended by ${u.suspended_by ?? "a site administrator"}: ${u.suspended_reason ?? ""}`}> | |
| 1567 | + suspended | |
| 1568 | + </span> | |
| 1569 | + ) : u.id === me.id ? ( | |
| 1534 | 1570 | <span class="badge green">{u.status}</span> |
| 1535 | 1571 | ) : ( |
| 1536 | 1572 | <Form action={`/settings/admin/users/${u.id}/status`} t={t} class="row" style="gap:6px;flex-wrap:nowrap"> |
| @@ -1555,6 +1591,7 @@ | ||
| 1555 | 1591 | )} |
| 1556 | 1592 | </td> |
| 1557 | 1593 | <td class="right nowrap"> |
| 1594 | + {u.id === me.id ? null : <SuspendControl u={u} t={t} />}{" "} | |
| 1558 | 1595 | <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}> |
| 1559 | 1596 | <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button> |
| 1560 | 1597 | </Form>{" "} |
| @@ -1604,6 +1641,58 @@ | ||
| 1604 | 1641 | return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: `We emailed it to ${target.email ?? "them"}; you can also send it yourself. Send it only to them: it works once within 24 hours and adds a passkey to their account.` }); |
| 1605 | 1642 | }); |
| 1606 | 1643 | |
| 1644 | + // Suspension (lib/moderation.ts): with a reason, from the Users table or a report. | |
| 1645 | + const suspendTarget = async (c: AppContext) => { | |
| 1646 | + const user = await c.get("dir").accountByLogin(prm(c, "login")); | |
| 1647 | + if (!user || user.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1648 | + return user; | |
| 1649 | + }; | |
| 1650 | + const backFrom = (f: { s(name: string): string }) => (/^\/settings\/admin(\/|$)/.test(f.s("back")) ? f.s("back") : "/settings/admin"); | |
| 1651 | + post(web, "/settings/admin/users/:login/suspend", async (c, f) => { | |
| 1652 | + const me = requireSiteAdmin(c); | |
| 1653 | + return act(c, backFrom(f), async () => { | |
| 1654 | + const user = await suspendTarget(c); | |
| 1655 | + const stopped = await suspend(c.env, (e) => c.get("dir").audit(e), me, user, suspendReason(f.s("reason")), "web"); | |
| 1656 | + flash(c, "notice", `Suspended ${user.login}.${stoppedNote(stopped)}`); | |
| 1657 | + }); | |
| 1658 | + }); | |
| 1659 | + post(web, "/settings/admin/users/:login/unsuspend", async (c, f) => { | |
| 1660 | + const me = requireSiteAdmin(c); | |
| 1661 | + return act(c, backFrom(f), async () => { | |
| 1662 | + const user = await suspendTarget(c); | |
| 1663 | + if (!(await unsuspend(c.env, (e) => c.get("dir").audit(e), me, user, "web"))) throw new HttpError(422, `${user.login} isn't suspended`); | |
| 1664 | + flash(c, "notice", `${user.login} is no longer suspended.`); | |
| 1665 | + }); | |
| 1666 | + }); | |
| 1667 | + | |
| 1668 | + // Reports (lib/moderation.ts): the queue, one report, resolving and reopening. | |
| 1669 | + web.get("/settings/admin/reports", async (c) => { | |
| 1670 | + const me = requireSiteAdmin(c); | |
| 1671 | + const state = c.req.query("state") === "resolved" ? "resolved" : "open"; | |
| 1672 | + const [list, open, resolved] = await Promise.all([reports(c.env, state, 100), openReportCount(c.env), reports(c.env, "resolved", 1)]); | |
| 1673 | + return page(c, me, "reports", "Reports", <ReportsList items={list.items} state={state} counts={{ open, resolved: resolved.total }} />); | |
| 1674 | + }); | |
| 1675 | + web.get("/settings/admin/reports/:id", async (c) => { | |
| 1676 | + const me = requireSiteAdmin(c); | |
| 1677 | + const r = await report(c.env, prm(c, "id")); | |
| 1678 | + if (!r) throw new HttpError(404, "no report with that id"); | |
| 1679 | + const account = r.account_id ? await c.get("dir").account(r.account_id) : null; | |
| 1680 | + return page(c, me, "reports", "Report", <ReportDetail r={r} t={csrfToken(c)} suspended={account?.kind === "user" && !account.site_admin && account.id !== me.id ? !!account.suspended_at : null} />); | |
| 1681 | + }); | |
| 1682 | + for (const [path, state] of [["resolve", "resolved"], ["reopen", "open"]] as const) { | |
| 1683 | + post(web, `/settings/admin/reports/:id/${path}`, async (c, f) => { | |
| 1684 | + const me = requireSiteAdmin(c); | |
| 1685 | + const back = `/settings/admin/reports/${encodeURIComponent(prm(c, "id"))}`; | |
| 1686 | + return act(c, back, async () => { | |
| 1687 | + const note = state === "resolved" ? cleanResolution(f.s("resolution")) : null; | |
| 1688 | + const r = await setReportState(c.env, prm(c, "id"), me, state, note); | |
| 1689 | + if (!r) throw new HttpError(404, "no report with that id"); | |
| 1690 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: state === "resolved" ? "report.resolve" : "report.reopen", target: r.target, detail: { id: r.id, ...(note ? { resolution: note } : {}), via: "web" } })); | |
| 1691 | + return state === "resolved" ? "/settings/admin/reports" : back; | |
| 1692 | + }, state === "resolved" ? "Report resolved." : "Report reopened."); | |
| 1693 | + }); | |
| 1694 | + } | |
| 1695 | + | |
| 1607 | 1696 | post(web, "/settings/admin/invite", async (c, f) => { |
| 1608 | 1697 | const me = requireSiteAdmin(c); |
| 1609 | 1698 | const email = f.s("email").trim(); |
| @@ -1923,6 +2012,8 @@ | ||
| 1923 | 2012 | if (acct.kind === "org" && acct.status === "disabled") throw new HttpError(404, "not found"); |
| 1924 | 2013 | // So does a deleted person's (accounts/lifecycle.ts). |
| 1925 | 2014 | if (acct.deleted_at) throw new HttpError(404, "not found"); |
| 2015 | + // A suspended person's profile is hidden from everyone but site administrators (lib/moderation.ts). | |
| 2016 | + if (!visibleTo(acct, me)) throw new HttpError(404, "not found"); | |
| 1926 | 2017 | if (acct.login !== prm(c, "owner")) return c.redirect(`/${acct.login}${new URL(c.req.url).search}`, 301); |
| 1927 | 2018 | |
| 1928 | 2019 | if (acct.kind === "agent") { |
| @@ -1968,11 +2059,15 @@ | ||
| 1968 | 2059 | <section> |
| 1969 | 2060 | <Avatar login={acct.login} size="xl" version={acct.avatar_version} /> |
| 1970 | 2061 | <h1 class="mt m0">{acct.name || acct.login}</h1> |
| 1971 | - <p class="muted">{acct.login}</p> | |
| 2062 | + <p class="muted"> | |
| 2063 | + {acct.login} {acct.suspended_at ? <span class="badge red">suspended</span> : null} | |
| 2064 | + </p> | |
| 1972 | 2065 | {acct.id === me.id ? ( |
| 1973 | 2066 | <a class="btn sm" href="/settings"> |
| 1974 | 2067 | Edit profile |
| 1975 | 2068 | </a> |
| 2069 | + ) : me.kind === "user" ? ( | |
| 2070 | + <ProfileMenu c={c} user={acct} blocked={await hasBlocked(c, me, acct)} /> | |
| 1976 | 2071 | ) : null} |
| 1977 | 2072 | </section> |
| 1978 | 2073 | {orgs.length ? ( |
| @@ -2176,6 +2271,15 @@ | ||
| 2176 | 2271 | <button class="btn primary">Save</button> |
| 2177 | 2272 | </div> |
| 2178 | 2273 | </Form> |
| 2274 | + <BlockList | |
| 2275 | + blocks={await blockList(c.env, acct.id)} | |
| 2276 | + add={`${base}/-/org/blocks`} | |
| 2277 | + unblock={(login) => `${base}/-/org/blocks/${encodeURIComponent(login)}/unblock`} | |
| 2278 | + t={t} | |
| 2279 | + title="Blocked users" | |
| 2280 | + hint={`They can't open issues or pull requests, comment or react in ${acct.login}'s repositories, be added as collaborators or be invited to ${acct.login}. Members can't be blocked; remove them first.`} | |
| 2281 | + empty="This organization hasn't blocked anyone." | |
| 2282 | + /> | |
| 2179 | 2283 | <div class="box" style="border-color:color-mix(in srgb,var(--red) 45%,transparent)"> |
| 2180 | 2284 | <div class="head" style="background:var(--red-soft)"> |
| 2181 | 2285 | <h2 class="bad">Danger zone</h2> |
modifiedapps/diffinery/src/web/assets.ts+2−0View file
| @@ -153,6 +153,8 @@ | ||
| 153 | 153 | details.menu>.pop{position:absolute;z-index:30;top:calc(100% + 4px);left:0;min-width:260px;max-height:360px;overflow:auto;background:var(--bg);border:1px solid var(--border);border-radius:var(--r);box-shadow:var(--shadow);padding:6px} |
| 154 | 154 | details.menu>.pop.right{left:auto;right:0} |
| 155 | 155 | details.menu .pop a{display:flex;gap:8px;align-items:center;padding:6px 8px;border-radius:5px;color:var(--fg)}details.menu .pop a:hover{background:var(--bg-hover);text-decoration:none} |
| 156 | +details.menu .pop button.mi{display:flex;width:100%;gap:8px;align-items:center;padding:6px 8px;border:0;border-radius:5px;background:none;color:var(--fg);font:inherit;text-align:left;cursor:pointer}details.menu .pop button.mi:hover{background:var(--bg-hover)} | |
| 157 | +details.minimized>summary{cursor:pointer;padding:8px 12px;list-style:none}details.minimized>summary::-webkit-details-marker{display:none}details.minimized>summary .small{color:var(--accent)}details.minimized[open]>summary .small{display:none} | |
| 156 | 158 | details.menu .pop h4{font-size:11px;text-transform:uppercase;color:var(--fg3);margin:6px 8px 4px} |
| 157 | 159 | .crumbs{font-size:15px;display:flex;flex-wrap:wrap;align-items:center;gap:4px}.crumbs a{font-weight:600} |
| 158 | 160 | table.files{width:100%;border-collapse:collapse} |
modifiedapps/diffinery/src/web/collab.tsx+44−9View file
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import { raw } from "hono/html"; |
| 4 | 4 | import type { Child, FC } from "hono/jsx"; |
| 5 | 5 | import type { AppContext } from "../auth/principal.js"; |
| 6 | -import { loadRepo } from "../http/common.js"; | |
| 6 | +import { hiddenAuthors, loadRepo } from "../http/common.js"; | |
| 7 | 7 | import { HttpError } from "../lib/util.js"; |
| 8 | 8 | import type { Check, Comment, Issue, Mergeability, Review } from "../repo/collab.js"; |
| 9 | 9 | import { atLeast, type Role } from "../repo/types.js"; |
| @@ -13,6 +13,7 @@ | ||
| 13 | 13 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 14 | 14 | import { mentions } from "../mail/notify.js"; |
| 15 | 15 | import { CheckName } from "./checks.js"; |
| 16 | +import { CommentMenu, Minimized, MoreMenu, ReportItem } from "./moderation.js"; | |
| 16 | 17 | import { |
| 17 | 18 | Actor, |
| 18 | 19 | Avatar, |
| @@ -122,7 +123,7 @@ | ||
| 122 | 123 | const ms = /^\d+$/.test(c.req.query("milestone") ?? "") ? Number(c.req.query("milestone")) : undefined; |
| 123 | 124 | const page = pageNum(c); |
| 124 | 125 | const per = 25; |
| 125 | - const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined, milestone: ms }; | |
| 126 | + const filter = { kind, label: label || undefined, q: q || undefined, author: author || undefined, milestone: ms, hide: await hiddenAuthors(c) }; | |
| 126 | 127 | const [list, other, labels, milestone] = await Promise.all([ |
| 127 | 128 | stub.issues({ ...filter, state, limit: per, offset: (page - 1) * per }), |
| 128 | 129 | stub.issues({ ...filter, state: state === "open" ? "closed" : "open", limit: 1 }), |
| @@ -267,8 +268,11 @@ | ||
| 267 | 268 | agentReview?: PullReviewCtx | null; |
| 268 | 269 | } |
| 269 | 270 | |
| 270 | -const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string }> = ({ x, author, at, body, verb, id, extra, cls }) => ( | |
| 271 | - <div class={`comment ${cls ?? ""}`} id={id}> | |
| 271 | +/** Whether the viewer may hide (minimize) comments here: people with write access, as on GitHub. */ | |
| 272 | +const canHide = (x: ConvCtx): boolean => x.c.get("principal").type === "account" && x.l.caller.kind === "user" && atLeast(x.l.caller.role, "write"); | |
| 273 | + | |
| 274 | +const CommentCard: FC<{ x: ConvCtx; author: { login: string; kind: string }; at: string; body: string; verb?: string; id?: string; extra?: Child; cls?: string; comment?: Comment }> = ({ x, author, at, body, verb, id, extra, cls, comment }) => { | |
| 275 | + const head = ( | |
| 272 | 276 | <div class="head"> |
| 273 | 277 | <Actor login={author.login} kind={author.kind} onBehalfOf={x.behalf.get(author.login)} /> |
| 274 | 278 | <span class="muted"> |
| @@ -277,12 +281,31 @@ | ||
| 277 | 281 | <span class="grow" /> |
| 278 | 282 | {author.login === x.issue.author.login ? <span class="badge">Author</span> : null} |
| 279 | 283 | {extra} |
| 284 | + {comment ? <CommentMenu c={x.c} repo={`${x.l.repo.owner_login}/${x.l.repo.name}`} id={comment.id} minimized={!!comment.minimized} canHide={canHide(x)} /> : null} | |
| 280 | 285 | </div> |
| 286 | + ); | |
| 287 | + const content = ( | |
| 281 | 288 | <div class="body"> |
| 282 | 289 | <Markdown text={body} base={{ owner: x.l.repo.owner_login, repo: x.l.repo.name, ref: x.l.repo.default_branch, dir: "" }} /> |
| 283 | 290 | </div> |
| 284 | - </div> | |
| 285 | -); | |
| 291 | + ); | |
| 292 | + // A hidden comment shows collapsed behind its reason (lib/moderation.ts). | |
| 293 | + if (comment?.minimized) | |
| 294 | + return ( | |
| 295 | + <div class={`comment minimized ${cls ?? ""}`} id={id}> | |
| 296 | + <Minimized reason={comment.minimized.reason}> | |
| 297 | + {head} | |
| 298 | + {content} | |
| 299 | + </Minimized> | |
| 300 | + </div> | |
| 301 | + ); | |
| 302 | + return ( | |
| 303 | + <div class={`comment ${cls ?? ""}`} id={id}> | |
| 304 | + {head} | |
| 305 | + {content} | |
| 306 | + </div> | |
| 307 | + ); | |
| 308 | +}; | |
| 286 | 309 | |
| 287 | 310 | const LineComments: FC<{ x: ConvCtx; list: Comment[] }> = ({ x, list }) => { |
| 288 | 311 | const base = repoUrl(x.l.repo); |
| @@ -300,8 +323,15 @@ | ||
| 300 | 323 | <span class="grow" /> |
| 301 | 324 | <Actor login={cm.author.login} kind={cm.author.kind} avatar={false} /> |
| 302 | 325 | <Time at={cm.createdAt} /> |
| 326 | + <CommentMenu c={x.c} repo={`${x.l.repo.owner_login}/${x.l.repo.name}`} id={cm.id} minimized={!!cm.minimized} canHide={canHide(x)} /> | |
| 303 | 327 | </div> |
| 304 | - <Markdown text={cm.body} /> | |
| 328 | + {cm.minimized ? ( | |
| 329 | + <Minimized reason={cm.minimized.reason}> | |
| 330 | + <Markdown text={cm.body} /> | |
| 331 | + </Minimized> | |
| 332 | + ) : ( | |
| 333 | + <Markdown text={cm.body} /> | |
| 334 | + )} | |
| 305 | 335 | {x.issue.pull && x.issue.state === "open" ? <FixIt l={x.l} pr={x.issue.number} comment={cm} /> : null} |
| 306 | 336 | </div> |
| 307 | 337 | ))} |
| @@ -313,7 +343,7 @@ | ||
| 313 | 343 | type Item = { at: string; node: Child }; |
| 314 | 344 | const items: Item[] = []; |
| 315 | 345 | for (const cm of x.comments.filter((c) => !c.reviewId)) { |
| 316 | - items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} /> }); | |
| 346 | + items.push({ at: cm.createdAt, node: <CommentCard x={x} author={cm.author} at={cm.createdAt} body={cm.body} id={`c-${cm.id}`} comment={cm} /> }); | |
| 317 | 347 | } |
| 318 | 348 | for (const rv of x.reviews) { |
| 319 | 349 | const lines = x.comments.filter((c) => c.reviewId === rv.id); |
| @@ -634,6 +664,11 @@ | ||
| 634 | 664 | </h1> |
| 635 | 665 | <div class="row"> |
| 636 | 666 | <TitleEditor x={x} /> |
| 667 | + {x.c.get("principal").type === "account" ? ( | |
| 668 | + <MoreMenu label={i.kind === "pull" ? "Pull request actions" : "Issue actions"}> | |
| 669 | + <ReportItem s={{ kind: i.kind === "pull" ? "pull_request" : "issue", repository: `${x.l.repo.owner_login}/${x.l.repo.name}`, number: i.number }} label="Report content" /> | |
| 670 | + </MoreMenu> | |
| 671 | + ) : null} | |
| 637 | 672 | {i.kind === "issue" ? ( |
| 638 | 673 | <a class="btn primary" href={`${base}/issues/new`}> |
| 639 | 674 | New issue |
| @@ -1027,7 +1062,7 @@ | ||
| 1027 | 1062 | async function loadConv(c: AppContext): Promise<ConvCtx> { |
| 1028 | 1063 | const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read"); |
| 1029 | 1064 | const n = issueNumber(c); |
| 1030 | - const [data, labels, milestones] = await Promise.all([l.stub.issue(n), l.stub.labels(), l.stub.milestones("open")]); | |
| 1065 | + const [data, labels, milestones] = await Promise.all([l.stub.issue(n, await hiddenAuthors(c)), l.stub.labels(), l.stub.milestones("open")]); | |
| 1031 | 1066 | const behalf = await onBehalfOf(c, [data.issue.author, ...data.comments.map((x) => x.author), ...data.reviews.map((x) => x.author)]); |
| 1032 | 1067 | const me = principalAccount(c); |
| 1033 | 1068 | let sub: ConvCtx["sub"]; |
modifiedapps/diffinery/src/web/icons.ui.generated.ts+4−0View file
| @@ -52,4 +52,8 @@ | ||
| 52 | 52 | caret: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z\"></path></svg>", |
| 53 | 53 | link: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M165.66,90.34a8,8,0,0,1,0,11.32l-64,64a8,8,0,0,1-11.32-11.32l64-64A8,8,0,0,1,165.66,90.34ZM215.6,40.4a56,56,0,0,0-79.2,0L106.34,70.45a8,8,0,0,0,11.32,11.32l30.06-30a40,40,0,0,1,56.57,56.56l-30.07,30.06a8,8,0,0,0,11.31,11.32L215.6,119.6a56,56,0,0,0,0-79.2ZM138.34,174.22l-30.06,30.06a40,40,0,1,1-56.56-56.57l30.05-30.05a8,8,0,0,0-11.32-11.32L40.4,136.4a56,56,0,0,0,79.2,79.2l30.06-30.07a8,8,0,0,0-11.32-11.31Z\"></path></svg>", |
| 54 | 54 | rocket: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M223.85,47.12a16,16,0,0,0-15-15c-12.58-.75-44.73.4-71.41,27.07L132.69,64H74.36A15.91,15.91,0,0,0,63,68.68L28.7,103a16,16,0,0,0,9.07,27.16l38.47,5.37,44.21,44.21,5.37,38.49a15.94,15.94,0,0,0,10.78,12.92,16.11,16.11,0,0,0,5.1.83A15.91,15.91,0,0,0,153,227.3L187.32,193A15.91,15.91,0,0,0,192,181.64V123.31l4.77-4.77C223.45,91.86,224.6,59.71,223.85,47.12ZM74.36,80h42.33L77.16,119.52,40,114.34Zm74.41-9.45a76.65,76.65,0,0,1,59.11-22.47,76.46,76.46,0,0,1-22.42,59.16L128,164.68,91.32,128ZM176,181.64,141.67,216l-5.19-37.17L176,139.31Zm-74.16,9.5C97.34,201,82.29,224,40,224a8,8,0,0,1-8-8c0-42.29,23-57.34,32.86-61.85a8,8,0,0,1,6.64,14.56c-6.43,2.93-20.62,12.36-23.12,38.91,26.55-2.5,36-16.69,38.91-23.12a8,8,0,1,1,14.56,6.64Z\"></path></svg>", |
| 55 | + more: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M144,128a16,16,0,1,1-16-16A16,16,0,0,1,144,128ZM60,112a16,16,0,1,0,16,16A16,16,0,0,0,60,112Zm136,0a16,16,0,1,0,16,16A16,16,0,0,0,196,112Z\"></path></svg>", | |
| 56 | + flag: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M42.76,50A8,8,0,0,0,40,56V224a8,8,0,0,0,16,0V179.77c26.79-21.16,49.87-9.75,76.45,3.41,16.4,8.11,34.06,16.85,53,16.85,13.93,0,28.54-4.75,43.82-18a8,8,0,0,0,2.76-6V56A8,8,0,0,0,218.76,50c-28,24.23-51.72,12.49-79.21-1.12C111.07,34.76,78.78,18.79,42.76,50ZM216,172.25c-26.79,21.16-49.87,9.74-76.45-3.41-25-12.35-52.81-26.13-83.55-8.4V59.79c26.79-21.16,49.87-9.75,76.45,3.4,25,12.35,52.82,26.13,83.55,8.4Z\"></path></svg>", | |
| 57 | + prohibit: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm88,104a87.56,87.56,0,0,1-20.41,56.28L71.72,60.4A88,88,0,0,1,216,128ZM40,128A87.56,87.56,0,0,1,60.41,71.72L184.28,195.6A88,88,0,0,1,40,128Z\"></path></svg>", | |
| 58 | + eyeSlash: "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" fill=\"currentColor\" viewBox=\"0 0 256 256\" aria-hidden=\"true\" focusable=\"false\"><path d=\"M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.57A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z\"></path></svg>", | |
| 55 | 59 | } as const; |
addedapps/diffinery/src/web/moderation.tsx+438−0View file
| @@ -1,0 +1,438 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { raw } from "hono/html"; | |
| 3 | +import type { Child, FC, PropsWithChildren } from "hono/jsx"; | |
| 4 | +import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 5 | +import type { Account } from "../lib/directory.js"; | |
| 6 | +import { fileReport, reportTarget, type ReportSubject } from "../http/moderation.js"; | |
| 7 | +import { loadRepo } from "../http/common.js"; | |
| 8 | +import { block, blockList, isBlocked, MAX_REPORT_TEXT, MINIMIZE_REASONS, minimizeReason, minimizedNote, REPORT_REASONS, unblock, visibleTo, type Block, type Report } from "../lib/moderation.js"; | |
| 9 | +import { HttpError } from "../lib/util.js"; | |
| 10 | +import { ICONS } from "./assets.js"; | |
| 11 | +import { csrfToken } from "./csrf.js"; | |
| 12 | +import { KRadio } from "./kumo.js"; | |
| 13 | +import { act, Actor, Form, flash, post, prm, qs, render, Time, type WebApp } from "./ui.js"; | |
| 14 | + | |
| 15 | +/** | |
| 16 | + * Moderation in the web UI (lib/moderation.ts): the "⋯" menus with **Report** (and **Block**, | |
| 17 | + * **Hide**) on profiles, repositories, issues, pull requests and comments, the report form, blocked | |
| 18 | + * users lists, minimized comments, and the pieces of the site administration pages (their routes | |
| 19 | + * are in account.tsx). | |
| 20 | + */ | |
| 21 | + | |
| 22 | +// ---------------------------------------------------------------- menus | |
| 23 | + | |
| 24 | +/** The "⋯" menu: links and one-button forms. */ | |
| 25 | +export const MoreMenu: FC<PropsWithChildren<{ label: string; right?: boolean }>> = ({ label, right = true, children }) => ( | |
| 26 | + <details class="menu more"> | |
| 27 | + <summary class="btn sm icon" aria-label={label} title={label}> | |
| 28 | + {raw(ICONS.more)} | |
| 29 | + </summary> | |
| 30 | + <div class={`pop${right ? " right" : ""}`}>{children}</div> | |
| 31 | + </details> | |
| 32 | +); | |
| 33 | + | |
| 34 | +/** Where the report form for something is. */ | |
| 35 | +export const reportHref = (s: ReportSubject): string => `/-/report${qs({ kind: s.kind, login: s.login, repository: s.repository, number: s.number, comment_id: s.comment_id })}`; | |
| 36 | + | |
| 37 | +export const ReportItem: FC<{ s: ReportSubject; label: string }> = ({ s, label }) => ( | |
| 38 | + <a href={reportHref(s)}> | |
| 39 | + {raw(ICONS.flag)} {label} | |
| 40 | + </a> | |
| 41 | +); | |
| 42 | + | |
| 43 | +/** On a person's profile: block or unblock them, and report them. */ | |
| 44 | +export const ProfileMenu: FC<{ c: AppContext; user: Account; blocked: boolean }> = ({ c, user, blocked }) => { | |
| 45 | + const t = csrfToken(c); | |
| 46 | + const back = `/${user.login}`; | |
| 47 | + return ( | |
| 48 | + <MoreMenu label={`More for ${user.login}`} right={false}> | |
| 49 | + {blocked ? ( | |
| 50 | + <Form action={`/settings/blocked-users/${encodeURIComponent(user.login)}/unblock`} t={t}> | |
| 51 | + <input type="hidden" name="back" value={back} /> | |
| 52 | + <button class="mi">{raw(ICONS.prohibit)} Unblock user</button> | |
| 53 | + </Form> | |
| 54 | + ) : ( | |
| 55 | + <Form action="/settings/blocked-users" t={t} confirm={`Block ${user.login}? They won't be able to open issues or pull requests, comment or react in your repositories, and you won't get notifications from them.`}> | |
| 56 | + <input type="hidden" name="login" value={user.login} /> | |
| 57 | + <input type="hidden" name="back" value={back} /> | |
| 58 | + <button class="mi">{raw(ICONS.prohibit)} Block user</button> | |
| 59 | + </Form> | |
| 60 | + )} | |
| 61 | + <ReportItem s={{ kind: "user", login: user.login }} label="Report user" /> | |
| 62 | + </MoreMenu> | |
| 63 | + ); | |
| 64 | +}; | |
| 65 | + | |
| 66 | +/** On a comment: report it, and (for people with write access) hide or show it. */ | |
| 67 | +export const CommentMenu: FC<{ c: AppContext; repo: string; id: string; minimized: boolean; canHide: boolean }> = ({ c, repo, id, minimized, canHide }) => { | |
| 68 | + if (c.get("principal").type !== "account") return null; | |
| 69 | + const t = csrfToken(c); | |
| 70 | + const action = `/${repo}/comments/${encodeURIComponent(id)}`; | |
| 71 | + return ( | |
| 72 | + <MoreMenu label="Comment actions"> | |
| 73 | + <ReportItem s={{ kind: "comment", repository: repo, comment_id: id }} label="Report content" /> | |
| 74 | + {canHide && minimized ? ( | |
| 75 | + <Form action={`${action}/unminimize`} t={t}> | |
| 76 | + <button class="mi">{raw(ICONS.eye)} Unhide</button> | |
| 77 | + </Form> | |
| 78 | + ) : null} | |
| 79 | + {canHide && !minimized ? ( | |
| 80 | + <> | |
| 81 | + <h4>Hide comment</h4> | |
| 82 | + {(Object.keys(MINIMIZE_REASONS) as Array<keyof typeof MINIMIZE_REASONS>).map((r) => ( | |
| 83 | + <Form action={`${action}/minimize`} t={t}> | |
| 84 | + <input type="hidden" name="reason" value={r} /> | |
| 85 | + <button class="mi"> | |
| 86 | + {raw(ICONS.eyeSlash)} {r === "off_topic" ? "Off-topic" : r.charAt(0).toUpperCase() + r.slice(1)} | |
| 87 | + </button> | |
| 88 | + </Form> | |
| 89 | + ))} | |
| 90 | + </> | |
| 91 | + ) : null} | |
| 92 | + </MoreMenu> | |
| 93 | + ); | |
| 94 | +}; | |
| 95 | + | |
| 96 | +/** A minimized comment: collapsed behind its reason, as on GitHub. */ | |
| 97 | +export const Minimized: FC<PropsWithChildren<{ reason: string }>> = ({ reason, children }) => ( | |
| 98 | + <details class="minimized" data-minimized={reason}> | |
| 99 | + <summary> | |
| 100 | + <span class="muted">{minimizedNote(reason)}</span> <span class="small">Show comment</span> | |
| 101 | + </summary> | |
| 102 | + {children} | |
| 103 | + </details> | |
| 104 | +); | |
| 105 | + | |
| 106 | +// ---------------------------------------------------------------- blocked users lists | |
| 107 | + | |
| 108 | +export const BlockList: FC<{ blocks: Block[]; add: string; unblock: (login: string) => string; t: string; title: string; hint: Child; empty: string }> = ({ blocks, add, unblock, t, title, hint, empty }) => ( | |
| 109 | + <div class="box"> | |
| 110 | + <div class="head"> | |
| 111 | + <h2>{title}</h2> | |
| 112 | + <span class="badge">{blocks.length}</span> | |
| 113 | + </div> | |
| 114 | + <Form action={add} t={t} class="body"> | |
| 115 | + <div class="formrow"> | |
| 116 | + <div class="field grow"> | |
| 117 | + <label for="block-login">Block a user</label> | |
| 118 | + <input id="block-login" name="login" type="text" required placeholder="username" autocomplete="off" /> | |
| 119 | + </div> | |
| 120 | + <button class="btn danger">{raw(ICONS.prohibit)} Block user</button> | |
| 121 | + </div> | |
| 122 | + <div class="hint">{hint}</div> | |
| 123 | + </Form> | |
| 124 | + {blocks.length ? ( | |
| 125 | + <ul class="list"> | |
| 126 | + {blocks.map((b) => ( | |
| 127 | + <li> | |
| 128 | + <Actor login={b.account.login} kind="user" version={b.account.avatar_version} /> | |
| 129 | + <span class="muted small grow"> | |
| 130 | + blocked <Time at={b.createdAt} /> | |
| 131 | + {b.createdBy ? <> by {b.createdBy}</> : null} | |
| 132 | + </span> | |
| 133 | + <Form action={unblock(b.account.login)} t={t} class="inline"> | |
| 134 | + <button class="btn sm">Unblock</button> | |
| 135 | + </Form> | |
| 136 | + </li> | |
| 137 | + ))} | |
| 138 | + </ul> | |
| 139 | + ) : ( | |
| 140 | + <div class="empty">{empty}</div> | |
| 141 | + )} | |
| 142 | + </div> | |
| 143 | +); | |
| 144 | + | |
| 145 | +// ---------------------------------------------------------------- site administration | |
| 146 | + | |
| 147 | +const KIND_LABEL: Record<string, string> = { user: "User", repository: "Repository", issue: "Issue", pull_request: "Pull request", comment: "Comment" }; | |
| 148 | +const pathOf = (url: string) => { | |
| 149 | + try { | |
| 150 | + const u = new URL(url); | |
| 151 | + return u.pathname + u.hash; | |
| 152 | + } catch { | |
| 153 | + return url; | |
| 154 | + } | |
| 155 | +}; | |
| 156 | + | |
| 157 | +/** The queue on Site administration → Reports. */ | |
| 158 | +export const ReportsList: FC<{ items: Report[]; state: "open" | "resolved"; counts: { open: number; resolved: number } }> = ({ items, state, counts }) => ( | |
| 159 | + <> | |
| 160 | + <h1>Reports</h1> | |
| 161 | + <p class="muted">What people reported as abusive. Open one to see what was reported, then resolve it with a note saying what you did.</p> | |
| 162 | + <div class="box"> | |
| 163 | + <div class="head"> | |
| 164 | + <a href="/settings/admin/reports" style={state === "open" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 165 | + {raw(ICONS.flag)} {counts.open} Open | |
| 166 | + </a> | |
| 167 | + <a href="/settings/admin/reports?state=resolved" style={state === "resolved" ? "font-weight:600;color:var(--fg)" : "color:var(--fg2)"}> | |
| 168 | + {raw(ICONS.check)} {counts.resolved} Resolved | |
| 169 | + </a> | |
| 170 | + </div> | |
| 171 | + {items.length ? ( | |
| 172 | + <ul class="list"> | |
| 173 | + {items.map((r) => ( | |
| 174 | + <li> | |
| 175 | + <span class="badge">{KIND_LABEL[r.kind] ?? r.kind}</span> | |
| 176 | + <div class="grow"> | |
| 177 | + <a class="title" href={`/settings/admin/reports/${r.id}`}> | |
| 178 | + {r.target} | |
| 179 | + </a>{" "} | |
| 180 | + <span class="badge amber">{REPORT_REASONS[r.reason] ?? r.reason}</span> | |
| 181 | + <div class="meta"> | |
| 182 | + reported by {r.reporter_login ?? "someone"} <Time at={r.created_at} /> | |
| 183 | + {r.state === "resolved" ? ( | |
| 184 | + <> | |
| 185 | + {" "} | |
| 186 | + · resolved by {r.resolved_by} <Time at={r.resolved_at} /> | |
| 187 | + </> | |
| 188 | + ) : null} | |
| 189 | + </div> | |
| 190 | + </div> | |
| 191 | + </li> | |
| 192 | + ))} | |
| 193 | + </ul> | |
| 194 | + ) : ( | |
| 195 | + <div class="empty">{state === "open" ? "No open reports." : "No resolved reports."}</div> | |
| 196 | + )} | |
| 197 | + </div> | |
| 198 | + </> | |
| 199 | +); | |
| 200 | + | |
| 201 | +/** One report, with what was reported and the form that resolves (or reopens) it. */ | |
| 202 | +export const ReportDetail: FC<{ r: Report; t: string; suspended: boolean | null }> = ({ r, t, suspended }) => ( | |
| 203 | + <> | |
| 204 | + <p class="small"> | |
| 205 | + <a href="/settings/admin/reports">← Reports</a> | |
| 206 | + </p> | |
| 207 | + <h1> | |
| 208 | + {KIND_LABEL[r.kind] ?? r.kind} report <span class={`badge ${r.state === "open" ? "amber" : "green"}`}>{r.state}</span> | |
| 209 | + </h1> | |
| 210 | + <div class="box"> | |
| 211 | + <div class="body"> | |
| 212 | + <dl class="kv"> | |
| 213 | + <dt>Reported</dt> | |
| 214 | + <dd> | |
| 215 | + <a href={pathOf(r.url)}>{r.target}</a> | |
| 216 | + </dd> | |
| 217 | + <dt>Reason</dt> | |
| 218 | + <dd>{REPORT_REASONS[r.reason] ?? r.reason}</dd> | |
| 219 | + <dt>Reported by</dt> | |
| 220 | + <dd> | |
| 221 | + {r.reporter_login ? <a href={`/${r.reporter_login}`}>{r.reporter_login}</a> : "—"} <Time at={r.created_at} /> | |
| 222 | + </dd> | |
| 223 | + <dt>Account</dt> | |
| 224 | + <dd>{r.account_login ? <a href={`/${r.account_login}`}>{r.account_login}</a> : "—"}{suspended ? <span class="badge red">suspended</span> : null}</dd> | |
| 225 | + {r.state === "resolved" ? ( | |
| 226 | + <> | |
| 227 | + <dt>Resolved</dt> | |
| 228 | + <dd> | |
| 229 | + by {r.resolved_by} <Time at={r.resolved_at} /> | |
| 230 | + </dd> | |
| 231 | + </> | |
| 232 | + ) : null} | |
| 233 | + </dl> | |
| 234 | + {r.body ? ( | |
| 235 | + <> | |
| 236 | + <h3>Note from the reporter</h3> | |
| 237 | + <div class="meta" style="white-space:pre-wrap">{r.body}</div> | |
| 238 | + </> | |
| 239 | + ) : null} | |
| 240 | + {r.excerpt ? ( | |
| 241 | + <> | |
| 242 | + <h3>What was reported, when it was reported</h3> | |
| 243 | + <pre class="excerpt" style="white-space:pre-wrap;max-height:320px;overflow:auto">{r.excerpt}</pre> | |
| 244 | + </> | |
| 245 | + ) : null} | |
| 246 | + {r.state === "resolved" && r.resolution ? ( | |
| 247 | + <> | |
| 248 | + <h3>Resolution</h3> | |
| 249 | + <div style="white-space:pre-wrap">{r.resolution}</div> | |
| 250 | + </> | |
| 251 | + ) : null} | |
| 252 | + </div> | |
| 253 | + </div> | |
| 254 | + {r.state === "open" ? ( | |
| 255 | + <Form action={`/settings/admin/reports/${r.id}/resolve`} t={t} class="box"> | |
| 256 | + <div class="body"> | |
| 257 | + <div class="field"> | |
| 258 | + <label for="resolution">Resolve with a note</label> | |
| 259 | + <textarea id="resolution" name="resolution" required maxlength={MAX_REPORT_TEXT} placeholder="What you did: hid the comment, suspended the account, nothing to do…" /> | |
| 260 | + </div> | |
| 261 | + <button class="btn primary">{raw(ICONS.check)} Resolve</button> | |
| 262 | + </div> | |
| 263 | + </Form> | |
| 264 | + ) : ( | |
| 265 | + <Form action={`/settings/admin/reports/${r.id}/reopen`} t={t} class="inline"> | |
| 266 | + <button class="btn">Reopen</button> | |
| 267 | + </Form> | |
| 268 | + )} | |
| 269 | + {r.account_login && suspended === false ? ( | |
| 270 | + <Form action={`/settings/admin/users/${encodeURIComponent(r.account_login)}/suspend`} t={t} class="box" confirm={`Suspend ${r.account_login}? They are signed out, their tokens, agents and runners stop working and their content is hidden until you unsuspend them.`}> | |
| 271 | + <div class="body"> | |
| 272 | + <div class="formrow"> | |
| 273 | + <div class="field grow"> | |
| 274 | + <label for="suspend-reason">Suspend {r.account_login}</label> | |
| 275 | + <input id="suspend-reason" name="reason" type="text" required maxlength={500} placeholder="Reason (shown to them and kept in the audit log)" /> | |
| 276 | + </div> | |
| 277 | + <input type="hidden" name="back" value={`/settings/admin/reports/${r.id}`} /> | |
| 278 | + <button class="btn danger">Suspend</button> | |
| 279 | + </div> | |
| 280 | + </div> | |
| 281 | + </Form> | |
| 282 | + ) : null} | |
| 283 | + </> | |
| 284 | +); | |
| 285 | + | |
| 286 | +/** In the Users table: suspend someone with a reason, or lift their suspension. */ | |
| 287 | +export const SuspendControl: FC<{ u: Account; t: string }> = ({ u, t }) => | |
| 288 | + u.suspended_at ? ( | |
| 289 | + <Form action={`/settings/admin/users/${encodeURIComponent(u.login)}/unsuspend`} t={t} class="inline" confirm={`Unsuspend ${u.login}? Their account, tokens and content come back.`}> | |
| 290 | + <button class="btn sm" title={u.suspended_reason ?? ""}> | |
| 291 | + Unsuspend | |
| 292 | + </button> | |
| 293 | + </Form> | |
| 294 | + ) : u.site_admin ? null : ( | |
| 295 | + <details class="menu"> | |
| 296 | + <summary class="btn sm">Suspend</summary> | |
| 297 | + <div class="pop right" style="width:min(340px,90vw)"> | |
| 298 | + <Form action={`/settings/admin/users/${encodeURIComponent(u.login)}/suspend`} t={t} style="padding:8px"> | |
| 299 | + <div class="field"> | |
| 300 | + <label>Reason</label> | |
| 301 | + <input name="reason" type="text" required maxlength={500} placeholder="Shown to them and kept in the audit log" /> | |
| 302 | + </div> | |
| 303 | + <button class="btn sm danger">Suspend {u.login}</button> | |
| 304 | + </Form> | |
| 305 | + </div> | |
| 306 | + </details> | |
| 307 | + ); | |
| 308 | + | |
| 309 | +// ---------------------------------------------------------------- routes | |
| 310 | + | |
| 311 | +const subjectOf = (get: (k: string) => string | undefined): ReportSubject => ({ | |
| 312 | + kind: get("kind") ?? "", | |
| 313 | + login: get("login") || undefined, | |
| 314 | + repository: get("repository") || undefined, | |
| 315 | + number: get("number") ? Number(get("number")) : undefined, | |
| 316 | + comment_id: get("comment_id") || undefined, | |
| 317 | +}); | |
| 318 | + | |
| 319 | +/** A path on this site to go back to (never another site). */ | |
| 320 | +const localBack = (v: string, fallback: string) => (/^\/[^/\\]/.test(v) ? v : fallback); | |
| 321 | + | |
| 322 | +export function registerModeration(web: WebApp): void { | |
| 323 | + // Report something: the form, then the report. | |
| 324 | + web.get("/-/report", async (c) => { | |
| 325 | + requireAccount(c); | |
| 326 | + const s = subjectOf((k) => c.req.query(k)); | |
| 327 | + const target = await reportTarget(c, s); | |
| 328 | + const t = csrfToken(c); | |
| 329 | + return render( | |
| 330 | + c, | |
| 331 | + { title: "Report content" }, | |
| 332 | + <div style="max-width:720px;margin:24px auto"> | |
| 333 | + <h1>Report {KIND_LABEL[target.kind]?.toLowerCase() ?? "content"}</h1> | |
| 334 | + <p class="muted"> | |
| 335 | + Reports go to the site administrators of this instance, who decide what to do. <a href={pathOf(target.url)}>{target.target}</a> isn't told who reported it. | |
| 336 | + </p> | |
| 337 | + {target.excerpt ? ( | |
| 338 | + <div class="box"> | |
| 339 | + <div class="body"> | |
| 340 | + <pre style="white-space:pre-wrap;max-height:200px;overflow:auto;margin:0">{target.excerpt.slice(0, 1000)}</pre> | |
| 341 | + </div> | |
| 342 | + </div> | |
| 343 | + ) : null} | |
| 344 | + <Form action={`/-/report${new URL(c.req.url).search}`} t={t} class="box"> | |
| 345 | + <div class="body"> | |
| 346 | + <div class="field"> | |
| 347 | + <label>What is wrong with it?</label> | |
| 348 | + <KRadio name="reason" value="spam" items={Object.entries(REPORT_REASONS).map(([value, label]) => ({ value, label }))} label="Reason" /> | |
| 349 | + </div> | |
| 350 | + <div class="field"> | |
| 351 | + <label for="report-body">Anything the administrators should know</label> | |
| 352 | + <textarea id="report-body" name="body" maxlength={MAX_REPORT_TEXT} placeholder="Optional, except for Something else" /> | |
| 353 | + </div> | |
| 354 | + <button class="btn danger">{raw(ICONS.flag)} Send report</button> | |
| 355 | + </div> | |
| 356 | + </Form> | |
| 357 | + </div>, | |
| 358 | + ); | |
| 359 | + }); | |
| 360 | + post(web, "/-/report", async (c, f) => { | |
| 361 | + requireAccount(c); | |
| 362 | + const s = subjectOf((k) => c.req.query(k) ?? f.s(k)); | |
| 363 | + return act(c, `/-/report${new URL(c.req.url).search}`, async () => { | |
| 364 | + const r = await fileReport(c, s, { reason: f.s("reason"), body: f.s("body") }, "web"); | |
| 365 | + return pathOf(r.url); | |
| 366 | + }, "Thanks for reporting it. The site administrators will look at it."); | |
| 367 | + }); | |
| 368 | + | |
| 369 | + // Block and unblock, for yourself (from a profile or Settings → Blocked users). | |
| 370 | + post(web, "/settings/blocked-users", async (c, f) => { | |
| 371 | + const me = requireAccount(c); | |
| 372 | + const back = localBack(f.s("back"), "/settings/blocked-users"); | |
| 373 | + return act(c, back, async () => { | |
| 374 | + if (me.kind !== "user") throw new HttpError(403, "only people can block people"); | |
| 375 | + const user = await c.get("dir").accountByLogin(f.s("login").trim().replace(/^@/, "")); | |
| 376 | + if (!user || !visibleTo(user, me)) throw new HttpError(404, "no one has that username"); | |
| 377 | + if (await block(c.env, me, user, me)) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "user.block", target: user.login, detail: { via: "web" } })); | |
| 378 | + flash(c, "notice", `You blocked ${user.login}.`); | |
| 379 | + }); | |
| 380 | + }); | |
| 381 | + post(web, "/settings/blocked-users/:login/unblock", async (c, f) => { | |
| 382 | + const me = requireAccount(c); | |
| 383 | + const back = localBack(f.s("back"), "/settings/blocked-users"); | |
| 384 | + return act(c, back, async () => { | |
| 385 | + const user = await c.get("dir").accountByLogin(prm(c, "login")); | |
| 386 | + if (user && (await unblock(c.env, me.id, user.id))) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "user.unblock", target: user.login, detail: { via: "web" } })); | |
| 387 | + flash(c, "notice", `You unblocked ${user?.login ?? prm(c, "login")}.`); | |
| 388 | + }); | |
| 389 | + }); | |
| 390 | + | |
| 391 | + // An organization's blocked users, managed by its owners on its Settings tab. | |
| 392 | + const orgOwner = async (c: AppContext) => { | |
| 393 | + const me = requireAccount(c); | |
| 394 | + const dir = c.get("dir"); | |
| 395 | + const org = await dir.accountByLogin(prm(c, "owner")); | |
| 396 | + if (!org || org.kind !== "org" || org.status === "disabled") throw new HttpError(404, "not found"); | |
| 397 | + if (!me.site_admin && (await dir.orgRole(org.id, me.id)) !== "owner") throw new HttpError(403, "organization owners only"); | |
| 398 | + return { me, org }; | |
| 399 | + }; | |
| 400 | + post(web, "/:owner/-/org/blocks", async (c, f) => { | |
| 401 | + const { me, org } = await orgOwner(c); | |
| 402 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 403 | + const user = await c.get("dir").accountByLogin(f.s("login").trim().replace(/^@/, "")); | |
| 404 | + if (!user || !visibleTo(user, me)) throw new HttpError(404, "no one has that username"); | |
| 405 | + if (await block(c.env, org, user, me)) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "org.block", target: org.login, detail: { user: user.login, via: "web" } })); | |
| 406 | + flash(c, "notice", `${org.login} blocked ${user.login}.`); | |
| 407 | + }); | |
| 408 | + }); | |
| 409 | + post(web, "/:owner/-/org/blocks/:login/unblock", async (c) => { | |
| 410 | + const { me, org } = await orgOwner(c); | |
| 411 | + return act(c, `/${org.login}?tab=settings`, async () => { | |
| 412 | + const user = await c.get("dir").accountByLogin(prm(c, "login")); | |
| 413 | + if (user && (await unblock(c.env, org.id, user.id))) c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "org.unblock", target: org.login, detail: { user: user.login, via: "web" } })); | |
| 414 | + flash(c, "notice", `${org.login} unblocked ${user?.login ?? prm(c, "login")}.`); | |
| 415 | + }); | |
| 416 | + }); | |
| 417 | + | |
| 418 | + // Hide (minimize) a comment, or show it again: people with write access. | |
| 419 | + for (const [path, hide] of [["minimize", true], ["unminimize", false]] as const) { | |
| 420 | + post(web, `/:owner/:repo/comments/:id/${path}`, async (c, f) => { | |
| 421 | + const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "write"); | |
| 422 | + const id = prm(c, "id"); | |
| 423 | + const found = await l.stub.comment(id).catch(() => l.stub.comment(id, true)); | |
| 424 | + const back = `/${l.repo.owner_login}/${l.repo.name}/${found.isPull ? "pull" : "issues"}/${found.number}${found.comment.reviewId ? "/files" : ""}#c-${found.comment.id}`; | |
| 425 | + return act(c, back, async () => { | |
| 426 | + const reason = hide ? minimizeReason(f.s("reason")) : null; | |
| 427 | + await l.stub.minimizeComment(found.comment.id, reason, l.caller); | |
| 428 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: l.caller.id, actorLogin: l.caller.login, action: hide ? "comment.minimize" : "comment.unminimize", target: `${l.repo.owner_login}/${l.repo.name}`, detail: { comment: found.comment.id, number: found.number, ...(reason ? { reason } : {}), via: "web" } })); | |
| 429 | + }, hide ? "Comment hidden." : "Comment shown again."); | |
| 430 | + }); | |
| 431 | + } | |
| 432 | +} | |
| 433 | + | |
| 434 | +/** A person's blocked users, for Settings → Blocked users. */ | |
| 435 | +export const personalBlocks = (c: AppContext, me: Account) => blockList(c.env, me.id); | |
| 436 | + | |
| 437 | +/** For a profile: whether `me` blocked `user`. */ | |
| 438 | +export const hasBlocked = (c: AppContext, me: Account, user: Account): Promise<boolean> => isBlocked(c.env, me.id, [user.id]); |
modifiedapps/diffinery/src/web/routes.tsx+3−0View file
| @@ -21,6 +21,7 @@ | ||
| 21 | 21 | import { registerKumoAssets } from "./kumo.js"; |
| 22 | 22 | import { registerPackages } from "./packages.js"; |
| 23 | 23 | import { registerDocs } from "../docs/web.js"; |
| 24 | +import { registerModeration } from "./moderation.js"; | |
| 24 | 25 | |
| 25 | 26 | /** |
| 26 | 27 | * Server-rendered web UI. Mount at "/" after the API, Git and runner routes: |
| @@ -54,6 +55,8 @@ | ||
| 54 | 55 | registerTerminals(web); |
| 55 | 56 | // Settings → Account and the account pages of site administration: fixed /settings paths, before /:owner. |
| 56 | 57 | registerAccountLifecycle(web); |
| 58 | +// /-/report and /:owner/:repo/comments/:id/...: before the /:owner and /:owner/:repo catch-alls. | |
| 59 | +registerModeration(web); | |
| 57 | 60 | registerAccount(web); |
| 58 | 61 | registerRepoSettings(web); |
| 59 | 62 | registerActions(web); |
modifiedapps/diffinery/src/web/ui.tsx+5−1View file
| @@ -15,6 +15,7 @@ | ||
| 15 | 15 | import { diffLineTokens, fenceHighlighter, langForPath, LIMITS, ready, tokenize, type Lang, type TokLine } from "./highlight.js"; |
| 16 | 16 | import { KSelect, kumoAssets } from "./kumo.js"; |
| 17 | 17 | import { MailStore, type WatchLevel } from "../mail/prefs.js"; |
| 18 | +import { MoreMenu, ReportItem } from "./moderation.js"; | |
| 18 | 19 | import { SeoHead, SITE } from "./seo.js"; |
| 19 | 20 | import { avatarPath } from "../http/avatars.js"; |
| 20 | 21 | import { TURNSTILE_ORIGIN, TURNSTILE_SCRIPT } from "../auth/signup.js"; |
| @@ -323,8 +324,11 @@ | ||
| 323 | 324 | <div class="repohead"> |
| 324 | 325 | <div class="wrap"> |
| 325 | 326 | {watch ? ( |
| 326 | - <div style="float:right"> | |
| 327 | + <div style="float:right" class="row"> | |
| 327 | 328 | <WatchControl base={base} level={watch} t={t} /> |
| 329 | + <MoreMenu label="Repository actions"> | |
| 330 | + <ReportItem s={{ kind: "repository", repository: `${r.repo.owner_login}/${r.repo.name}` }} label="Report repository" /> | |
| 331 | + </MoreMenu> | |
| 328 | 332 | </div> |
| 329 | 333 | ) : null} |
| 330 | 334 | <h1> |
addedapps/diffinery/test/e2e/moderation.test.mjs+300−0View file
| @@ -1,0 +1,300 @@ | ||
| 1 | +// End-to-end checks of moderation (lib/moderation.ts) against a real `wrangler dev` Worker with | |
| 2 | +// seeded accounts, tokens and sessions: | |
| 3 | +// | |
| 4 | +// flock /home/claude/e2e.lock timeout 1500 node --test --test-concurrency=1 --test-timeout=900000 test/e2e/moderation.test.mjs | |
| 5 | +// | |
| 6 | +// Covers: reports (REST and the web form) land in the site administrators' queue and are resolved; | |
| 7 | +// a blocked person can't open issues, comment, react or be added as a collaborator in the blocker's | |
| 8 | +// (or the blocking organization's) repositories but still can everywhere else; a suspended person's | |
| 9 | +// token and session stop at once, their repositories, profile and comments disappear for everyone | |
| 10 | +// but site administrators, and unsuspending restores it all; a minimized comment shows collapsed. | |
| 11 | +import { spawn, execFileSync } from "node:child_process"; | |
| 12 | +import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; | |
| 13 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 14 | +import { tmpdir } from "node:os"; | |
| 15 | +import { join, dirname, resolve } from "node:path"; | |
| 16 | +import { fileURLToPath } from "node:url"; | |
| 17 | +import { createServer } from "node:net"; | |
| 18 | +import { test, before, after } from "node:test"; | |
| 19 | +import assert from "node:assert/strict"; | |
| 20 | + | |
| 21 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 22 | +const work = mkdtempSync(join(tmpdir(), "diffinery-moderation-")); | |
| 23 | +let base; | |
| 24 | +let dev; | |
| 25 | +let devLog = ""; | |
| 26 | + | |
| 27 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 28 | +const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; | |
| 29 | +const T = { olive: tok("olive"), sully: tok("sully"), rita: tok("rita"), mal: tok("mal"), sid: tok("sid"), sam: tok("sam") }; | |
| 30 | +const S = { olive: `olive-${randomBytes(12).toString("hex")}`, sully: `sully-${randomBytes(12).toString("hex")}`, rita: `rita-${randomBytes(12).toString("hex")}`, sid: `sid-${randomBytes(12).toString("hex")}` }; | |
| 31 | + | |
| 32 | +const freePort = () => | |
| 33 | + new Promise((res) => { | |
| 34 | + const s = createServer(); | |
| 35 | + s.listen(0, "127.0.0.1", () => { | |
| 36 | + const { port } = s.address(); | |
| 37 | + s.close(() => res(port)); | |
| 38 | + }); | |
| 39 | + }); | |
| 40 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 41 | + | |
| 42 | +/** Call the REST API as a token, a session cookie (`{ cookie }`) or anonymously (null). */ | |
| 43 | +async function api(method, path, body, as = T.olive) { | |
| 44 | + const headers = { "content-type": "application/json" }; | |
| 45 | + if (typeof as === "string") headers.authorization = `Bearer ${as}`; | |
| 46 | + else if (as?.cookie) headers.cookie = `__Host-dfy_session=${as.cookie}`; | |
| 47 | + const send = () => fetch(`${base}/api/v3${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }); | |
| 48 | + const res = await send().catch((e) => (e.cause?.code === "UND_ERR_SOCKET" ? send() : Promise.reject(e))); | |
| 49 | + const text = await res.text(); | |
| 50 | + let json = null; | |
| 51 | + try { | |
| 52 | + json = text ? JSON.parse(text) : null; | |
| 53 | + } catch { | |
| 54 | + json = text; | |
| 55 | + } | |
| 56 | + return { status: res.status, json }; | |
| 57 | +} | |
| 58 | +async function ok(method, path, body, as) { | |
| 59 | + const r = await api(method, path, body, as); | |
| 60 | + assert.ok(r.status >= 200 && r.status < 300, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 61 | + return r.json; | |
| 62 | +} | |
| 63 | +async function status(expected, method, path, body, as) { | |
| 64 | + const r = await api(method, path, body, as); | |
| 65 | + assert.equal(r.status, expected, `${method} ${path} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 66 | + return r.json; | |
| 67 | +} | |
| 68 | +const b64 = (s) => Buffer.from(s).toString("base64"); | |
| 69 | + | |
| 70 | +/** A web page, signed in with `session` (or anonymously). */ | |
| 71 | +async function page(path, session) { | |
| 72 | + const r = await fetch(`${base}${path}`, { headers: session ? { cookie: `__Host-dfy_session=${session}` } : {}, redirect: "manual" }); | |
| 73 | + return { status: r.status, html: await r.text() }; | |
| 74 | +} | |
| 75 | + | |
| 76 | +/** A web form post signed in with `session`, with the CSRF pair taken from `from`. */ | |
| 77 | +async function submit(session, from, action, fields) { | |
| 78 | + const cookie = `__Host-dfy_session=${session}`; | |
| 79 | + const res = await fetch(`${base}${from}`, { headers: { cookie } }); | |
| 80 | + const csrf = /name="_csrf" value="([^"]+)"/.exec(await res.text())?.[1]; | |
| 81 | + const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; | |
| 82 | + assert.ok(csrf && pair, `no CSRF pair on ${from} (${res.status})`); | |
| 83 | + const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); | |
| 84 | + return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")) }; | |
| 85 | +} | |
| 86 | + | |
| 87 | +before(async () => { | |
| 88 | + const port = await freePort(); | |
| 89 | + base = `http://127.0.0.1:${port}`; | |
| 90 | + const persist = join(work, "state"); | |
| 91 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 92 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 93 | + const at = new Date().toISOString(); | |
| 94 | + const later = new Date(Date.now() + 30 * 86400_000).toISOString(); | |
| 95 | + const account = (id, login, kind) => `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('${id}', '${login}', '${kind}', '${login}', 'active', '${at}')`; | |
| 96 | + const token = (id, account, t) => `INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES ('${id}', '${account}', 'e2e', '${hash(t)}', '${t.slice(0, 10)}', '["admin"]', '${later}', '${at}')`; | |
| 97 | + const session = (account, cookie) => `INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('${hash(cookie)}', '${account}', 'passkey', '${at}', '${at}', '${later}')`; | |
| 98 | + wrangler( | |
| 99 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 100 | + [ | |
| 101 | + ...["olive", "sully", "rita", "mal", "sid", "sam"].map((l) => account(`u-${l}`, l, "user")), | |
| 102 | + "UPDATE accounts SET site_admin = 1 WHERE id = 'u-sully'", | |
| 103 | + ...Object.entries(T).map(([l, t]) => token(`t-${l}`, `u-${l}`, t)), | |
| 104 | + ...Object.entries(S).map(([l, s]) => session(`u-${l}`, s)), | |
| 105 | + account("o-guild", "guild", "org"), | |
| 106 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-guild', 'u-olive', 'owner', '${at}')`, | |
| 107 | + ].join("; "), | |
| 108 | + ); | |
| 109 | + dev = spawn( | |
| 110 | + "pnpm", | |
| 111 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--inspector-port", String(await freePort()), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--var", `MASTER_KEY:${randomBytes(32).toString("base64")}`, "--var", `OIDC_PRIVATE_KEY:${JSON.stringify(generateKeyPairSync("ec", { namedCurve: "P-256" }).privateKey.export({ format: "jwk" }))}`, "--show-interactive-dev-session=false"], | |
| 112 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 113 | + ); | |
| 114 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 115 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 116 | + const end = Date.now() + 120_000; | |
| 117 | + while (Date.now() < end) { | |
| 118 | + try { | |
| 119 | + if ((await fetch(`${base}/healthz`)).ok) return; | |
| 120 | + } catch {} | |
| 121 | + await sleep(300); | |
| 122 | + } | |
| 123 | + assert.fail(`wrangler dev didn't start\n${devLog.slice(-4000)}`); | |
| 124 | +}); | |
| 125 | + | |
| 126 | +after(() => { | |
| 127 | + if (dev?.pid) { | |
| 128 | + try { | |
| 129 | + process.kill(-dev.pid, "SIGTERM"); | |
| 130 | + } catch {} | |
| 131 | + } | |
| 132 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 133 | +}); | |
| 134 | + | |
| 135 | +/** A public repository with one commit, created by `as` (under `owner`, a person or an organization). */ | |
| 136 | +async function publicRepo(owner, name, as) { | |
| 137 | + await ok("POST", owner === "user" ? "/user/repos" : `/orgs/${owner}/repos`, { name, visibility: "public" }, as); | |
| 138 | + const login = owner === "user" ? (await ok("GET", "/user", undefined, as)).login : owner; | |
| 139 | + await ok("PUT", `/repos/${login}/${name}/contents/README.md`, { message: "init", content: b64(`# ${name}\n`) }, as); | |
| 140 | + return `/repos/${login}/${name}`; | |
| 141 | +} | |
| 142 | + | |
| 143 | +/** olive's public repository town with issue #1, guild's hall, sam's park and sid's pub, made once. */ | |
| 144 | +let made; | |
| 145 | +const setup = () => | |
| 146 | + (made ??= (async () => { | |
| 147 | + const town = await publicRepo("user", "town", T.olive); | |
| 148 | + const hall = await publicRepo("guild", "hall", T.olive); | |
| 149 | + const park = await publicRepo("user", "park", T.sam); | |
| 150 | + const pub = await publicRepo("user", "pub", T.sid); | |
| 151 | + const welcome = await ok("POST", `${town}/issues`, { title: "Welcome" }); | |
| 152 | + return { town, hall, park, pub, welcome }; | |
| 153 | + })()); | |
| 154 | + | |
| 155 | +test("moderation: reports land in the site administrators' queue", async () => { | |
| 156 | + const { welcome } = await setup(); | |
| 157 | + const filed = await status(201, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "spam", body: "looks like an ad" }, T.rita); | |
| 158 | + assert.equal(filed.target, `olive/town#${welcome.number}`); | |
| 159 | + assert.equal(filed.state, "open"); | |
| 160 | + assert.match(filed.html_url, /\/olive\/town\/issues\/1$/); | |
| 161 | + // A reason from the list, a bounded note, one open report per thing. | |
| 162 | + await status(422, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "boring" }, T.rita); | |
| 163 | + await status(422, "POST", "/diffinery/reports", { kind: "user", login: "mal", reason: "spam", body: "x".repeat(2001) }, T.rita); | |
| 164 | + await status(422, "POST", "/diffinery/reports", { kind: "issue", repository: "olive/town", number: welcome.number, reason: "spam" }, T.rita); | |
| 165 | + await status(401, "POST", "/diffinery/reports", { kind: "user", login: "mal", reason: "spam" }, null); | |
| 166 | + // The web form files one too. | |
| 167 | + const form = await submit(S.rita, "/-/report?kind=user&login=mal", "/-/report?kind=user&login=mal", { reason: "harassment", body: "keeps pinging me" }); | |
| 168 | + assert.ok([302, 303].includes(form.status), String(form.status)); | |
| 169 | + assert.match(form.flash, /Thanks for reporting it/); | |
| 170 | + | |
| 171 | + await status(403, "GET", "/admin/reports", undefined, T.rita); | |
| 172 | + const queue = await ok("GET", "/admin/reports", undefined, T.sully); | |
| 173 | + assert.deepEqual(queue.map((r) => [r.kind, r.target, r.reason, r.reporter]).sort(), [ | |
| 174 | + ["issue", "olive/town#1", "spam", "rita"], | |
| 175 | + ["user", "mal", "harassment", "rita"], | |
| 176 | + ]); | |
| 177 | + const adminPage = await page("/settings/admin/reports", S.sully); | |
| 178 | + assert.equal(adminPage.status, 200); | |
| 179 | + assert.match(adminPage.html, /olive\/town#1/); | |
| 180 | + assert.match(adminPage.html, /keeps pinging me|harassment|Harassment/); | |
| 181 | + assert.notEqual((await page("/settings/admin/reports", S.rita)).status, 200); | |
| 182 | + const done = await ok("PATCH", `/admin/reports/${filed.id}`, { state: "resolved", resolution: "spoke to olive" }, T.sully); | |
| 183 | + assert.equal(done.state, "resolved"); | |
| 184 | + assert.equal(done.resolution, "spoke to olive"); | |
| 185 | + assert.deepEqual((await ok("GET", "/admin/reports", undefined, T.sully)).map((r) => r.target), ["mal"]); | |
| 186 | + | |
| 187 | +}); | |
| 188 | + | |
| 189 | +test("moderation: a blocked person can't take part in the blocker's repositories, and can elsewhere", async () => { | |
| 190 | + const { town, hall, park, welcome } = await setup(); | |
| 191 | + await collaborator(town, "mal", T.mal); | |
| 192 | + const before = await ok("POST", `${town}/issues/${welcome.number}/comments`, { body: "hi from mal" }, T.mal); | |
| 193 | + await status(204, "PUT", "/user/blocks/mal"); | |
| 194 | + await status(204, "GET", "/user/blocks/mal"); | |
| 195 | + assert.deepEqual((await ok("GET", "/user/blocks")).map((u) => u.login), ["mal"]); | |
| 196 | + await status(404, "GET", "/user/blocks/rita"); | |
| 197 | + await status(422, "PUT", "/user/blocks/olive"); | |
| 198 | + // Blocking removed mal as a collaborator, and they can't be added back. | |
| 199 | + assert.ok(!(await ok("GET", `${town}/collaborators`)).some((u) => u.login === "mal")); | |
| 200 | + await status(422, "PUT", `${town}/collaborators/mal`, { permission: "write" }); | |
| 201 | + | |
| 202 | + const refused = await status(403, "POST", `${town}/issues`, { title: "let me in" }, T.mal); | |
| 203 | + assert.match(refused.message, /olive blocked you/); | |
| 204 | + await status(403, "POST", `${town}/issues/${welcome.number}/comments`, { body: "again" }, T.mal); | |
| 205 | + await status(403, "POST", `${town}/issues/${welcome.number}/reactions`, { content: "+1" }, T.mal); | |
| 206 | + await status(403, "POST", `${town}/pulls`, { title: "x", head: "main", base: "main" }, T.mal); | |
| 207 | + // Reading stays public, and what they wrote before stays. | |
| 208 | + await ok("GET", `${town}/issues/${welcome.number}`, undefined, T.mal); | |
| 209 | + assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === before.id)); | |
| 210 | + // Everywhere else mal takes part as before: sam's repository, and a pull request where mal can write. | |
| 211 | + const elsewhere = await status(201, "POST", `${park}/issues`, { title: "mal's issue" }, T.mal); | |
| 212 | + await status(201, "POST", `${park}/issues/${elsewhere.number}/comments`, { body: "and a comment" }, T.mal); | |
| 213 | + await collaborator(park, "mal", T.mal, T.sam); | |
| 214 | + await ok("POST", `${park}/git/refs`, { ref: "refs/heads/mal-change", sha: (await ok("GET", `${park}/git/ref/heads/main`, undefined, T.sam)).object.sha }, T.mal); | |
| 215 | + await ok("PUT", `${park}/contents/NOTES.md`, { message: "notes", content: b64("notes\n"), branch: "mal-change" }, T.mal); | |
| 216 | + await status(201, "POST", `${park}/pulls`, { title: "mal's change", head: "mal-change", base: "main" }, T.mal); | |
| 217 | + | |
| 218 | + // An organization blocks through its owners, with the same effect in its repositories. | |
| 219 | + await status(201, "POST", `${hall}/issues`, { title: "before the block" }, T.mal); | |
| 220 | + await status(404, "PUT", "/orgs/guild/blocks/mal", undefined, T.rita); | |
| 221 | + await status(204, "PUT", "/orgs/guild/blocks/mal"); | |
| 222 | + await status(204, "GET", "/orgs/guild/blocks/mal"); | |
| 223 | + assert.match((await status(403, "POST", `${hall}/issues`, { title: "after the block" }, T.mal)).message, /guild blocked you/); | |
| 224 | + // Unblocking lets them back in. | |
| 225 | + await status(204, "DELETE", "/user/blocks/mal"); | |
| 226 | + await status(204, "DELETE", "/orgs/guild/blocks/mal"); | |
| 227 | + await status(201, "POST", `${town}/issues`, { title: "thanks" }, T.mal); | |
| 228 | + await status(201, "POST", `${hall}/issues`, { title: "thanks" }, T.mal); | |
| 229 | + | |
| 230 | +}); | |
| 231 | + | |
| 232 | +test("moderation: a suspended person is stopped and hidden at once, and unsuspending restores them", async () => { | |
| 233 | + const { town, park, pub, welcome } = await setup(); | |
| 234 | + const sidIssue = await status(201, "POST", `${town}/issues`, { title: "sid was here" }, T.sid); | |
| 235 | + const sidComment = await status(201, "POST", `${town}/issues/${welcome.number}/comments`, { body: "sid's comment" }, T.sid); | |
| 236 | + await ok("GET", "/user", undefined, T.sid); | |
| 237 | + await ok("GET", pub, undefined, T.olive); | |
| 238 | + assert.equal((await page("/sid", S.olive)).status, 200); | |
| 239 | + | |
| 240 | + await status(403, "PUT", "/users/sid/suspended", { reason: "spam" }, T.olive); | |
| 241 | + await status(422, "PUT", "/users/sid/suspended", {}, T.sully); | |
| 242 | + await status(204, "PUT", "/users/sid/suspended", { reason: "spam campaign" }, T.sully); | |
| 243 | + await status(401, "GET", "/user", undefined, T.sid); | |
| 244 | + await status(401, "GET", "/user", undefined, { cookie: S.sid }); | |
| 245 | + await status(401, "POST", `${park}/issues`, { title: "still here?" }, T.sid); | |
| 246 | + // Their repository, profile, issues and comments are gone for everyone else... | |
| 247 | + await status(404, "GET", pub, undefined, T.olive); | |
| 248 | + await status(404, "GET", pub, undefined, null); | |
| 249 | + assert.equal((await page("/sid/pub", S.olive)).status, 404); | |
| 250 | + assert.equal((await page("/sid", S.olive)).status, 404); | |
| 251 | + await status(404, "GET", "/users/sid", undefined, T.olive); | |
| 252 | + await status(404, "GET", `${town}/issues/${sidIssue.number}`, undefined, T.olive); | |
| 253 | + assert.ok(!(await ok("GET", `${town}/issues?state=all`)).some((i) => i.number === sidIssue.number)); | |
| 254 | + assert.ok(!(await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === sidComment.id)); | |
| 255 | + assert.doesNotMatch((await page(`/olive/town/issues/${welcome.number}`, S.olive)).html, /sid's comment|sid's comment/); | |
| 256 | + // ...but not for site administrators, who see why. | |
| 257 | + await ok("GET", pub, undefined, T.sully); | |
| 258 | + const shown = await ok("GET", "/users/sid", undefined, T.sully); | |
| 259 | + assert.equal(shown.suspension_reason, "spam campaign"); | |
| 260 | + assert.ok(shown.suspended_at); | |
| 261 | + assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`, undefined, T.sully)).some((c) => c.id === sidComment.id)); | |
| 262 | + | |
| 263 | + // Unsuspending restores everything, tokens included. | |
| 264 | + await status(204, "DELETE", "/users/sid/suspended", undefined, T.sully); | |
| 265 | + assert.equal((await ok("GET", "/user", undefined, T.sid)).login, "sid"); | |
| 266 | + await ok("GET", pub, undefined, null); | |
| 267 | + assert.equal((await page("/sid", S.olive)).status, 200); | |
| 268 | + await ok("GET", `${town}/issues/${sidIssue.number}`); | |
| 269 | + assert.ok((await ok("GET", `${town}/issues/${welcome.number}/comments`)).some((c) => c.id === sidComment.id)); | |
| 270 | + assert.equal((await ok("GET", "/users/sid", undefined, T.sully)).suspended_at, null); | |
| 271 | + | |
| 272 | +}); | |
| 273 | + | |
| 274 | +test("moderation: a minimized comment shows collapsed", async () => { | |
| 275 | + const { town, welcome } = await setup(); | |
| 276 | + const spam = await status(201, "POST", `${town}/issues/${welcome.number}/comments`, { body: "BUY CHEAP WATCHES" }, T.rita); | |
| 277 | + // Only people who can write to the repository hide comments. | |
| 278 | + await status(403, "PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { reason: "spam" }, T.rita); | |
| 279 | + await status(422, "PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { reason: "rude" }); | |
| 280 | + assert.deepEqual(await ok("PUT", `/repos/olive/town/diffinery/comments/${spam.id}/minimized`, { classifier: "SPAM" }), { is_minimized: true, minimized_reason: "spam" }); | |
| 281 | + const issuePage = await page(`/olive/town/issues/${welcome.number}`, S.rita); | |
| 282 | + assert.match(issuePage.html, /<details class="minimized" data-minimized="spam">/); | |
| 283 | + assert.match(issuePage.html, /This comment was marked as spam\./); | |
| 284 | + // Collapsed, not deleted: the body is still there behind "Show comment". | |
| 285 | + assert.match(issuePage.html, /BUY CHEAP WATCHES/); | |
| 286 | + // From the web too: unhide, then hide as off-topic. | |
| 287 | + const issuePath = `/olive/town/issues/${welcome.number}`; | |
| 288 | + assert.ok([302, 303].includes((await submit(S.olive, issuePath, `/olive/town/comments/${spam.id}/unminimize`, {})).status)); | |
| 289 | + assert.doesNotMatch((await page(issuePath, S.rita)).html, /data-minimized=/); | |
| 290 | + assert.ok([302, 303].includes((await submit(S.olive, issuePath, `/olive/town/comments/${spam.id}/minimize`, { reason: "off_topic" })).status)); | |
| 291 | + assert.match((await page(issuePath, S.rita)).html, /This comment was marked as off-topic\./); | |
| 292 | +}); | |
| 293 | + | |
| 294 | +/** Make `login` a collaborator on `repo` with write access, accepting the invitation as them. */ | |
| 295 | +async function collaborator(repo, login, invitee, as = T.olive) { | |
| 296 | + const r = await api("PUT", `${repo}/collaborators/${login}`, { permission: "write" }, as); | |
| 297 | + if (r.status === 204) return; | |
| 298 | + assert.equal(r.status, 201, `PUT ${repo}/collaborators/${login} -> ${r.status} ${JSON.stringify(r.json)}`); | |
| 299 | + await status(204, "PATCH", `/user/repository_invitations/${r.json.id}`, undefined, invitee); | |
| 300 | +} |
addedapps/diffinery/test/unit/moderation.test.ts+206−0View file
| @@ -1,0 +1,206 @@ | ||
| 1 | +import { describe, expect, it } from "vitest"; | |
| 2 | +import { | |
| 3 | + block, | |
| 4 | + blockersOf, | |
| 5 | + blockList, | |
| 6 | + blockSubjects, | |
| 7 | + cleanReport, | |
| 8 | + cleanResolution, | |
| 9 | + createReport, | |
| 10 | + isBlocked, | |
| 11 | + MAX_REPORTS_PER_DAY, | |
| 12 | + minimizedNote, | |
| 13 | + minimizeReason, | |
| 14 | + openReportCount, | |
| 15 | + reports, | |
| 16 | + setReportState, | |
| 17 | + suspend, | |
| 18 | + suspendReason, | |
| 19 | + unblock, | |
| 20 | + unsuspend, | |
| 21 | + visibleTo, | |
| 22 | +} from "../../src/lib/moderation.js"; | |
| 23 | +import { Directory, type Account } from "../../src/lib/directory.js"; | |
| 24 | +import { blockedBy, killSwitches } from "../../src/lib/killswitch.js"; | |
| 25 | +import { sha256 } from "../../src/lib/util.js"; | |
| 26 | +import type { Env } from "../../src/env.js"; | |
| 27 | +import { testD1 } from "./stubs/d1.js"; | |
| 28 | + | |
| 29 | +const at = "2026-10-01T00:00:00.000Z"; | |
| 30 | +const later = "2099-01-01T00:00:00.000Z"; | |
| 31 | + | |
| 32 | +/** A D1 with ann (site administrator), bob, cat and bob's agent bot, the organization acme (ann owns it, cat is a member), and repositories acme/app and bob/tool; REPO answers stopActions. */ | |
| 33 | +function setup() { | |
| 34 | + const DB = testD1(); | |
| 35 | + const add = (id: string, kind: string, siteAdmin = 0, owner: string | null = null) => | |
| 36 | + DB.sqlite.prepare("INSERT INTO accounts (id, login, kind, status, site_admin, owner_id, created_at) VALUES (?, ?, ?, 'active', ?, ?, ?)").run(id, id, kind, siteAdmin, owner, at); | |
| 37 | + add("ann", "user", 1); | |
| 38 | + add("bob", "user"); | |
| 39 | + add("cat", "user"); | |
| 40 | + add("bot", "agent", 0, "bob"); | |
| 41 | + add("acme", "org"); | |
| 42 | + DB.sqlite.prepare("INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('acme', 'ann', 'owner', ?), ('acme', 'cat', 'member', ?)").run(at, at); | |
| 43 | + const repo = (id: string, owner: string) => DB.sqlite.prepare("INSERT INTO repos (id, owner_id, name, description, visibility, default_branch, created_at, updated_at) VALUES (?, ?, ?, '', 'public', 'main', ?, ?)").run(id, owner, id, at, at); | |
| 44 | + repo("app", "acme"); | |
| 45 | + repo("tool", "bob"); | |
| 46 | + const stops: string[] = []; | |
| 47 | + const REPO = { idFromName: (n: string) => n, get: (id: string) => ({ stopActions: async () => (stops.push(id), { runs: 1, jobs: 1, sandboxes: 0 }) }) }; | |
| 48 | + const audits: Array<{ action: string; target?: string; detail?: unknown }> = []; | |
| 49 | + const audit = async (e: { action: string; target?: string; detail?: unknown }) => void audits.push(e); | |
| 50 | + const env = { DB, REPO } as unknown as Env; | |
| 51 | + const acct = async (id: string) => (await new Directory(env).account(id))!; | |
| 52 | + return { env, DB, stops, audits, audit, acct }; | |
| 53 | +} | |
| 54 | + | |
| 55 | +const target = { kind: "issue" as const, target: "acme/app#1", url: "https://x/acme/app/issues/1", accountId: "bob", repoId: "app", excerpt: "buy now" }; | |
| 56 | + | |
| 57 | +describe("reports", () => { | |
| 58 | + it("take a known reason and a bounded note, and need a note for 'something else'", () => { | |
| 59 | + expect(cleanReport({ reason: " Spam ", body: " lots of links\r\n" })).toEqual({ reason: "spam", body: "lots of links" }); | |
| 60 | + expect(cleanReport({ reason: "harassment" })).toEqual({ reason: "harassment", body: "" }); | |
| 61 | + expect(() => cleanReport({ reason: "boring" })).toThrow(/reason must be one of/); | |
| 62 | + expect(() => cleanReport({ reason: "other", body: " " })).toThrow(/say what is wrong/); | |
| 63 | + expect(() => cleanReport({ reason: "spam", body: "x".repeat(2001) })).toThrow(/at most 2000/); | |
| 64 | + expect(() => cleanReport({ reason: "spam", body: 3 })).toThrow(/must be a string/); | |
| 65 | + expect(() => cleanResolution(" ")).toThrow(/write what you did/); | |
| 66 | + expect(cleanResolution(" removed it ")).toBe("removed it"); | |
| 67 | + }); | |
| 68 | + | |
| 69 | + it("land in the queue once per thing, are capped per day, and are resolved and reopened", async () => { | |
| 70 | + const { env, acct } = setup(); | |
| 71 | + const cat = await acct("cat"); | |
| 72 | + const r = await createReport(env, cat, target, { reason: "spam", body: "ads" }); | |
| 73 | + expect(r).toMatchObject({ reporter_login: "cat", account_login: "bob", kind: "issue", target: "acme/app#1", excerpt: "buy now", state: "open" }); | |
| 74 | + await expect(createReport(env, cat, target, { reason: "spam", body: "" })).rejects.toThrow(/already reported/); | |
| 75 | + expect(await openReportCount(env)).toBe(1); | |
| 76 | + expect((await reports(env, "open")).items.map((x) => x.id)).toEqual([r.id]); | |
| 77 | + | |
| 78 | + const done = await setReportState(env, r.id, { login: "ann" }, "resolved", "deleted the issue"); | |
| 79 | + expect(done).toMatchObject({ state: "resolved", resolved_by: "ann", resolution: "deleted the issue" }); | |
| 80 | + expect((await reports(env, "open")).total).toBe(0); | |
| 81 | + expect((await reports(env, "resolved")).total).toBe(1); | |
| 82 | + // Resolved, the same thing can be reported again. | |
| 83 | + await createReport(env, cat, target, { reason: "spam", body: "" }); | |
| 84 | + expect((await setReportState(env, r.id, { login: "ann" }, "open", null))?.resolution).toBeNull(); | |
| 85 | + expect((await reports(env, "all")).total).toBe(2); | |
| 86 | + expect(await setReportState(env, "nope", { login: "ann" }, "resolved", "x")).toBeNull(); | |
| 87 | + | |
| 88 | + for (let i = 2; i < MAX_REPORTS_PER_DAY; i++) await createReport(env, cat, { ...target, target: `acme/app#${i + 10}` }, { reason: "spam", body: "" }); | |
| 89 | + await expect(createReport(env, cat, { ...target, target: "acme/app#99" }, { reason: "spam", body: "" })).rejects.toThrow(/at most 20 reports/); | |
| 90 | + }); | |
| 91 | +}); | |
| 92 | + | |
| 93 | +describe("blocks", () => { | |
| 94 | + it("cover an agent through its owner", () => { | |
| 95 | + expect(blockSubjects({ id: "bot", kind: "agent", owner_id: "bob" })).toEqual(["bot", "bob"]); | |
| 96 | + expect(blockSubjects({ id: "bob", kind: "user", owner_id: null })).toEqual(["bob"]); | |
| 97 | + }); | |
| 98 | + | |
| 99 | + it("end the blocked person's collaboration and pending invitations, and are lifted by unblocking", async () => { | |
| 100 | + const { env, DB, acct } = setup(); | |
| 101 | + DB.sqlite.prepare("INSERT INTO repo_access (repo_id, account_id, role) VALUES ('app', 'bob', 'write'), ('app', 'bot', 'write'), ('tool', 'cat', 'write')").run(); | |
| 102 | + const invite = (id: string, by: string, who: string | null, org: string | null, repo: string | null) => | |
| 103 | + DB.sqlite.prepare("INSERT INTO invites (id, org_id, repo_id, account_id, role, token_hash, invited_by, created_at, expires_at) VALUES (?, ?, ?, ?, 'member', ?, ?, ?, ?)").run(id, org, repo, who, id, by, at, later); | |
| 104 | + invite("i1", "ann", "bob", "acme", null); | |
| 105 | + invite("i2", "ann", "bob", null, "app"); | |
| 106 | + invite("i3", "cat", "bob", null, null); | |
| 107 | + const acme = await acct("acme"); | |
| 108 | + const ann = await acct("ann"); | |
| 109 | + const bob = await acct("bob"); | |
| 110 | + | |
| 111 | + expect(await block(env, acme, bob, ann)).toBe(true); | |
| 112 | + expect(await block(env, acme, bob, ann)).toBe(false); | |
| 113 | + expect(await isBlocked(env, "acme", ["bob"])).toBe(true); | |
| 114 | + expect(await isBlocked(env, "acme", blockSubjects(await acct("bot")))).toBe(true); | |
| 115 | + expect(await isBlocked(env, "bob", ["acme"])).toBe(false); | |
| 116 | + expect([...(await blockersOf(env, ["bob"]))]).toEqual(["acme"]); | |
| 117 | + expect((await blockList(env, "acme")).map((b) => [b.account.login, b.createdBy])).toEqual([["bob", "ann"]]); | |
| 118 | + expect(DB.sqlite.prepare("SELECT repo_id, account_id FROM repo_access ORDER BY repo_id").all()).toEqual([{ repo_id: "tool", account_id: "cat" }]); | |
| 119 | + expect(DB.sqlite.prepare("SELECT id FROM invites").all()).toEqual([{ id: "i3" }]); | |
| 120 | + | |
| 121 | + expect(await unblock(env, "acme", "bob")).toBe(true); | |
| 122 | + expect(await unblock(env, "acme", "bob")).toBe(false); | |
| 123 | + expect(await isBlocked(env, "acme", ["bob"])).toBe(false); | |
| 124 | + }); | |
| 125 | + | |
| 126 | + it("can't block yourself, an organization's own member, or anything but a person", async () => { | |
| 127 | + const { env, acct } = setup(); | |
| 128 | + const [ann, bob, cat, acme, bot] = await Promise.all(["ann", "bob", "cat", "acme", "bot"].map(acct)); | |
| 129 | + await expect(block(env, bob, bob, bob)).rejects.toThrow(/yourself/); | |
| 130 | + await expect(block(env, acme, cat, ann)).rejects.toThrow(/member of acme/); | |
| 131 | + await expect(block(env, bob, acme, bob)).rejects.toThrow(/only people/); | |
| 132 | + await expect(block(env, bob, bot!, bob)).rejects.toThrow(/only people/); | |
| 133 | + // Blocking a person stops them being added back as a collaborator or invited. | |
| 134 | + await block(env, bob, cat, bob); | |
| 135 | + const tool = (await new Directory(env).repo("bob", "tool"))!; | |
| 136 | + await expect(new Directory(env).grantRepoAccess(tool, bob!, cat!, "write")).rejects.toThrow(/bob blocked cat/); | |
| 137 | + // Nor their agents. | |
| 138 | + await block(env, acme!, bob!, ann!); | |
| 139 | + const app = (await new Directory(env).repo("acme", "app"))!; | |
| 140 | + await expect(new Directory(env).grantRepoAccess(app, ann!, bot!, "write")).rejects.toThrow(/acme blocked bot's owner/); | |
| 141 | + }); | |
| 142 | +}); | |
| 143 | + | |
| 144 | +describe("suspension", () => { | |
| 145 | + it("needs a reason, and hides a suspended person's content from all but site administrators", () => { | |
| 146 | + expect(suspendReason(" spam bot ")).toBe("spam bot"); | |
| 147 | + expect(() => suspendReason("")).toThrow(/give a reason/); | |
| 148 | + expect(() => suspendReason("x".repeat(501))).toThrow(/at most 500/); | |
| 149 | + expect(visibleTo({ suspended_at: null }, null)).toBe(true); | |
| 150 | + expect(visibleTo({ suspended_at: at }, null)).toBe(false); | |
| 151 | + expect(visibleTo({ suspended_at: at }, { site_admin: 0 } as Account)).toBe(false); | |
| 152 | + expect(visibleTo({ suspended_at: at }, { site_admin: 1 } as Account)).toBe(true); | |
| 153 | + }); | |
| 154 | + | |
| 155 | + it("refuses tokens and sessions at once, stops Actions, and unsuspending restores the tokens", async () => { | |
| 156 | + const { env, DB, stops, audits, audit, acct } = setup(); | |
| 157 | + const token = "dfy_test_token"; | |
| 158 | + const agentToken = "dfy_agent_token"; | |
| 159 | + for (const [id, owner, t] of [["t1", "bob", token], ["t2", "bot", agentToken]]) { | |
| 160 | + DB.sqlite.prepare("INSERT INTO tokens (id, account_id, name, token_hash, prefix, scopes, expires_at, created_at) VALUES (?, ?, 'x', ?, 'dfy', '[\"repo\"]', ?, ?)").run(id!, owner!, await sha256(t!), later, at); | |
| 161 | + } | |
| 162 | + DB.sqlite.prepare("INSERT INTO sessions (id, account_id, method, created_at, last_seen_at, expires_at) VALUES ('s1', 'bob', 'passkey', ?, ?, ?)").run(at, at, later); | |
| 163 | + const dir = new Directory(env); | |
| 164 | + const ann = await acct("ann"); | |
| 165 | + expect((await dir.verifyToken(token))?.account.login).toBe("bob"); | |
| 166 | + | |
| 167 | + const stopped = await suspend(env, audit, ann, await acct("bob"), "spam", "api"); | |
| 168 | + expect(stops).toEqual(["tool"]); | |
| 169 | + expect(stopped).toMatchObject({ repositories: 1 }); | |
| 170 | + expect(await dir.verifyToken(token)).toBeNull(); | |
| 171 | + expect(await dir.verifyToken(agentToken)).toBeNull(); | |
| 172 | + expect(DB.sqlite.prepare("SELECT count(*) AS n FROM sessions").get()).toEqual({ n: 0 }); | |
| 173 | + const bob = await acct("bob"); | |
| 174 | + expect(bob).toMatchObject({ status: "disabled", suspended_by: "ann", suspended_reason: "spam" }); | |
| 175 | + expect(blockedBy(await killSwitches(env, "tool"), "run")).toMatch(/spam/); | |
| 176 | + expect(audits[0]).toMatchObject({ action: "user.suspend", target: "bob", detail: { reason: "spam", via: "api" } }); | |
| 177 | + | |
| 178 | + expect(await unsuspend(env, audit, ann, bob, "web")).toBe(true); | |
| 179 | + expect(await unsuspend(env, audit, ann, await acct("bob"), "web")).toBe(false); | |
| 180 | + expect(await acct("bob")).toMatchObject({ status: "active", suspended_at: null }); | |
| 181 | + expect((await dir.verifyToken(token))?.account.login).toBe("bob"); | |
| 182 | + expect((await dir.verifyToken(agentToken))?.account.login).toBe("bot"); | |
| 183 | + expect(blockedBy(await killSwitches(env, "tool"), "run")).toBeNull(); | |
| 184 | + expect(audits[1]).toEqual({ actorId: "ann", actorLogin: "ann", action: "user.unsuspend", target: "bob", detail: { via: "web" } }); | |
| 185 | + expect(audits).toHaveLength(2); | |
| 186 | + }); | |
| 187 | + | |
| 188 | + it("can't suspend a site administrator, yourself, or an organization", async () => { | |
| 189 | + const { env, audit, acct } = setup(); | |
| 190 | + const [ann, bob, acme] = await Promise.all(["ann", "bob", "acme"].map(acct)); | |
| 191 | + await expect(suspend(env, audit, bob!, ann!, "x", "api")).rejects.toThrow(/site administrator/); | |
| 192 | + await expect(suspend(env, audit, ann!, ann!, "x", "api")).rejects.toThrow(/yourself/); | |
| 193 | + await expect(suspend(env, audit, ann!, acme!, "x", "api")).rejects.toThrow(/only people/); | |
| 194 | + }); | |
| 195 | +}); | |
| 196 | + | |
| 197 | +describe("hidden comments", () => { | |
| 198 | + it("take GitHub's classifiers and say why they are hidden", () => { | |
| 199 | + expect(minimizeReason("OFF_TOPIC")).toBe("off_topic"); | |
| 200 | + expect(minimizeReason("off-topic")).toBe("off_topic"); | |
| 201 | + expect(minimizeReason("spam")).toBe("spam"); | |
| 202 | + expect(() => minimizeReason("rude")).toThrow(/reason must be one of/); | |
| 203 | + expect(minimizedNote("duplicate")).toBe("This comment was marked as duplicate."); | |
| 204 | + expect(minimizedNote(null)).toBe("This comment has been minimized."); | |
| 205 | + }); | |
| 206 | +}); |
modifiedapps/diffinery/test/unit/rate-limit.test.ts+2−0View file
| @@ -50,6 +50,8 @@ | ||
| 50 | 50 | ["POST", "/acme/app/issues"], |
| 51 | 51 | ["POST", "/acme/app/issues/3/comment"], |
| 52 | 52 | ["POST", "/acme/app/pull/4/review"], |
| 53 | + ["POST", "/api/v3/diffinery/reports"], | |
| 54 | + ["POST", "/-/report"], | |
| 53 | 55 | ]) expect(bucketsFor(m!, path!, person, "x"), path).toEqual([["RL_ACCOUNT", "acct:u1"], ["RL_CREATE", "acct:u1"]]); |
| 54 | 56 | expect(bucketsFor("PATCH", "/api/v3/repos/acme/app/issues/3", person, "x")).toEqual([["RL_ACCOUNT", "acct:u1"]]); |
| 55 | 57 | }); |