R6 moderation: roles, silence and suspension, bans that write nothing on the personas, deletions, report actions
Roles (user, moderator, administrator) from the CLI and /clientapi/admin/roots/{id}/role, never one's own nor the last
administrator. Silence keeps an account's posts out of public timelines, hashtags, trends, search and relays while its
followers still get them; suspension refuses and revokes a local persona's tokens, answers 404 for its posts and
collections, holds back every delivery and tells other servers nothing new, its actor unchanged; a suspended remote
account's activities are dropped and nothing goes to its own inbox (a shared one still serves its server). A banned
login's personas are held back in memory alone (Restrictions), so nothing pairs them in stored data. Personas and groups
are deleted one at a time (PersonaRemoval, GroupRemoval), by their login or an administrator with the password. Reports
resolve with an action and a note; posts are taken down, a remote copy hidden. Warnings reach the persona as Mastodon's
moderation_warning. Every staff act goes to the staff's log. Migration _020 limits posts from servers already silenced;
a restore keeps the moderation made since the backup.
Verified: interop.sh moderation gts mastodon with SecureMode, 194 passed; restore.sh, 25 passed, now with a suspension,
a silence and a takedown made after the backup.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
70d1ffe9a2
commit
9f68ca3ed0
71 files changed
+2229
-219
No files matched your search
@@ -83,14 +83,15 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
|
||||
Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar,
|
||||
Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports,
|
||||
the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the
|
||||
server's settings, thumbnail, announcements, relays)
|
||||
accounts and posts moderated, the staff's log), Admin (logins: roles, bans, deletions,
|
||||
two-factor resets), DomainBlock, Statistics, Backup, InstanceAdmin (the server's
|
||||
settings, thumbnail, announcements, relays)
|
||||
Infrastructure/
|
||||
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
|
||||
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
|
||||
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts)
|
||||
Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root|reset-totp`,
|
||||
Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`,
|
||||
`media audit`, `backup`, `backups`, `restore`)
|
||||
Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore
|
||||
Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater
|
||||
@@ -120,7 +121,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags;
|
||||
Document: the server's own texts)
|
||||
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings
|
||||
Moderation/ ModerationLog (the staff's log, AdminAction)
|
||||
Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts
|
||||
removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again)
|
||||
Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
|
||||
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
|
||||
Privacy/ VisibilityPolicy (IsPublic expression, CanSee)
|
||||
@@ -140,7 +142,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0
|
||||
provenance; the stubs are `StubsController` in `SearchController.cs`
|
||||
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
|
||||
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent)
|
||||
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, …
|
||||
Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval,
|
||||
PersonaRemoval and GroupRemoval (deleting a login, a persona, a group), …
|
||||
Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
|
||||
ServerSettings, AdminAction
|
||||
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher
|
||||
@@ -188,6 +191,7 @@ made (signing up as "admin" grants nothing):
|
||||
cd /var/www/privapub.thepra.dev
|
||||
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote <root>
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin role <root> <user|moderator|admin> # promote/demote are admin/user
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona <persona> <root> # its link moves, its Mastodon tokens end
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root <root> # as the client deletes a login: its personas go too
|
||||
ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp <root> # a login that lost its authenticator and codes
|
||||
@@ -195,8 +199,9 @@ ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp <root> # a logi
|
||||
|
||||
The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through
|
||||
group www-data and reaches the private mongod; `sudo -u www-data` works too. Every one of them that changes a login or a
|
||||
persona (promote, demote, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as
|
||||
the command line's (`cli`).
|
||||
persona (promote, demote, role, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's
|
||||
log as the command line's (`cli`). The command line may leave the server without an administrator (it says so); the
|
||||
client's role route never does.
|
||||
|
||||
## The server's own settings (completion program R5)
|
||||
|
||||
@@ -262,13 +267,85 @@ the command line's (`cli`).
|
||||
by their creation (their dates are spread on purpose).
|
||||
- **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action,
|
||||
the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas
|
||||
(an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It
|
||||
(an act on a persona the acting root owns, or since R6 on a post of it, a group it owns or a report about it, is kept
|
||||
without who acted; a root's entry names none of its personas). It
|
||||
keeps domain blocks, backups (create, delete, restore), settings saves (the fields changed, never their values),
|
||||
rules, the thumbnail, announcements, relays, report resolutions and the CLI's acts. `GET
|
||||
rules, the thumbnail, announcements, relays, report resolutions, the CLI's acts and, since R6, every act of moderation
|
||||
(below). `GET
|
||||
/clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it
|
||||
with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was
|
||||
the command line. Writing never fails the act it records.
|
||||
|
||||
## Moderation (completion program R6)
|
||||
|
||||
- **Roles.** A login is a user, a moderator (also a user) or an administrator (all three): `RootUser.SetRole` writes its
|
||||
`Policies` (`SetAdmin` is a wrapper), and JwtEvents reads them from the database on every request. Moderators handle
|
||||
reports, silence and suspend personas and accounts elsewhere, and remove posts; never logins (roles, bans, deletions,
|
||||
two-factor), settings, emoji, relays, backups or statistics, which are the administrators'.
|
||||
- **What an act does** (`Domain/Moderation/AccountModeration.cs`, following the `AuthorGone` pattern): the account keeps
|
||||
when it was silenced or suspended (`Avatar`/`ForeignAvatar.SilencedAt`, `SuspendedAt`, `AccountState`, the staff's
|
||||
`ModerationNote`), and its posts carry `Post.AuthorLimited` or `Post.AuthorSuspended`, set on what it already wrote
|
||||
and on what arrives or is written later (`RemotePosts.Build`, `AnnounceHandler`, `StatusService`, archive imports), so
|
||||
a query needs no join. `Restrictions` (static, in memory, reloaded after every act and when five minutes old) holds
|
||||
the suspended personas, the silenced ones, and the suspended accounts elsewhere with their own inboxes.
|
||||
- **Silenced** (a persona, an account elsewhere, or a server: a domain block of `Silence` limits every post of its
|
||||
accounts and subdomains, on arrival and when the block is made or lifted, `AccountModeration.DomainChanged`):
|
||||
`VisibilityPolicy.IsListed` keeps its posts out of the public and hashtag timelines and streams, trends, followed
|
||||
hashtags, status search (but what the reader met itself), the `/tags` page and the relays; its account out of the
|
||||
directory, trends and search by a part of its name (its exact name still finds it). Its followers still get it, and
|
||||
anyone may still open it; the Mastodon account says `limited`.
|
||||
- **Suspended, a persona** (owner decision 2026-10-17, **tells other servers nothing new**): its Mastodon tokens are
|
||||
revoked and refused (`MastodonController`, 401; `TokenController` and `/oauth/authorize` give it none), its streams
|
||||
close, `/clientapi` acts as it nowhere (posts, follows, groups, profile edits, archive imports; its archive may still
|
||||
be exported), it delivers nothing (`DeliveryService.HeldBack`, at enqueueing and again at sending; the one exception
|
||||
is the Delete of itself when it is deleted), it takes no follower (`FollowHandler` leaves the request unanswered),
|
||||
its scheduled posts wait (up to a month), `IsShown` hides its posts from everyone, the Mastodon mapper shows its
|
||||
account to nobody (404 for the account, its statuses, lookup), and `PeasantsController` answers 404 for everything
|
||||
of it but its actor and its inbox, signed or not; the actor, WebFinger and the server's counts stay exactly as they
|
||||
were. Unsuspending gives everything back but the revoked tokens. **It is warned** with Mastodon's
|
||||
`moderation_warning` notification (`Notifications.Warn`: from itself, as Mastodon's are, `Notification.WarningAction`
|
||||
and the moderator's `Warning` text), which it reads once it can sign in again; meanwhile its login sees it
|
||||
`Suspended` in `/clientapi/avatar/private/list`. Silencing warns it too, and a removed post warns its author
|
||||
(`delete_statuses`).
|
||||
- **Suspended, an account elsewhere:** its posts are hidden as above, `InboxProcessor` drops what it sends (but a
|
||||
`Delete` or an `Undo`, which only take away what we hold), and nothing is addressed to it or delivered to its own
|
||||
inbox (its server's shared inbox still serves the others there). It is told nothing.
|
||||
- **A banned login** (`/clientapi/admin/ban/users`): its sessions end and `Restrictions` holds its personas back as
|
||||
suspended ones (`Counted.HeldBackPersonas`, which is `BarredPersonas` plus the suspended personas), **without writing
|
||||
anything on any of them**: flagging every persona of a login at once would pair them in stored data a moderator or a
|
||||
backup reads. Where posts are read one at a time `VisibilityPolicy.Shown` asks `Restrictions` (`Barred`); lists leave
|
||||
them to the Mastodon mapper, which shows no account of theirs and so none of their posts. Unbanning gives them back.
|
||||
- **A post removed:** a post here is deleted as its author deletes it (`StatusService.Remove`: emptied, a Delete to its
|
||||
audience, unless its author is suspended); a copy of a post elsewhere gets `Post.ModeratedAt`, hidden everywhere by
|
||||
`IsShown` and never taken in again (its address stays known).
|
||||
- **Deleting** a persona (`Services/PersonaRemoval.cs`) or a group (`Services/GroupRemoval.cs`) deletes everything
|
||||
public it had, as `RootRemoval` always did for a login's personas (and now does through PersonaRemoval): its
|
||||
tokens end, a persona's groups go with it, a Delete of the actor goes to its followers, the accounts it follows and
|
||||
a group's members elsewhere, its posts are emptied, its name stays reserved and its documents answer 410.
|
||||
- **Routes**, every one kept in the staff's log (`persona.silence|unsilence|suspend|unsuspend|delete`,
|
||||
`account.silence|unsilence|suspend|unsuspend`, `post.remove`, `report.resolve` with its action in the note,
|
||||
`group.delete`, `root.role|ban|unban|delete|reset_totp`); an entry about a persona of the acting login, a post of it, a
|
||||
group it owns or a report about it is kept without who acted:
|
||||
- moderators (`ModeratorController`): `GET /clientapi/moderator/accounts?q=` (a name, `user@host` or an actor's address:
|
||||
personas here, never their logins, and accounts elsewhere), `GET /accounts/moderated?offset=&limit=`,
|
||||
`GET /accounts/{id}` (`ViewModeratedAccount`, `ViewModeratedAccountsPage`), `POST /accounts/moderate`
|
||||
(`ModerateAccountForm`: `silence`, `unsilence`, `suspend`, `unsuspend`, a `Note` for the staff, a `Warning` for the
|
||||
persona), `POST /posts/remove` (`RemovePostForm`), `POST /reports/{id}/resolve` (an optional `ResolveReportForm`:
|
||||
`none`, `silence`, `suspend` or `remove_posts`, the act first, so one that fails leaves the report open; kept as
|
||||
`Report.Resolution` and `ResolutionNote`, which `ViewReport` shows);
|
||||
- administrators (`AdminController`, `Authorize(IsAdmin)`): `GET /clientapi/admin/roots?q=&before=&limit=` and
|
||||
`/roots/{id}` (`ViewAdminRoot`: the one place a login is shown with its personas and the groups they own; `q` matches
|
||||
a login's name or a persona's), `POST /roots/{id}/role` (`RootRoleForm`; never one's own, 409, and never the last
|
||||
administrator: a demotion that leaves none is undone, 409), `POST /roots/{id}/totp/reset` (as `admin reset-totp`;
|
||||
never one's own), `POST /personas/{id}/delete` and `/groups/{id}/delete` (`AdminPasswordForm`: the administrator's own
|
||||
password, 403 when wrong), and the older `/ban/users`, `/unban/users`, `DELETE /remove/users`, now logged;
|
||||
- the login itself: `POST /clientapi/avatar/private/delete` (`DeletePersonaForm`, its password) and the group's owner
|
||||
`POST /clientapi/group/delete` (`DeleteGroupForm`, the login's password). Neither is a staff act, so neither is logged.
|
||||
- **Data:** migration `_020` and every restore run `ModerationFlags.Recompute` (the posts' flags and the accounts'
|
||||
`AccountState` from their moderation and the silenced servers); `ProtectiveMerge` keeps P's silences and suspensions,
|
||||
here and elsewhere, and the posts taken down, before it does; indexes on `SilencedAt` and `SuspendedAt` of both account
|
||||
collections and a partial one on `Post.ModeratedAt`. No new collection, so backups hold it all as they did.
|
||||
|
||||
## Federation invariants
|
||||
|
||||
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only
|
||||
@@ -341,13 +418,17 @@ the command line's (`cli`).
|
||||
11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one
|
||||
answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance
|
||||
`status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too);
|
||||
users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a
|
||||
users exclude personas of banned or deleted roots (a suspended persona still counts, and its posts: a count tells
|
||||
nothing new of it); `replies_count` counts only public and unlisted replies; a
|
||||
remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count
|
||||
(`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
|
||||
true).
|
||||
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and
|
||||
any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account
|
||||
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision).
|
||||
any other read of stored posts filters by `IsShown`. All three hide deleted posts, the posts of a remote account
|
||||
that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision), of a suspended author
|
||||
(`AuthorSuspended`) and those the staff took down (`ModeratedAt`); a post read one at a time (`Shown`) is also hidden
|
||||
when its persona's login is banned (`Restrictions`). Where strangers browse (public and hashtag timelines, trends,
|
||||
search, relays) `IsListed` leaves out a silenced author's posts (`AuthorLimited`, R6).
|
||||
13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or
|
||||
mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is
|
||||
fetched as context. Followers-only is detected by the author's stored `followers` URL.
|
||||
@@ -379,7 +460,9 @@ the command line's (`cli`).
|
||||
deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
|
||||
`RootSessions` cover it.
|
||||
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme).
|
||||
Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`).
|
||||
Every `/api` request re-checks that the persona's root is neither banned nor deleted and the persona neither deleted
|
||||
nor suspended (`MastodonController`); a persona's token that can no longer act answers 401 everywhere, as a revoked
|
||||
one does on Mastodon (R6).
|
||||
3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read
|
||||
from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long.
|
||||
4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`.
|
||||
@@ -511,7 +594,12 @@ the command line's (`cli`).
|
||||
- **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete`
|
||||
with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members
|
||||
and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer
|
||||
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name.
|
||||
410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. One persona
|
||||
or one group is deleted the same way (`PersonaRemoval`, `GroupRemoval`, R6), by its login or owner with the password,
|
||||
or by an administrator with theirs.
|
||||
- **Moderation never pairs a login with its personas** (R6): a ban is held in memory (`Restrictions`) and never written
|
||||
on the personas or their posts; moderators see personas and accounts, never logins (`ViewModeratedAccount`), and only
|
||||
administrators read a login next to its personas (`ViewAdminRoot`); the staff's log follows R5's rule.
|
||||
- **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
|
||||
equal to the login.
|
||||
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before
|
||||
@@ -650,7 +738,9 @@ the command line's (`cli`).
|
||||
4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks,
|
||||
mutes, domain blocks, being blocked, reserved names, `DeletedObject`, reports, filters and OAuth applications are
|
||||
the union, P's row winning; roots, personas, groups, posts and remote accounts deleted since are deleted again, with
|
||||
what their deletion takes away; P's moderation of a persona and a root's password, e-mail, ban and policies stay;
|
||||
what their deletion takes away; P's moderation of a persona or an account elsewhere (silenced, suspended), the posts
|
||||
the staff took down, and a root's password, e-mail, ban and policies stay, and every post's moderation flags are
|
||||
worked out again (`ModerationFlags`);
|
||||
roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410,
|
||||
media made or trashed since go to the trash (but the server's own pictures, `ServerUse`); the staff's log
|
||||
(`AdminAction`) is the union, so a restore never loses an entry, its own request included, and the server's settings
|
||||
@@ -885,6 +975,21 @@ tools/pasture/run.sh down # removes e
|
||||
beside the configured actor), accepted and removed without ending the configured subscription; the activity once
|
||||
published; the staff's log listing it all without an address. It saves the settings it found at the start back at the
|
||||
end (the description's and the policy's dates then say today).
|
||||
- **Moderation (`scenarios/moderation.sh`, needs gts and mastodon; after instance in batch A, whose log check expects
|
||||
the administrator's acts alone):** a second login made a moderator by `create-root` and the role route (its own role
|
||||
refused, 409; a moderator refused the logins, 403; two-factor reset); a silenced persona's post out of the public,
|
||||
local and hashtag timelines and search, still in a follower's home here and on Mastodon, its account `limited`, the
|
||||
persona warned; a suspended persona's token 401, its account, statuses, post and lookup 404, its actor 200 and its
|
||||
outbox and post 404 to a signed GET (Mastodon's `Request` through `rails runner`, since SecureMode answers unsigned
|
||||
ones 401), OAuth no longer offering it, Mastodon's copy of the account untouched, then back whole (a new token) and
|
||||
warned; gtsuser suspended: its post and account gone here, what it sends meanwhile dropped (Mongo), a DM to it never
|
||||
sent, then its earlier post back and new ones arriving; gts.test silenced through a domain block and back; a banned
|
||||
login's personas 401 and 404, a Mastodon follow left waiting (`follow_requests`), nothing written on either persona,
|
||||
back once unbanned; a persona deleted by the administrator (password checked, moderators refused) gone on Mastodon and
|
||||
GoToSocial, one deleted by its own login, groups deleted by their owner and by the administrator (410); posts removed
|
||||
directly and through a report gone on Mastodon, the author warned, a GoToSocial copy taken down here; the staff's log
|
||||
listing every act, the report's action in its note, no login entry naming a persona, none shown to the moderator, and
|
||||
no address.
|
||||
- **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json`
|
||||
sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to
|
||||
`https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
|
||||
|
||||
Reference in new issue
Block a user