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:
thepraandClaude Opus 5.5 committed 2026-10-17 05:23:45 +02:00
1 parent 70d1ffe9a2
commit 9f68ca3ed0
71 files changed
+2228 -218

No files matched your search

+120 -15
View File
@@ -83,14 +83,15 @@ PrivaPub/ ASP.NET Core Web API, net10.0
Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS) Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS)
Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar, Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar,
Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports, Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports,
the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the accounts and posts moderated, the staff's log), Admin (logins: roles, bans, deletions,
server's settings, thumbnail, announcements, relays) two-factor resets), DomainBlock, Statistics, Backup, InstanceAdmin (the server's
settings, thumbnail, announcements, relays)
Infrastructure/ Infrastructure/
Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out
Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker
Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts) 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 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`) `media audit`, `backup`, `backups`, `restore`)
Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore
Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater 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; Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags;
Document: the server's own texts) Document: the server's own texts)
Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings 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 Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements
Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService
Privacy/ VisibilityPolicy (IsPublic expression, CanSee) 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` provenance; the stubs are `StubsController` in `SearchController.cs`
Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex); Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex);
OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent) 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, Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup,
ServerSettings, AdminAction ServerSettings, AdminAction
StaticServices/ DbEntities (Find<T> accessors), AuthTokenManager (JWT), PasswordHasher 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 cd /var/www/privapub.thepra.dev
echo '<password>' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root <login> --admin # password on stdin 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 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 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 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 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 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 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 persona (promote, demote, role, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's
the command line's (`cli`). 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) ## 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). 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 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 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), 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 /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 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. 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 ## Federation invariants
1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only 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 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 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); `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 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 (`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always
true). true).
12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and 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 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). 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 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 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. 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 deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and
`RootSessions` cover it. `RootSessions` cover it.
2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme). 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 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. 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`. 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` - **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 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 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 - **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one
equal to the login. equal to the login.
- **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before - **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, 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 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 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, 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 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 (`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 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 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). 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` - **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 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 `https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the
+25
View File
@@ -380,6 +380,31 @@ persona's public post outside any group, its edit and its deletion also go to th
only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of only with an LD signature or an FEP-8b32 proof: PrivaPub's carry a proof, so they reach Mastodon through both kinds of
relay (checked live). relay (checked live).
## Moderation
What this server's staff does to an account tells other servers nothing new (owner decision 2026-10-17): no `Flag`,
`Block`, `Update` or `Delete` is sent for a silence or a suspension, and none is undone when it is lifted.
- **A persona silenced here** stays out of this server's public and hashtag timelines, trends, directory and search,
and its posts no longer go to the relays. Everything else is as before: its actor, its collections, and every
delivery to its followers and to the accounts it addresses.
- **A persona suspended here** (by the staff, or with its login banned) delivers nothing, takes no new follower (a
`Follow` is left unanswered, as one waiting for a locked account's approval), and its outbox, followers, following,
featured collections, posts, activities, replies, contexts, wall and quote permissions answer 404, signed or not. Its
actor document, its inbox (202, as ever) and WebFinger stay exactly as they were, so lifting the suspension serves
everything again without telling anyone it happened. A post of it removed meanwhile is deleted here only. The one thing
it may still send is the `Delete` of itself, if it is deleted.
- **An account elsewhere suspended here:** what it sends is dropped after the 202, but a `Delete` or an `Undo`; nothing
is addressed to it, and nothing goes to its own inbox (its server's shared inbox still serves the others there).
Silenced, its posts stay out of the public timelines here. Its server hears of neither.
- **A server silenced** (a domain block of `silence`) has its accounts' posts kept out of the public timelines here, as a
silenced account's are; a suspended one is refused at the inbox, as before.
- **A post removed** by the staff: one of ours is deleted as its author would delete it (a `Delete` to its audience); a
copy of one from elsewhere is hidden here and never taken in again, and its server is not told.
- **A persona or a group deleted** (by its login, its owner or an administrator): a `Delete` of the actor to its
followers, the accounts it follows and a group's members elsewhere; its documents answer 410 from then on, and its name
is never given again.
## Server descriptions and the crawler ## Server descriptions and the crawler
PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server). PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server).
+159
View File
@@ -0,0 +1,159 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.Admin
{
// The staff's acts on accounts and posts (completion program R6). Moderators silence and suspend personas here and
// accounts elsewhere, remove posts and resolve reports with one of those acts; administrators also change logins
// (roles, bans, deletions, two-factor sign-in). Every act is kept in the staff's log.
public static class ModerationActions
{
public const string None = "none";
public const string Silence = "silence";
public const string Unsilence = "unsilence";
public const string Suspend = "suspend";
public const string Unsuspend = "unsuspend";
public const string RemovePosts = "remove_posts";//a report's resolution only: the posts it names
public static bool IsAccountAction(string action) => action is Silence or Unsilence or Suspend or Unsuspend;
public static bool IsResolution(string action) => action is None or Silence or Suspend or RemovePosts;
}
// POST /clientapi/moderator/accounts/moderate: a persona here (its id) or an account elsewhere (its id here)
public class ModerateAccountForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AccountId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Action { get; set; }//ModerationActions: silence, unsilence, suspend, unsuspend
// the staff's own words: kept in the log and on the account, never shown to it
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
// what a persona here is told with its warning (Mastodon's moderation_warning); nothing reaches an account elsewhere
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// POST /clientapi/moderator/posts/remove: a post here is deleted as its author would (the deletion federates), a copy of
// a post elsewhere is taken down here only
public class RemovePostForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(64, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string PostId { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// POST /clientapi/moderator/reports/{id}/resolve: the body is optional; without one, or with action none, the report is
// only marked resolved
public class ResolveReportForm
{
[StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Action { get; set; } = ModerationActions.None;//none, silence, suspend, remove_posts
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Warning { get; set; }
}
// an account as moderators see it: a persona here (never its login) or an account elsewhere, and what the staff did to it
public class ViewModeratedAccount
{
public string Id { get; set; }
public bool IsLocal { get; set; } = false;
public string UserName { get; set; }
public string DisplayName { get; set; }
public string Handle { get; set; }//user@host
public string Url { get; set; }//its actor's id
public string Domain { get; set; }
public DateTime? SilencedAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public bool DomainSilenced { get; set; } = false;//an account elsewhere whose server is silenced: limited without its own silence
public bool DomainSuspended { get; set; } = false;
public string Note { get; set; }//the staff's last note on it
}
// GET /clientapi/moderator/accounts?q= and /clientapi/moderator/accounts/moderated?offset=&limit=: Next is the offset of
// the next page, null on the last
public class ViewModeratedAccountsPage
{
public List<ViewModeratedAccount> Items { get; set; } = [];
public int? Next { get; set; }
}
// a login as administrators see it (GET /clientapi/admin/roots), with its personas: only administrators ever read a login
// next to its personas
public class ViewAdminRoot
{
public string Id { get; set; }
public string UserName { get; set; }
public string Role { get; set; }//user, moderator, admin
public bool IsBanned { get; set; } = false;
public bool HasEmail { get; set; } = false;
public bool IsEmailValidated { get; set; } = false;
public bool HasTotp { get; set; } = false;
public bool IsYou { get; set; } = false;
public DateTime CreatedAt { get; set; }
public List<ViewAdminPersona> Personas { get; set; } = [];
}
public class ViewAdminPersona
{
public string Id { get; set; }
public string UserName { get; set; }
public string DisplayName { get; set; }
public string Handle { get; set; }
public DateTime? SilencedAt { get; set; }
public DateTime? SuspendedAt { get; set; }
public List<ViewAdminGroup> Groups { get; set; } = [];//the groups it owns
}
public class ViewAdminGroup
{
public string Id { get; set; }
public string UserName { get; set; }
public string Name { get; set; }
public bool IsCommunity { get; set; } = false;
public int MembersCount { get; set; } = 0;
}
// newest first; Next is the `before` of the next page, null on the last
public class ViewAdminRootsPage
{
public List<ViewAdminRoot> Items { get; set; } = [];
public string Next { get; set; }
}
// POST /clientapi/admin/roots/{id}/role
public class RootRoleForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
StringLength(16, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Role { get; set; }//user, moderator, admin
}
// what can't be undone asks for the administrator's own password: deleting a persona or a group
public class AdminPasswordForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
[StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))]
public string Note { get; set; }
}
}
@@ -16,6 +16,8 @@ namespace PrivaPub.ClientModels.Admin
public string Comment { get; set; } public string Comment { get; set; }
public bool Forwarded { get; set; } public bool Forwarded { get; set; }
public bool IsResolved { get; set; } public bool IsResolved { get; set; }
public string Resolution { get; set; }//what resolving it did: none, silence, suspend, remove_posts (null before R6)
public string ResolutionNote { get; set; }
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it
@@ -17,6 +17,19 @@ namespace PrivaPub.ClientModels.Group
public string InvitationPassword { get; set; } public string InvitationPassword { get; set; }
} }
// the group's owner deletes it (POST /clientapi/group/delete), with the login's password: its members are told, and its
// name stays taken
public class DeleteGroupForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
public string GroupId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
public class GroupMembershipForm public class GroupMembershipForm
{ {
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))] [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))]
@@ -0,0 +1,19 @@
using PrivaPub.ClientModels.Resources;
using System.ComponentModel.DataAnnotations;
namespace PrivaPub.ClientModels.User
{
// A login deletes one of its own personas (POST /clientapi/avatar/private/delete), and with it the groups the persona
// owns, with its password again, as it deletes itself.
public class DeletePersonaForm
{
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "AvatarId", ResourceType = typeof(FieldsNameResource))]
public string AvatarId { get; set; }
[Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
Display(Name = "Password", ResourceType = typeof(FieldsNameResource))]
public string Password { get; set; }
}
}
@@ -82,7 +82,8 @@ namespace PrivaPub.Api.Mastodon.Auth
var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token); var link = await _dbEntities.RootToAvatars.Match(r => r.AvatarId == avatarId).ExecuteFirstAsync(token);
var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); var root = link == default ? default : await _dbEntities.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token); var avatar = await _dbEntities.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null }; // a suspended persona gets no token (R6), as its tokens were revoked when it was suspended
return root is { IsBanned: false, DeletedAt: null } && avatar is { DeletionAt: null, SuspendedAt: null };
} }
ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string> ForbidResult Refuse(string error, string description) => Forbid(new AuthenticationProperties(new Dictionary<string, string>
@@ -158,11 +158,12 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase))
{ {
var local = await _localActors.FindByUserName(parts[0], token); var local = await _localActors.FindByUserName(parts[0], token);
return local is { IsFederated: true, IsCircle: false, IsServerActor: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError(); return local is { IsFederated: true, IsCircle: false, IsServerActor: false, Suspended: false } ? Json(await _mapper.Local(local, false, token)) : NotFoundError();
} }
var userName = parts[0]; var userName = parts[0];
var domain = parts[1].ToLowerInvariant(); var domain = parts[1].ToLowerInvariant();
var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue).ExecuteFirstAsync(token); var foreign = await _dbEntities.ForeignAvatars.Match(f => f.UserName == userName && f.Domain == domain && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue)
.ExecuteFirstAsync(token);
return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign)); return foreign == default ? NotFoundError() : Json(_mapper.Foreign(foreign));
} }
@@ -203,7 +204,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Statuses(string id, CancellationToken token) public async Task<IActionResult> Statuses(string id, CancellationToken token)
{ {
var (local, remote) = await Find(id, token); var (local, remote) = await Find(id, token);
if (local == default && remote == default) // a suspended account's posts answer 404, as its account does (R6)
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
return NotFoundError(); return NotFoundError();
if (Params.Bool("pinned") == true) if (Params.Bool("pinned") == true)
{ {
@@ -292,7 +294,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Follow(string id, CancellationToken token) public async Task<IActionResult> Follow(string id, CancellationToken token)
{ {
var (local, remote) = await Find(id, token); var (local, remote) = await Find(id, token);
if (local == default && remote == default) // nobody follows a suspended account (R6), here or elsewhere: it is not shown
if (local == default && remote == default || local is { Suspended: true } || remote is { SuspendedAt: not null })
return NotFoundError(); return NotFoundError();
// a feed is read through the server, whose reader follows its communities; the persona follows nothing // a feed is read through the server, whose reader follows its communities; the persona follows nothing
if (remote is { AvatarType: AvatarType.Feed }) if (remote is { AvatarType: AvatarType.Feed })
@@ -65,7 +65,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
if (string.IsNullOrEmpty(id)) if (string.IsNullOrEmpty(id))
return default; return default;
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default || (await Counted.BarredPersonas(token)).Contains(id)) if (avatar == default || (await Counted.HeldBackPersonas(token)).Contains(id))
return default; return default;
return await _mapper.Local(_localActors.FromAvatar(avatar), false, token); return await _mapper.Local(_localActors.FromAvatar(avatar), false, token);
} }
@@ -93,7 +93,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
// named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only // named in, and public posts of authors who let themselves be indexed; newest first, what the persona may see only
async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token) async Task<List<Status>> Words(string q, int offset, int limit, CancellationToken token)
{ {
var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => !p.DeletedAt.HasValue && p.ReblogOfPostId == null && !p.AuthorGone) var candidates = await _dbEntities.Posts.Match(f => f.Text(q)).Match(p => p.ReblogOfPostId == null).Match(VisibilityPolicy.IsShown)
.Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token); .Sort(p => p.ID, MongoDB.Entities.Order.Descending).Limit(WordCandidates).ExecuteAsync(token);
if (candidates.Count == 0) if (candidates.Count == 0)
return new List<Status>(); return new List<Status>();
@@ -128,7 +128,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
{ {
var author = post.AuthorAccountId ?? post.GroupUserId; var author = post.AuthorAccountId ?? post.GroupUserId;
var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId) var reachable = author == MyId && !post.IsFederatedCopy || mine.Contains(post.ID) || post.Mentions.Any(m => m.AccountId == MyId)
|| post.Visibility == PostVisibility.Public && Searchable(post, author); || post.Visibility == PostVisibility.Public && !post.AuthorLimited && Searchable(post, author);//a silenced author's only to who met it (R6)
if (reachable && await VisibilityPolicy.CanSee(post, MyId, token)) if (reachable && await VisibilityPolicy.CanSee(post, MyId, token))
found.Add(post); found.Add(post);
if (found.Count >= offset + limit) if (found.Count >= offset + limit)
@@ -159,6 +159,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
continue; continue;
await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload }); await Send(new JsonObject { ["stream"] = new JsonArray(key.Wire.Select(w => (JsonNode)w).ToArray()), ["event"] = e.Name, ["payload"] = payload });
} }
// the queue ends only when the stream was closed from here (the persona was suspended): the socket goes too
done.Cancel();
await reading; await reading;
} }
catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) catch (Exception ex) when (ex is OperationCanceledException or WebSocketException)
@@ -202,20 +204,23 @@ namespace PrivaPub.Api.Mastodon.Controllers
try try
{ {
await Write(":)\n\n"); await Write(":)\n\n");
using var done = CancellationTokenSource.CreateLinkedTokenSource(token);
var beating = Task.Run(async () => var beating = Task.Run(async () =>
{ {
while (!token.IsCancellationRequested) while (!done.IsCancellationRequested)
{ {
await Task.Delay(KeepAlive, token); await Task.Delay(KeepAlive, done.Token);
await Write(":thump\n\n"); await Write(":thump\n\n");
} }
}, token); }, done.Token);
await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token)) await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token))
{ {
if (await Payload(e, token) is not { } payload) if (await Payload(e, token) is not { } payload)
continue; continue;
await Write($"event: {e.Name}\ndata: {payload}\n\n"); await Write($"event: {e.Name}\ndata: {payload}\n\n");
} }
// closed from here (the persona was suspended): the response ends
done.Cancel();
await beating; await beating;
} }
catch (OperationCanceledException) catch (OperationCanceledException)
@@ -44,6 +44,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var since = today.AddDays(-6); var since = today.AddDays(-6);
var posts = await dbEntities.Posts var posts = await dbEntities.Posts
.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since) .Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && p.CreationDate >= since)
.Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsPublic).Match(PrivaPub.Domain.Privacy.VisibilityPolicy.IsListed)
.Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId)) .Project(p => p.Include(x => x.CreationDate).Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
.ExecuteAsync(token); .ExecuteAsync(token);
return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day => return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day =>
@@ -54,7 +54,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] [HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public async Task<IActionResult> Public(CancellationToken token) public async Task<IActionResult> Public(CancellationToken token)
{ {
var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic); var query = _dbEntities.Posts.Match(p => p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
.Match(VisibilityPolicy.IsListed);
if (Params.Bool("local") == true) if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy); query.Match(p => !p.IsFederatedCopy);
if (Params.Bool("remote") == true) if (Params.Bool("remote") == true)
@@ -88,7 +89,8 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> Tag(string hashtag, CancellationToken token) public async Task<IActionResult> Tag(string hashtag, CancellationToken token)
{ {
var tag = hashtag.TrimStart('#').ToLowerInvariant(); var tag = hashtag.TrimStart('#').ToLowerInvariant();
var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic); var query = _dbEntities.Posts.Match(p => p.Tags.Contains(tag) && p.Visibility == PostVisibility.Public && p.ReblogOfPostId == null).Match(VisibilityPolicy.IsPublic)
.Match(VisibilityPolicy.IsListed);
if (Params.Bool("local") == true) if (Params.Bool("local") == true)
query.Match(p => !p.IsFederatedCopy); query.Match(p => !p.IsFederatedCopy);
return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token);
@@ -90,30 +90,30 @@ namespace PrivaPub.Api.Mastodon.Controllers
var offset = Math.Max(0, Params.Int("offset") ?? 0); var offset = Math.Max(0, Params.Int("offset") ?? 0);
var limit = Limit(40, 80); var limit = Limit(40, 80);
var localOnly = Params.Bool("local") == true; var localOnly = Params.Bool("local") == true;
var barred = await Counted.BarredPersonas(token); var barred = await Counted.HeldBackPersonas(token);
var wanted = offset + limit; var wanted = offset + limit;
var ordered = new List<string>(); var ordered = new List<string>();
if (Params.Get("order") == "new") if (Params.Get("order") == "new")
{ {
// many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way // many share a day: the first `wanted` are read with every other of the last one's day, so each page is cut the same way
var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) var personas = await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
.Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token); .Sort(a => a.PublishedOn, Order.Descending).Limit(wanted).ExecuteAsync(token);
if (personas.Count == wanted) if (personas.Count == wanted)
{ {
var edge = personas[^1].PublishedOn; var edge = personas[^1].PublishedOn;
var known = personas.Select(a => a.ID).ToList(); var known = personas.Select(a => a.ID).ToList();
personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) personas.AddRange(await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue
&& a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token)); && a.PublishedOn == edge && !known.Contains(a.ID)).ExecuteAsync(token));
} }
var remote = localOnly var remote = localOnly
? new List<ForeignAvatar>() ? new List<ForeignAvatar>()
: await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && f.Published != null) : await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue && f.Published != null)
.Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token); .Sort(f => f.Published, Order.Descending).Limit(wanted).ExecuteAsync(token);
if (remote.Count == wanted) if (remote.Count == wanted)
{ {
var edge = remote[^1].Published; var edge = remote[^1].Published;
var known = remote.Select(f => f.ID).ToList(); var known = remote.Select(f => f.ID).ToList();
remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue remote.AddRange(await _dbEntities.ForeignAvatars.Match(f => f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue
&& f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token)); && f.Published == edge && !known.Contains(f.ID)).ExecuteAsync(token));
} }
ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName))) ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName)))
@@ -123,7 +123,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
} }
else else
{ {
var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) var discoverableLocal = (await _dbEntities.Avatars.Match(a => a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID) && !a.SilencedAt.HasValue)
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(); .Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet();
var seen = new HashSet<string>(); var seen = new HashSet<string>();
var maxId = default(string); var maxId = default(string);
@@ -144,7 +144,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList(); var remoteIds = fresh.Where(a => a.IsFederatedCopy).Select(a => a.Id).ToList();
var discoverableRemote = remoteIds.Count == 0 var discoverableRemote = remoteIds.Count == 0
? new HashSet<string>() ? new HashSet<string>()
: (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue) : (await _dbEntities.ForeignAvatars.Match(f => remoteIds.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet(); .Project(f => f.Include(x => x.ID)).ExecuteAsync(token)).Select(f => f.ID).ToHashSet();
ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id)); ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id));
} }
@@ -28,6 +28,7 @@
public List<CustomEmojiEntity> Emojis { get; set; } = new(); public List<CustomEmojiEntity> Emojis { get; set; } = new();
public List<object> Roles { get; set; } = new(); public List<object> Roles { get; set; } = new();
public bool? Memorial { get; set; } public bool? Memorial { get; set; }
public bool? Limited { get; set; }//silenced by this server's staff, or its server silenced (Mastodon's `limited`)
public string AvatarDescription { get; set; } = string.Empty; public string AvatarDescription { get; set; } = string.Empty;
public string HeaderDescription { get; set; } = string.Empty; public string HeaderDescription { get; set; } = string.Empty;
public List<Field> Fields { get; set; } = new(); public List<Field> Fields { get; set; } = new();
@@ -53,7 +53,15 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes"); context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes");
return; return;
} }
Me = await Resolve(User.GetClaim(Claims.Subject), HttpContext.RequestAborted); var subject = User.GetClaim(Claims.Subject);
Me = await Resolve(subject, HttpContext.RequestAborted);
// a persona's token whose persona can no longer act (suspended, its login banned or deleted) is refused
// everywhere, as Mastodon refuses a revoked token; an application's own token has no persona
if (Me == default && !string.IsNullOrEmpty(subject) && !subject.StartsWith("app:", StringComparison.Ordinal))
{
context.Result = Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
return;
}
} }
if (scope?.RequiresUser == true && Me == default) if (scope?.RequiresUser == true && Me == default)
{ {
@@ -75,7 +83,10 @@ namespace PrivaPub.Api.Mastodon.Infrastructure
var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token);
if (root is not { IsBanned: false, DeletedAt: null }) if (root is not { IsBanned: false, DeletedAt: null })
return default; return default;
return await services.GetRequiredService<ILocalActorService>().FindById(LocalActorKind.Person, avatarId, token); var avatar = await db.Avatars.MatchID(avatarId).ExecuteFirstAsync(token);
if (avatar is not { DeletionAt: null, SuspendedAt: null })
return default;
return services.GetRequiredService<ILocalActorService>().FromAvatar(avatar) is { Suspended: false } me ? me : default;
} }
protected JsonResult Json(object value, int status = StatusCodes.Status200OK) => protected JsonResult Json(object value, int status = StatusCodes.Status200OK) =>
@@ -64,22 +64,25 @@ namespace PrivaPub.Api.Mastodon.Mappers
var actorUri = await _remoteActors.ResolveHandle(q, token); var actorUri = await _remoteActors.ResolveHandle(q, token);
known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token); known = actorUri == default ? default : await _remoteActors.GetActor(actorUri, refresh: false, token);
} }
if (known is { DeletionAt: null } && offset == 0) if (known is { DeletionAt: null, SuspendedAt: null } && offset == 0)
results.Add(_mapper.Foreign(known)); results.Add(_mapper.Foreign(known));
return results; return results;
} }
var prefix = parts[0].ToLowerInvariant(); var prefix = parts[0].ToLowerInvariant();
var barred = await Domain.Privacy.Counted.BarredPersonas(token); var barred = await Domain.Privacy.Counted.HeldBackPersonas(token);
var locals = await _dbEntities.Avatars var locals = await _dbEntities.Avatars
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID)) // a silenced persona is found only by its exact name (R6), as its followers look for it
.Match(a => a.UserName.StartsWith(prefix) && !a.DeletionAt.HasValue && a.Settings.IsDiscoverable != false && !barred.Contains(a.ID)
&& (!a.SilencedAt.HasValue || a.UserName == prefix))
.Sort(a => a.UserName, MongoDB.Entities.Order.Ascending) .Sort(a => a.UserName, MongoDB.Entities.Order.Ascending)
.Limit(offset + limit).ExecuteAsync(token); .Limit(offset + limit).ExecuteAsync(token);
foreach (var avatar in locals.Skip(offset)) foreach (var avatar in locals.Skip(offset))
results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token)); results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token));
var skipRemote = Math.Max(0, offset - locals.Count); var skipRemote = Math.Max(0, offset - locals.Count);
if (results.Count < limit) if (results.Count < limit)
foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue) foreach (var foreign in await _dbEntities.ForeignAvatars.Match(f => f.UserName.StartsWith(parts[0]) && f.IsDiscoverable && !f.DeletionAt.HasValue
&& !f.SuspendedAt.HasValue && !f.SilencedAt.HasValue)
.Sort(f => f.UserName, MongoDB.Entities.Order.Ascending) .Sort(f => f.UserName, MongoDB.Entities.Order.Ascending)
.Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token)) .Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token))
results.Add(_mapper.Foreign(foreign)); results.Add(_mapper.Foreign(foreign));
@@ -28,12 +28,15 @@ namespace PrivaPub.Api.Mastodon.Mappers
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors; readonly ILocalActorService _localActors;
readonly IMediaProxy _proxy; readonly IMediaProxy _proxy;
readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks;
public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default) public MastodonMapper(DbEntities dbEntities, ILocalActorService localActors, IMediaProxy proxy = default,
PrivaPub.Federation.Moderation.IDomainBlocks domainBlocks = default)
{ {
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors; _localActors = localActors;
_proxy = proxy; _proxy = proxy;
_domainBlocks = domainBlocks;
} }
string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url); string Proxied(string url) => _proxy == default ? url : _proxy.Wrap(url);
@@ -51,7 +54,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id); var statuses = (await Statuses(posts, viewerId, token)).ToDictionary(s => s.Id);
var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default; var warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default;
return notifications return notifications
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty) && (n.PostId == default || statuses.ContainsKey(n.PostId))) // a warning names the posts it is about, which the staff may have removed
.Where(n => accounts.ContainsKey(n.FromAccountId ?? string.Empty)
&& (n.PostId == default || n.Type == NotificationType.ModerationWarning || statuses.ContainsKey(n.PostId)))
.Select(n => new Entities.Notification .Select(n => new Entities.Notification
{ {
Id = n.ID, Id = n.ID,
@@ -66,8 +71,9 @@ namespace PrivaPub.Api.Mastodon.Mappers
ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning
{ {
Id = n.ID, Id = n.ID,
Action = n.WarningAction ?? "none",
Text = n.Text ?? string.Empty, Text = n.Text ?? string.Empty,
StatusIds = n.PostId == default ? new List<string>() : new List<string> { n.PostId }, StatusIds = n.StatusIds ?? (n.PostId == default ? new List<string>() : new List<string> { n.PostId }),
TargetAccount = warned, TargetAccount = warned,
CreatedAt = MastodonJson.Time(n.CreatedAt) CreatedAt = MastodonJson.Time(n.CreatedAt)
} }
@@ -104,6 +110,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Noindex = !actor.Indexable, Noindex = !actor.Indexable,
HideCollections = true,//follower and following lists are never shown to others, only their counts HideCollections = true,//follower and following lists are never shown to others, only their counts
Group = actor.Kind == LocalActorKind.Group, Group = actor.Kind == LocalActorKind.Group,
Limited = actor.Silenced ? true : default(bool?),//silenced by the staff (R6), as Mastodon says it
CreatedAt = MastodonJson.Day(actor.Published), CreatedAt = MastodonJson.Day(actor.Published),
Note = ActivityPubRenderer.Html(actor.Summary), Note = ActivityPubRenderer.Html(actor.Summary),
Url = actor.HtmlUrl, Url = actor.HtmlUrl,
@@ -144,6 +151,7 @@ namespace PrivaPub.Api.Mastodon.Mappers
Discoverable = foreign.IsDiscoverable, Discoverable = foreign.IsDiscoverable,
Indexable = foreign.IsIndexable, Indexable = foreign.IsIndexable,
Memorial = foreign.IsMemorial ? true : default(bool?), Memorial = foreign.IsMemorial ? true : default(bool?),
Limited = foreign.SilencedAt.HasValue || DomainSilenced(foreign) ? true : default(bool?),
CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt), CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt),
Emojis = Emojis(foreign.Emojis), Emojis = Emojis(foreign.Emojis),
Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(), Fields = foreign.Fields.Select(f => new Field { Name = f.Key, Value = f.Value }).ToList(),
@@ -161,6 +169,10 @@ namespace PrivaPub.Api.Mastodon.Mappers
StatusesCount = foreign.StatusesCount ?? 0 StatusesCount = foreign.StatusesCount ?? 0
}; };
bool DomainSilenced(ForeignAvatar foreign) =>
_domainBlocks != default && Uri.TryCreate(foreign.ActorURI, UriKind.Absolute, out var uri)
&& _domainBlocks.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
public async Task<Account> Account(string id, CancellationToken token) => public async Task<Account> Account(string id, CancellationToken token) =>
(await Accounts(new[] { id }, token)).GetValueOrDefault(id); (await Accounts(new[] { id }, token)).GetValueOrDefault(id);
@@ -171,11 +183,14 @@ namespace PrivaPub.Api.Mastodon.Mappers
if (wanted.Count == 0) if (wanted.Count == 0)
return accounts; return accounts;
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) // a suspended account, here or elsewhere, and a banned login's personas are shown to nobody (R6): their posts and
accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); // what they notify are left out with them, wherever a list holds them
foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue && !a.SuspendedAt.HasValue).ExecuteAsync(token))
if (_localActors.FromAvatar(avatar) is { Suspended: false } persona)
accounts[avatar.ID] = await Local(persona, false, token);
foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token)) foreach (var group in await _dbEntities.Groups.Match(g => wanted.Contains(g.ID) && !g.DeletionAt.HasValue && g.Kind == GroupKind.Community).ExecuteAsync(token))
accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token); accounts[group.ID] = await Local(_localActors.FromGroup(group), false, token);
var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue).ExecuteAsync(token); var foreigns = await _dbEntities.ForeignAvatars.Match(f => wanted.Contains(f.ID) && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue).ExecuteAsync(token);
foreach (var foreign in foreigns) foreach (var foreign in foreigns)
accounts[foreign.ID] = Foreign(foreign); accounts[foreign.ID] = Foreign(foreign);
// an account that moved shows where to, once the new account names it as one of its own (as a Move is believed) // an account that moved shows where to, once the new account names it as one of its own (as a Move is believed)
@@ -2,50 +2,90 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using PrivaPub.ClientModels; using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User;
using PrivaPub.Domain.Moderation;
using PrivaPub.Domain.Timelines;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Federation.Actors;
using PrivaPub.Models;
using PrivaPub.Models.Group;
using PrivaPub.Models.User;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.Services; using PrivaPub.Services;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using GroupEntity = PrivaPub.Models.Group.Group;
namespace PrivaPub.Controllers.ClientToServer namespace PrivaPub.Controllers.ClientToServer
{ {
// What only administrators do to logins (completion program R6): read them with their personas (the one place a login
// is shown next to its personas), change their role, ban and unban them, delete them, a persona or a group, and turn a
// login's two-factor sign-in off. Every act is kept in the staff's log; an entry about a login names none of its
// personas.
[ApiController, [ApiController,
Route("clientapi/admin")] Route("clientapi/admin"),
Authorize(Policy = Policies.IsAdmin)]
public class AdminController : ControllerBase public class AdminController : ControllerBase
{ {
const int MaxRootsPage = 100;
readonly IRootUsersService RootUsersService; readonly IRootUsersService RootUsersService;
readonly ILogger<RootUserController> Logger; readonly ILogger<RootUserController> Logger;
readonly IStringLocalizer Localizer; readonly IStringLocalizer Localizer;
readonly IModerationLog _log;
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
public AdminController(ILogger<RootUserController> logger, public AdminController(ILogger<RootUserController> logger,
IStringLocalizer<GenericRes> localizer, IStringLocalizer<GenericRes> localizer,
IRootUsersService rootUsersService) IRootUsersService rootUsersService,
IModerationLog log,
DbEntities dbEntities,
ILocalActorService localActors)
{ {
Logger = logger; Logger = logger;
Localizer = localizer; Localizer = localizer;
RootUsersService = rootUsersService; RootUsersService = rootUsersService;
_log = log;
_dbEntities = dbEntities;
_localActors = localActors;
} }
// an administrator never removes, bans or unbans itself; the request's own list is left as it came // an administrator never removes, bans or unbans itself; the request's own list is left as it came
UsersIds Others(UsersIds usersIds) UsersIds Others(UsersIds usersIds)
{ {
var selfId = User.GetUserId(); var selfId = User.GetUserId();
return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).ToList() }; return new UsersIds { UserIdList = usersIds.UserIdList.Where(id => id != selfId).Distinct().ToList() };
} }
[HttpDelete, Route("/clientapi/admin/remove/users"), Authorize(Policy = Policies.IsAdmin)] // the logins a request names that exist and are not deleted, by id: what the log names them
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds) static async Task<Dictionary<string, string>> Named(UsersIds usersIds, CancellationToken token)
{
var ids = usersIds.UserIdList;
return ids.Count == 0
? new Dictionary<string, string>()
: (await DB.Default.Find<RootUser>().Match(u => ids.Contains(u.ID) && u.DeletedAt == null).ExecuteAsync(token)).ToDictionary(u => u.ID, u => u.UserName);
}
[HttpDelete, Route("/clientapi/admin/remove/users")]
public async Task<IActionResult> RemoveUsers([Required] UsersIds usersIds, CancellationToken token)
{ {
var result = new WebResult(); var result = new WebResult();
try try
{ {
result = await RootUsersService.RemoveUserAsync(Others(usersIds)); var others = Others(usersIds);
var named = await Named(others, token);
result = await RootUsersService.RemoveUserAsync(others);
if (!result.IsValid) if (!result.IsValid)
return StatusCode(result.StatusCode, result); return StatusCode(result.StatusCode, result);
foreach (var (id, name) in named)
await _log.Write(User.GetUserId(), AdminActions.RootDelete, AdminTargets.Root, id, name, token: CancellationToken.None);
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -55,19 +95,31 @@ namespace PrivaPub.Controllers.ClientToServer
} }
} }
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/ban/users")] // A ban ends the login's sessions and holds its personas back as suspended ones (Restrictions), without writing a word
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds) // on any of them; unbanning gives them back as they were.
[HttpPost, Route("/clientapi/admin/ban/users")]
public async Task<IActionResult> BanUsers([Required] UsersIds usersIds, [FromServices] IRootSessions sessions, CancellationToken token)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
return BadRequest(usersIds); return BadRequest(usersIds);
var result = new WebResult(); var result = new WebResult();
try try
{ {
result = await RootUsersService.BanUserAsync(Others(usersIds)); var others = Others(usersIds);
var named = await Named(others, token);
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
result = await RootUsersService.BanUserAsync(others);
if (!result.IsValid) if (!result.IsValid)
return StatusCode(result.StatusCode, result); return StatusCode(result.StatusCode, result);
await Restrictions.Reload(CancellationToken.None);
foreach (var (id, name) in named.Where(n => !banned.Contains(n.Key)))
{
await sessions.Revoke(id, CancellationToken.None);
foreach (var link in await _dbEntities.RootToAvatars.Match(ra => ra.RootId == id).ExecuteAsync(token))
Streams.CloseFor(link.AvatarId);
await _log.Write(User.GetUserId(), AdminActions.RootBan, AdminTargets.Root, id, name, token: CancellationToken.None);
}
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -77,18 +129,23 @@ namespace PrivaPub.Controllers.ClientToServer
} }
} }
[HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/unban/users")] [HttpPost, Route("/clientapi/admin/unban/users")]
public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds) public async Task<IActionResult> UnbanUsers([Required] UsersIds usersIds, CancellationToken token)
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
return BadRequest(usersIds); return BadRequest(usersIds);
var result = new WebResult(); var result = new WebResult();
try try
{ {
result = await RootUsersService.UnbanUserAsync(Others(usersIds)); var others = Others(usersIds);
var named = await Named(others, token);
var banned = (await DB.Default.Find<RootUser>().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet();
result = await RootUsersService.UnbanUserAsync(others);
if (!result.IsValid) if (!result.IsValid)
return StatusCode(result.StatusCode, result); return StatusCode(result.StatusCode, result);
await Restrictions.Reload(CancellationToken.None);
foreach (var (id, name) in named.Where(n => banned.Contains(n.Key)))
await _log.Write(User.GetUserId(), AdminActions.RootUnban, AdminTargets.Root, id, name, token: CancellationToken.None);
return Ok(); return Ok();
} }
catch (Exception ex) catch (Exception ex)
@@ -97,5 +154,187 @@ namespace PrivaPub.Controllers.ClientToServer
return BadRequest(result.Invalidate(Localizer["Something went wrong."])); return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
} }
} }
// The logins, newest first, each with its personas and the groups they own; q finds a login by its name, or by the
// name of one of its personas.
[HttpGet, Route("/clientapi/admin/roots")]
public async Task<IActionResult> Roots([FromQuery] string q, [FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
{
limit = Math.Clamp(limit, 1, MaxRootsPage);
var query = DB.Default.Find<RootUser>().Match(u => u.DeletedAt == null);
var name = q?.Trim().TrimStart('@').Split('@')[0].ToLowerInvariant();
if (!string.IsNullOrEmpty(name))
{
var personaIds = (await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(name) && !a.DeletionAt.HasValue)
.Project(a => a.Include(x => x.ID)).Limit(500).ExecuteAsync(token)).Select(a => a.ID).ToList();
var owners = personaIds.Count == 0
? new List<string>()
: (await DB.Default.Find<RootToAvatar>().Match(ra => personaIds.Contains(ra.AvatarId)).ExecuteAsync(token)).Select(ra => ra.RootId).Distinct().ToList();
query.Match(u => u.UserName.StartsWith(name) || owners.Contains(u.ID));
}
if (!string.IsNullOrEmpty(before))
{
if (!MongoDB.Bson.ObjectId.TryParse(before, out _))
return BadRequest();
query.Match(f => f.Lt(u => u.ID, before));
}
var roots = await query.Sort(u => u.ID, Order.Descending).Limit(limit).ExecuteAsync(token);
return Ok(new ViewAdminRootsPage
{
Items = await Views(roots, token),
Next = roots.Count == limit ? roots[^1].ID : default
});
}
[HttpGet, Route("/clientapi/admin/roots/{id}")]
public async Task<IActionResult> Root(string id, CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
return root == default ? NotFound() : Ok((await Views(new List<RootUser> { root }, token))[0]);
}
// A login's role: user, moderator or administrator. Never one's own, and never the last administrator's.
[HttpPost, Route("/clientapi/admin/roots/{id}/role")]
public async Task<IActionResult> Role(string id, RootRoleForm form, CancellationToken token)
{
if (!ModelState.IsValid || !RootUser.TryParseRole(form.Role, out var role))
return UnprocessableEntity(new WebResult().Invalidate(Localizer["Unknown role."], StatusCodes.Status422UnprocessableEntity));
if (id == User.GetUserId())
return Conflict(new WebResult().Invalidate(Localizer["You can't change your own role."], StatusCodes.Status409Conflict));
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
if (root == default)
return NotFound();
var was = root.Role;
if (was == role)
return Ok((await Views(new List<RootUser> { root }, token))[0]);
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, token))
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
root.SetRole(role);
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).Modify(u => u.UpdatedAt, root.UpdatedAt)
.ExecuteAsync(CancellationToken.None);
// two administrators demoting each other at once: this one's act is undone, so one always remains
if (was == RootRole.Admin && !await OtherAdministrators(root.ID, CancellationToken.None))
{
root.SetRole(was);
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Policies, root.Policies).ExecuteAsync(CancellationToken.None);
return Conflict(new WebResult().Invalidate(Localizer["The last administrator can't be demoted."], StatusCodes.Status409Conflict));
}
if (role == RootRole.Admin)
await ServerSetup.Seal(root.ID, default);//an administrator exists: the client's setup is closed
await _log.Write(User.GetUserId(), AdminActions.RootRole, AdminTargets.Root, root.ID, root.UserName,
note: $"{RoleName(was)} → {RoleName(role)}", token: CancellationToken.None);
return Ok((await Views(new List<RootUser> { root }, token))[0]);
}
// an administrator other than this login that can still act
static Task<bool> OtherAdministrators(string rootId, CancellationToken token) =>
DB.Default.Find<RootUser>().Match(u => u.ID != rootId && u.DeletedAt == null && !u.IsBanned && u.Policies.Contains(Policies.IsAdmin)).ExecuteAnyAsync(token);
// A login that lost its authenticator and its recovery codes, as `PrivaPub admin reset-totp` does it: its two-factor
// sign-in goes, its password and sessions stay. Never one's own (that is /clientapi/user/totp/disable, with a code).
[HttpPost, Route("/clientapi/admin/roots/{id}/totp/reset")]
public async Task<IActionResult> ResetTotp(string id, CancellationToken token)
{
if (id == User.GetUserId())
return Conflict(new WebResult().Invalidate(Localizer["Turn your own two-factor sign-in off from your account."], StatusCodes.Status409Conflict));
var root = await DB.Default.Find<RootUser>().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token);
if (root == default)
return NotFound();
await DB.Default.Update<RootUser>().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync(CancellationToken.None);
await _log.Write(User.GetUserId(), AdminActions.RootResetTotp, AdminTargets.Root, root.ID, root.UserName, token: CancellationToken.None);
return Ok();
}
// A persona deleted by an administrator, who types their own password again: as its login would delete it
// (PersonaRemoval), the groups it owns with it. The log keeps it without who acted when it is the administrator's own.
[HttpPost, Route("/clientapi/admin/personas/{id}/delete")]
public async Task<IActionResult> DeletePersona(string id, AdminPasswordForm form, [FromServices] IPersonaRemoval personas, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
var avatar = await _dbEntities.Avatars.MatchID(id).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default)
return NotFound();
var label = "@" + _localActors.FromAvatar(avatar).Handle;
if (!await personas.Remove(avatar.ID, CancellationToken.None))
return NotFound();
await _log.Write(User.GetUserId(), AdminActions.PersonaDelete, AdminTargets.Persona, avatar.ID, label, note: form.Note, token: CancellationToken.None);
return Ok();
}
[HttpPost, Route("/clientapi/admin/groups/{id}/delete")]
public async Task<IActionResult> DeleteGroup(string id, AdminPasswordForm form, [FromServices] IGroupRemoval groups, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."]));
if (!await RootUsersService.PasswordMatches(User.GetUserId(), form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
var group = await _dbEntities.Groups.MatchID(id).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (group == default)
return NotFound();
var label = "@" + _localActors.FromGroup(group).Handle;
if (!await groups.Remove(group.ID, CancellationToken.None))
return NotFound();
await _log.Write(User.GetUserId(), AdminActions.GroupDelete, AdminTargets.Group, group.ID, label, note: form.Note, token: CancellationToken.None);
return Ok();
}
async Task<List<ViewAdminRoot>> Views(List<RootUser> roots, CancellationToken token)
{
var ids = roots.Select(r => r.ID).ToList();
var links = ids.Count == 0 ? new List<RootToAvatar>() : await _dbEntities.RootToAvatars.Match(ra => ids.Contains(ra.RootId)).ExecuteAsync(token);
var avatarIds = links.Select(l => l.AvatarId).Distinct().ToList();
var avatars = avatarIds.Count == 0
? new Dictionary<string, Avatar>()
: (await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)).ToDictionary(a => a.ID);
var owned = avatarIds.Count == 0
? new List<GroupEntity>()
: await _dbEntities.Groups.Match(g => avatarIds.Contains(g.OwnerAvatarId) && !g.DeletionAt.HasValue).ExecuteAsync(token);
var me = User.GetUserId();
return roots.Select(root => new ViewAdminRoot
{
Id = root.ID,
UserName = root.UserName,
Role = RoleName(root.Role),
IsBanned = root.IsBanned,
HasEmail = !string.IsNullOrEmpty(root.Email),
IsEmailValidated = root.IsEmailValidated,
HasTotp = root.Totp?.EnabledAt != default,
IsYou = root.ID == me,
CreatedAt = root.CreatedAt,
Personas = links.Where(l => l.RootId == root.ID && avatars.ContainsKey(l.AvatarId)).Select(l => avatars[l.AvatarId])
.OrderBy(a => a.UserName, StringComparer.Ordinal)
.Select(a =>
{
var persona = _localActors.FromAvatar(a);
return new ViewAdminPersona
{
Id = a.ID,
UserName = a.UserName,
DisplayName = persona.Name,
Handle = persona.Handle,
SilencedAt = a.SilencedAt,
SuspendedAt = a.SuspendedAt,
Groups = owned.Where(g => g.OwnerAvatarId == a.ID).Select(g => new ViewAdminGroup
{
Id = g.ID,
UserName = g.UserName,
Name = g.Name,
IsCommunity = g.Kind == GroupKind.Community,
MembersCount = g.Members.Count
}).ToList()
};
}).ToList()
}).ToList();
}
static string RoleName(RootRole role) => role switch
{
RootRole.Admin => "admin",
RootRole.Moderator => "moderator",
_ => "user"
};
} }
} }
@@ -24,9 +24,12 @@ namespace PrivaPub.Controllers.ClientToServer
readonly IStringLocalizer _localizer; readonly IStringLocalizer _localizer;
readonly Domain.Media.IMediaProxy _proxy; readonly Domain.Media.IMediaProxy _proxy;
readonly IModerationLog _log; readonly IModerationLog _log;
readonly IAccountModeration _moderation;
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log) public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer, Domain.Media.IMediaProxy proxy, IModerationLog log,
IAccountModeration moderation)
{ {
_moderation = moderation;
_domainBlocks = domainBlocks; _domainBlocks = domainBlocks;
_localizer = localizer; _localizer = localizer;
_proxy = proxy; _proxy = proxy;
@@ -57,6 +60,8 @@ namespace PrivaPub.Controllers.ClientToServer
.Option(o => o.IsUpsert = true) .Option(o => o.IsUpsert = true)
.ExecuteAsync(token); .ExecuteAsync(token);
await _domainBlocks.Reload(token); await _domainBlocks.Reload(token);
// a silenced server's posts are limited from now on, and no longer once it is not (R6)
await _moderation.DomainChanged(domain, token);
// what the media proxy holds of it goes with the block // what the media proxy holds of it goes with the block
if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia) if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia)
_proxy.Purge(domain); _proxy.Purge(domain);
@@ -72,6 +77,8 @@ namespace PrivaPub.Controllers.ClientToServer
domain = DomainBlocks.Normalise(domain); domain = DomainBlocks.Normalise(domain);
var deleted = await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain); var deleted = await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
await _domainBlocks.Reload(token); await _domainBlocks.Reload(token);
if (deleted.DeletedCount > 0)
await _moderation.DomainChanged(domain, token);
if (deleted.DeletedCount > 0) if (deleted.DeletedCount > 0)
await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token); await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token);
return Ok(); return Ok();
@@ -60,6 +60,27 @@ namespace PrivaPub.Controllers.ClientToServer
public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) => public async Task<IActionResult> Remove(GroupMembershipForm form, CancellationToken token) =>
!ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token)); !ModelState.IsValid ? Invalid() : Answer(await _groupUsersService.RemoveMember(User.GetUserId(), form, token));
// the group's owner deletes it, with the login's password (R6): its members and followers are told, its name stays
// taken (GroupRemoval)
[HttpPost, Route("/clientapi/group/delete"), Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Infrastructure.RateLimiting.Accounts)]
public async Task<IActionResult> Delete(DeleteGroupForm form, [FromServices] IRootUsersService users, [FromServices] IGroupRemoval groups,
[FromServices] StaticServices.DbEntities dbEntities, CancellationToken token)
{
if (!ModelState.IsValid)
return Invalid();
var rootId = User.GetUserId();
if (!await users.PasswordMatches(rootId, form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer[RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
var owns = await dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId && ra.AvatarId == form.AvatarId).ExecuteAnyAsync(token);
var group = owns ? await dbEntities.Groups.MatchID(form.GroupId).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token) : default;
if (group == default)
return NotFound(new WebResult().Invalidate(_localizer["Group not found."], StatusCodes.Status404NotFound));
if (group.OwnerAvatarId != form.AvatarId)
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer["Only the group's owner can delete it."], StatusCodes.Status403Forbidden));
await groups.Remove(group.ID, CancellationToken.None);
return Ok();
}
IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."])); IActionResult Invalid() => BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); IActionResult Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
@@ -230,7 +230,7 @@ namespace PrivaPub.Controllers.ClientToServer
// a local persona whose login may act: the contact is shown to everyone as the one who runs the server // a local persona whose login may act: the contact is shown to everyone as the one who runs the server
static async Task<bool> IsContactable(string avatarId, CancellationToken token) => static async Task<bool> IsContactable(string avatarId, CancellationToken token) =>
await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token) await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)
&& !(await Counted.BarredPersonas(token)).Contains(avatarId); && !(await Counted.HeldBackPersonas(token)).Contains(avatarId);
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim(); static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
@@ -21,13 +21,22 @@ namespace PrivaPub.Controllers.ClientToServer
{ {
const int MaxLogPage = 100; const int MaxLogPage = 100;
const int MaxAccountsPage = 100;
readonly IInstanceSettings _settings; readonly IInstanceSettings _settings;
readonly IModerationLog _log; readonly IModerationLog _log;
readonly IAccountModeration _moderation;
readonly Federation.Actors.ILocalActorService _localActors;
readonly Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> _localizer;
public ModeratorController(IInstanceSettings settings, IModerationLog log) public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation,
Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer<Resources.GenericRes> localizer)
{ {
_settings = settings; _settings = settings;
_log = log; _log = log;
_moderation = moderation;
_localActors = localActors;
_localizer = localizer;
} }
[HttpGet, Route("/clientapi/moderator/reports")] [HttpGet, Route("/clientapi/moderator/reports")]
@@ -49,6 +58,8 @@ namespace PrivaPub.Controllers.ClientToServer
Comment = r.Comment, Comment = r.Comment,
Forwarded = r.Forwarded, Forwarded = r.Forwarded,
IsResolved = r.IsResolved, IsResolved = r.IsResolved,
Resolution = r.Resolution,
ResolutionNote = r.ResolutionNote,
CreatedAt = r.CreatedAt, CreatedAt = r.CreatedAt,
ResolvedAt = r.ResolvedAt, ResolvedAt = r.ResolvedAt,
RemoteResolvedAt = r.RemoteResolvedAt, RemoteResolvedAt = r.RemoteResolvedAt,
@@ -56,22 +67,136 @@ namespace PrivaPub.Controllers.ClientToServer
})); }));
} }
// A report resolved, and with it what the staff does about it (R6): nothing more, the reported account silenced or
// suspended, or the reported posts removed. The act comes first: one that fails leaves the report open. The body is
// optional (a report only marked resolved, as before R6).
[HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")] [HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")]
public async Task<IActionResult> Resolve(string id, CancellationToken token) public async Task<IActionResult> Resolve(string id, [FromBody(EmptyBodyBehavior = Microsoft.AspNetCore.Mvc.ModelBinding.EmptyBodyBehavior.Allow)] ResolveReportForm form,
CancellationToken token)
{ {
form ??= new ResolveReportForm();
var action = string.IsNullOrWhiteSpace(form.Action) ? ModerationActions.None : form.Action.Trim().ToLowerInvariant();
if (!ModerationActions.IsResolution(action))
return UnprocessableEntity(new WebResult().Invalidate(_localizer["Unknown moderation action."], StatusCodes.Status422UnprocessableEntity));
var by = User.GetUserId(); var by = User.GetUserId();
var result = await DB.Default.Update<Report>().MatchID(id) var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token);
if (report == default)
return NotFound();
switch (action)
{
case ModerationActions.Silence or ModerationActions.Suspend:
var moderated = await _moderation.Moderate(by, report.TargetAccountId, action, form.Note, form.Warning, report.ID, token);
if (!moderated.IsValid)
return StatusCode(moderated.StatusCode, moderated);
break;
case ModerationActions.RemovePosts:
if (report.PostIds.Count == 0)
return UnprocessableEntity(new WebResult().Invalidate(_localizer["The report names no posts."], StatusCodes.Status422UnprocessableEntity));
foreach (var postId in report.PostIds)
{
var removed = await _moderation.RemovePost(by, postId, form.Note, form.Warning, report.ID, token);
if (!removed.IsValid && removed.StatusCode != StatusCodes.Status404NotFound)//one already gone is removed
return StatusCode(removed.StatusCode, removed);
}
break;
}
await DB.Default.Update<Report>().MatchID(id)
.Modify(r => r.IsResolved, true) .Modify(r => r.IsResolved, true)
.Modify(r => r.ResolvedAt, DateTime.UtcNow) .Modify(r => r.ResolvedAt, DateTime.UtcNow)
.Modify(r => r.ResolvedBy, by) .Modify(r => r.ResolvedBy, by)
.ExecuteAsync(token); .Modify(r => r.Resolution, action)
if (result.MatchedCount == 0) .Modify(r => r.ResolutionNote, string.IsNullOrWhiteSpace(form.Note) ? null : form.Note.Trim())
return NotFound(); .ExecuteAsync(CancellationToken.None);
var report = await DB.Default.Find<Report>().MatchID(id).ExecuteFirstAsync(token); await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report.Category, reportId: id,
await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token); note: action == ModerationActions.None ? form.Note : string.IsNullOrWhiteSpace(form.Note) ? action : $"{action}: {form.Note.Trim()}",
token: CancellationToken.None);
return Ok(); return Ok();
} }
// Accounts to moderate, found by a name (a persona's, or user@host for an account elsewhere) or an actor's address:
// personas here, never their logins, and accounts elsewhere PrivaPub holds
[HttpGet, Route("/clientapi/moderator/accounts")]
public async Task<IActionResult> Accounts([FromQuery] string q, [FromQuery] int limit = 20, CancellationToken token = default)
{
limit = Math.Clamp(limit, 1, 40);
q = q?.Trim().TrimStart('@');
var items = new List<ViewModeratedAccount>();
if (string.IsNullOrEmpty(q) || q.Length > 300)
return Ok(new ViewModeratedAccountsPage { Items = items });
if (Uri.TryCreate(q, UriKind.Absolute, out var address) && address.Scheme is "https" or "http")
{
if (await _localActors.FindByUri(q, token) is { Kind: Models.Federation.LocalActorKind.Person } local
&& await DB.Default.Find<Avatar>().MatchID(local.Id).ExecuteFirstAsync(token) is { } persona)
items.Add(_moderation.View(persona));
items.AddRange((await DB.Default.Find<ForeignAvatar>().Match(f => f.ActorURI == q || f.Url == q).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
return Ok(new ViewModeratedAccountsPage { Items = items.Take(limit).ToList() });
}
var parts = q.Split('@', 2);
var name = parts[0];
var domain = parts.Length > 1 ? parts[1].ToLowerInvariant() : default;
var here = new Uri(_localActors.BaseAddress).Authority;
if (domain == default || domain == here)
{
var lowered = name.ToLowerInvariant();
items.AddRange((await DB.Default.Find<Avatar>().Match(a => a.UserName.StartsWith(lowered) && !a.DeletionAt.HasValue)
.Sort(a => a.UserName, Order.Ascending).Limit(limit).ExecuteAsync(token)).Select(_moderation.View));
}
if (domain != here && items.Count < limit && name.Length > 0)
{
var userName = new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(name), "i");
var remote = DB.Default.Find<ForeignAvatar>().Match(f => f.Regex(a => a.UserName, userName));
if (domain != default)
remote.Match(f => f.Regex(a => a.Domain, new MongoDB.Bson.BsonRegularExpression("^" + System.Text.RegularExpressions.Regex.Escape(domain), "i")));
items.AddRange((await remote.Match(f => !f.DeletionAt.HasValue).Sort(f => f.UserName, Order.Ascending).Limit(limit - items.Count).ExecuteAsync(token))
.Select(_moderation.View));
}
return Ok(new ViewModeratedAccountsPage { Items = items });
}
// what the staff silenced or suspended, here and elsewhere, most recently moderated first (a banned login's personas
// are never among them: the ban is the login's, and only administrators see logins)
[HttpGet, Route("/clientapi/moderator/accounts/moderated")]
public async Task<IActionResult> Moderated([FromQuery] int offset = 0, [FromQuery] int limit = 50, CancellationToken token = default)
{
offset = Math.Max(0, offset);
limit = Math.Clamp(limit, 1, MaxAccountsPage);
var locals = await DB.Default.Find<Avatar>().Match(a => (a.SilencedAt != null || a.SuspendedAt != null) && a.DeletionAt == null).ExecuteAsync(token);
var remotes = await DB.Default.Find<ForeignAvatar>().Match(f => f.SilencedAt != null || f.SuspendedAt != null).ExecuteAsync(token);
var all = locals.Select(_moderation.View).Concat(remotes.Select(_moderation.View))
.OrderByDescending(a => Latest(a.SilencedAt, a.SuspendedAt)).ThenBy(a => a.Id, StringComparer.Ordinal).ToList();
return Ok(new ViewModeratedAccountsPage
{
Items = all.Skip(offset).Take(limit).ToList(),
Next = all.Count > offset + limit ? offset + limit : default(int?)
});
}
static DateTime Latest(DateTime? a, DateTime? b) => (a ?? DateTime.MinValue) > (b ?? DateTime.MinValue) ? a ?? DateTime.MinValue : b ?? DateTime.MinValue;
[HttpGet, Route("/clientapi/moderator/accounts/{id}")]
public async Task<IActionResult> Account(string id, CancellationToken token) =>
await _moderation.View(id, token) is { } account ? Ok(account) : NotFound();
// silence, unsilence, suspend or unsuspend a persona here or an account elsewhere
[HttpPost, Route("/clientapi/moderator/accounts/moderate")]
public async Task<IActionResult> Moderate(ModerateAccountForm form, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var result = await _moderation.Moderate(User.GetUserId(), form.AccountId, form.Action.Trim().ToLowerInvariant(), form.Note, form.Warning, default, token);
return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result);
}
// a post taken down: deleted when it is ours (the deletion federates), hidden here when it is a copy
[HttpPost, Route("/clientapi/moderator/posts/remove")]
public async Task<IActionResult> RemovePost(RemovePostForm form, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var result = await _moderation.RemovePost(User.GetUserId(), form.PostId, form.Note, form.Warning, default, token);
return result.IsValid ? Ok() : StatusCode(result.StatusCode, result);
}
// The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all // The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all
// of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or // of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or
// themselves. // themselves.
@@ -110,6 +110,9 @@ namespace PrivaPub.Controllers.ClientToServer
{ {
if (!await Owns(avatarId, token)) if (!await Owns(avatarId, token))
return NotFound(); return NotFound();
// a suspended persona may take its archive away, never bring one in (R6): an import follows, blocks and posts
if (await DB.Default.Find<Avatar>().MatchID(avatarId).Match(a => a.SuspendedAt != null).ExecuteAnyAsync(token))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate("This persona is suspended.", StatusCodes.Status403Forbidden));
var state = await PersonaArchives.Of(avatarId, token); var state = await PersonaArchives.Of(avatarId, token);
if (state.ImportState is "queued" or "running") if (state.ImportState is "queued" or "running")
return Conflict(new WebResult().Invalidate("An import is running.")); return Conflict(new WebResult().Invalidate("An import is running."));
@@ -5,6 +5,7 @@ using PrivaPub.ClientModels;
using PrivaPub.Extensions; using PrivaPub.Extensions;
using PrivaPub.Resources; using PrivaPub.Resources;
using PrivaPub.Services.ClientToServer.Private; using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar; using PrivaPub.ClientModels.User.Avatar;
namespace PrivaPub.Controllers.ClientToServer namespace PrivaPub.Controllers.ClientToServer
@@ -56,6 +57,23 @@ namespace PrivaPub.Controllers.ClientToServer
} }
} }
// A login deletes one of its personas, with its password again (R6): everything public it had goes, the groups it owns
// with it, as when the whole login is deleted (PersonaRemoval). A suspended persona may be deleted too.
[HttpPost, Route("/clientapi/avatar/private/delete"), Microsoft.AspNetCore.RateLimiting.EnableRateLimiting(Infrastructure.RateLimiting.Accounts)]
public async Task<IActionResult> DeleteAvatar(DeletePersonaForm form, [FromServices] Services.IRootUsersService users,
[FromServices] Services.IPersonaRemoval personas, [FromServices] StaticServices.DbEntities dbEntities, CancellationToken token)
{
if (!ModelState.IsValid)
return BadRequest(new WebResult().Invalidate(_localizer["Invalid model."]));
var rootId = User.GetUserId();
if (!await users.PasswordMatches(rootId, form.Password))
return StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(_localizer[Services.RootUsersService.NoMatch], StatusCodes.Status403Forbidden));
if (!await dbEntities.RootToAvatars.Match(ra => ra.RootId == rootId && ra.AvatarId == form.AvatarId).ExecuteAnyAsync(token)
|| !await personas.Remove(form.AvatarId, CancellationToken.None))
return NotFound(new WebResult().Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound));
return Ok();
}
[HttpPost, Route("/clientapi/avatar/private/update")] [HttpPost, Route("/clientapi/avatar/private/update")]
public async Task<IActionResult> UpdateAvatar(UpdateAvatarForm model) public async Task<IActionResult> UpdateAvatar(UpdateAvatarForm model)
{ {
@@ -0,0 +1,339 @@
using Microsoft.Extensions.Localization;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.Admin;
using PrivaPub.Domain.Statuses;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Moderation;
using PrivaPub.Models;
using PrivaPub.Models.Federation;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.StaticServices;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Domain.Moderation
{
public interface IAccountModeration
{
/// <summary>Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions);
/// Data is the account as moderators see it.</summary>
Task<WebResult> Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId, CancellationToken token);
/// <summary>Takes a post down: a post here is deleted as its author deletes it, a copy of one from elsewhere is hidden
/// here (Post.ModeratedAt).</summary>
Task<WebResult> RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token);
/// <summary>A server's block changed: its accounts' posts are limited while it is silenced, and only then.</summary>
Task DomainChanged(string domain, CancellationToken token);
Task<ViewModeratedAccount> View(string accountId, CancellationToken token);
ViewModeratedAccount View(Avatar avatar);
ViewModeratedAccount View(ForeignAvatar account);
}
// The staff's acts on accounts and posts (completion program R6; moderators' and administrators' alike). What they do,
// following the AuthorGone pattern: the account carries when it was silenced or suspended, its posts carry
// AuthorLimited or AuthorSuspended so every query can leave them out, and Restrictions knows it in memory.
//
// A persona here:
// - silenced: out of the public and hashtag timelines, trends, the directory, search and the relays; its followers still
// get what it posts, and it is told (a moderation_warning);
// - suspended: its tokens are revoked and refused, its open streams close, it delivers nothing, its posts and collections
// answer 404 to everyone, and nothing new is told to other servers (owner decision 2026-10-17): its actor stays as it
// was, so lifting the suspension gives everything back as it was. It is warned; it reads the warning once it is lifted,
// and its login sees it suspended among its personas meanwhile.
// An account elsewhere: the same flags on its posts here; suspended, what it sends is dropped (its own Delete aside)
// and nothing is delivered to its own inbox. It is told nothing.
//
// Every act is kept in the staff's log (ModerationLog), without who acted when the persona is the acting login's own.
public class AccountModeration : IAccountModeration
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IModerationLog _log;
readonly IOpenIddictTokenManager _tokens;
readonly IOpenIddictAuthorizationManager _authorizations;
readonly IStatusService _statuses;
readonly IDomainBlocks _domainBlocks;
readonly IStringLocalizer<GenericRes> _localizer;
readonly ILogger<AccountModeration> _logger;
public AccountModeration(DbEntities dbEntities, ILocalActorService localActors, IModerationLog log, IOpenIddictTokenManager tokens,
IOpenIddictAuthorizationManager authorizations, IStatusService statuses, IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> localizer,
ILogger<AccountModeration> logger)
{
_dbEntities = dbEntities;
_localActors = localActors;
_log = log;
_tokens = tokens;
_authorizations = authorizations;
_statuses = statuses;
_domainBlocks = domainBlocks;
_localizer = localizer;
_logger = logger;
}
public async Task<WebResult> Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId,
CancellationToken token)
{
var result = new WebResult();
if (!ModerationActions.IsAccountAction(action))
return result.Invalidate(_localizer["Unknown moderation action."], StatusCodes.Status422UnprocessableEntity);
try
{
var avatar = string.IsNullOrEmpty(accountId) ? default : await _dbEntities.Avatars.MatchID(accountId).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar != default)
{
result.Data = await Persona(byRootId, avatar, action, note, warning, reportId, token);
return result;
}
var account = string.IsNullOrEmpty(accountId) ? default : await _dbEntities.ForeignAvatars.MatchID(accountId).ExecuteFirstAsync(token);
if (account == default)
return result.Invalidate(_localizer["Account not found."], StatusCodes.Status404NotFound);
result.Data = await Remote(byRootId, account, action, note, reportId, token);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service}.{Method}({Action})", nameof(AccountModeration), nameof(Moderate), action);
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
async Task<ViewModeratedAccount> Persona(string byRootId, Avatar avatar, string action, string note, string warning, string reportId,
CancellationToken token)
{
var now = DateTime.UtcNow;
var (silenced, suspended) = (avatar.SilencedAt, avatar.SuspendedAt);
switch (action)
{
case ModerationActions.Silence:
silenced ??= now;
break;
case ModerationActions.Unsilence:
silenced = null;
break;
case ModerationActions.Suspend:
suspended ??= now;
break;
case ModerationActions.Unsuspend:
suspended = null;
break;
}
var changed = silenced.HasValue != avatar.SilencedAt.HasValue || suspended.HasValue != avatar.SuspendedAt.HasValue;
avatar.SilencedAt = silenced;
avatar.SuspendedAt = suspended;
avatar.AccountState = suspended.HasValue ? AvatarAccountState.Suspended : silenced.HasValue ? AvatarAccountState.Silenced : AvatarAccountState.Normal;
if (!string.IsNullOrWhiteSpace(note))
avatar.ModerationNote = note.Trim();
await DB.Default.Update<Avatar>().MatchID(avatar.ID)
.Modify(a => a.SilencedAt, avatar.SilencedAt)
.Modify(a => a.SuspendedAt, avatar.SuspendedAt)
.Modify(a => a.AccountState, avatar.AccountState)
.Modify(a => a.ModerationNote, avatar.ModerationNote)
.ExecuteAsync(CancellationToken.None);
if (!changed)
return View(avatar);
// its own posts and boosts, everywhere they are read
await DB.Default.Update<PostEntity>().Match(p => p.GroupUserId == avatar.ID && !p.IsFederatedCopy)
.Modify(p => p.AuthorSuspended, suspended.HasValue)
.Modify(p => p.AuthorLimited, silenced.HasValue)
.ExecuteAsync(CancellationToken.None);
await Restrictions.Reload(CancellationToken.None);
var persona = _localActors.FromAvatar(avatar);
if (action == ModerationActions.Suspend)
{
// nothing acts as it from now on: its Mastodon tokens end and its open streams close
await _tokens.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
await _authorizations.RevokeBySubjectAsync(avatar.ID, CancellationToken.None);
Streams.CloseFor(avatar.ID);
}
if (action is ModerationActions.Silence or ModerationActions.Suspend)
await Social.Notifications.Warn(avatar.ID, persona.Uri, action, warning, default, token);
await _log.Write(byRootId, action switch
{
ModerationActions.Silence => AdminActions.PersonaSilence,
ModerationActions.Unsilence => AdminActions.PersonaUnsilence,
ModerationActions.Suspend => AdminActions.PersonaSuspend,
_ => AdminActions.PersonaUnsuspend
}, AdminTargets.Persona, avatar.ID, "@" + persona.Handle, reportId, note, CancellationToken.None);
return View(avatar);
}
async Task<ViewModeratedAccount> Remote(string byRootId, ForeignAvatar account, string action, string note, string reportId, CancellationToken token)
{
var now = DateTime.UtcNow;
var (silenced, suspended) = (account.SilencedAt, account.SuspendedAt);
switch (action)
{
case ModerationActions.Silence:
silenced ??= now;
break;
case ModerationActions.Unsilence:
silenced = null;
break;
case ModerationActions.Suspend:
suspended ??= now;
break;
case ModerationActions.Unsuspend:
suspended = null;
break;
}
var changed = silenced.HasValue != account.SilencedAt.HasValue || suspended.HasValue != account.SuspendedAt.HasValue;
account.SilencedAt = silenced;
account.SuspendedAt = suspended;
// an account that deleted itself stays deleted, whatever is done to it
if (account.AccountState != AvatarAccountState.Deleted)
account.AccountState = suspended.HasValue ? AvatarAccountState.Suspended : silenced.HasValue ? AvatarAccountState.Silenced : AvatarAccountState.Normal;
if (!string.IsNullOrWhiteSpace(note))
account.ModerationNote = note.Trim();
await DB.Default.Update<ForeignAvatar>().MatchID(account.ID)
.Modify(a => a.SilencedAt, account.SilencedAt)
.Modify(a => a.SuspendedAt, account.SuspendedAt)
.Modify(a => a.AccountState, account.AccountState)
.Modify(a => a.ModerationNote, account.ModerationNote)
.ExecuteAsync(CancellationToken.None);
if (!changed)
return View(account);
// what it wrote and boosted, as it arrived and arrives (RemotePosts)
var limited = silenced.HasValue || IsDomainSilenced(account.ActorURI);
await DB.Default.Update<PostEntity>().Match(p => p.ActorURI == account.ActorURI && p.IsFederatedCopy)
.Modify(p => p.AuthorSuspended, suspended.HasValue)
.Modify(p => p.AuthorLimited, limited)
.ExecuteAsync(CancellationToken.None);
await Restrictions.Reload(CancellationToken.None);
await _log.Write(byRootId, action switch
{
ModerationActions.Silence => AdminActions.AccountSilence,
ModerationActions.Unsilence => AdminActions.AccountUnsilence,
ModerationActions.Suspend => AdminActions.AccountSuspend,
_ => AdminActions.AccountUnsuspend
}, AdminTargets.Account, account.ID, Handle(account), reportId, note, CancellationToken.None);
return View(account);
}
public async Task<WebResult> RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token)
{
var result = new WebResult();
try
{
var post = string.IsNullOrEmpty(postId) ? default : await _dbEntities.Posts.MatchID(postId).ExecuteFirstAsync(token);
if (post == default || post.DeletedAt.HasValue || post.ModeratedAt.HasValue)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
if (!post.IsFederatedCopy)
{
var author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author == default)
return result.Invalidate(_localizer["Post not found."], StatusCodes.Status404NotFound);
// deleted as its author deletes it: emptied, out of every timeline, and a Delete to its audience (but from a
// suspended persona, which delivers nothing)
var outcome = await _statuses.Remove(author, post.ID, CancellationToken.None);
if (!outcome.Ok)
return result.Invalidate(_localizer["Post not found."], outcome.Status);
await Social.Notifications.Warn(author.Id, author.Uri, "delete_statuses", warning, new[] { post.ID }, token);
await _log.Write(byRootId, AdminActions.PostRemove, AdminTargets.Post, post.ID, $"@{author.Handle}: {post.ObjectURI}", reportId, note,
CancellationToken.None);
return result;
}
// a copy of a post from elsewhere: kept (its address stays known, so it is never taken in again), hidden here
await DB.Default.Update<PostEntity>().MatchID(post.ID).Modify(p => p.ModeratedAt, DateTime.UtcNow).ExecuteAsync(CancellationToken.None);
await Fanout.Deleting(post, CancellationToken.None);
await _log.Write(byRootId, AdminActions.PostRemove, AdminTargets.Post, post.ID, post.Url ?? post.ObjectURI, reportId, note, CancellationToken.None);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "{Service}.{Method}()", nameof(AccountModeration), nameof(RemovePost));
return result.Invalidate(_localizer["Something went wrong."], exception: ex);
}
}
public async Task DomainChanged(string domain, CancellationToken token)
{
domain = DomainBlocks.Normalise(domain);
if (string.IsNullOrEmpty(domain))
return;
var silenced = _domainBlocks.Find(domain)?.Severity == DomainBlockSeverity.Silence;
// the posts of that server and of its subdomains, by their authors' addresses
var pattern = ModerationFlags.OfDomain(domain);
await DB.Default.Update<PostEntity>().Match(f => f.Regex(p => p.ActorURI, pattern) & f.Eq(p => p.IsFederatedCopy, true))
.Modify(p => p.AuthorLimited, silenced)
.ExecuteAsync(token);
if (silenced)
return;
// an account silenced on its own stays limited, whatever its server is
var own = (await DB.Default.Find<ForeignAvatar>().Match(f => f.Regex(a => a.ActorURI, pattern) & f.Ne(a => a.SilencedAt, null))
.Project(a => a.Include(x => x.ActorURI)).ExecuteAsync(token)).Select(a => a.ActorURI).Where(u => !string.IsNullOrEmpty(u)).ToList();
foreach (var batch in own.Chunk(500))
await DB.Default.Update<PostEntity>().Match(p => batch.Contains(p.ActorURI) && p.IsFederatedCopy)
.Modify(p => p.AuthorLimited, true)
.ExecuteAsync(token);
}
bool IsDomainSilenced(string actorUri) =>
Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) && _domainBlocks.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
public async Task<ViewModeratedAccount> View(string accountId, CancellationToken token)
{
var avatar = await _dbEntities.Avatars.MatchID(accountId).ExecuteFirstAsync(token);
if (avatar != default)
return View(avatar);
var account = await _dbEntities.ForeignAvatars.MatchID(accountId).ExecuteFirstAsync(token);
return account == default ? default : View(account);
}
// a persona as moderators see it: never its login
public ViewModeratedAccount View(Avatar avatar)
{
var persona = _localActors.FromAvatar(avatar);
return new ViewModeratedAccount
{
Id = avatar.ID,
IsLocal = true,
UserName = avatar.UserName,
DisplayName = persona.Name,
Handle = persona.Handle,
Url = persona.Uri,
Domain = persona.Domain,
SilencedAt = avatar.SilencedAt,
SuspendedAt = avatar.SuspendedAt,
Note = avatar.ModerationNote
};
}
public ViewModeratedAccount View(ForeignAvatar account)
{
var block = Uri.TryCreate(account.ActorURI, UriKind.Absolute, out var uri) ? _domainBlocks.Find(uri.Host) : default;
return new ViewModeratedAccount
{
Id = account.ID,
IsLocal = false,
UserName = account.UserName,
DisplayName = account.Name ?? account.UserName,
Handle = Handle(account),
Url = account.ActorURI,
Domain = account.Domain ?? uri?.Host,
SilencedAt = account.SilencedAt,
SuspendedAt = account.SuspendedAt,
DomainSilenced = block?.Severity == DomainBlockSeverity.Silence,
DomainSuspended = block?.Severity == DomainBlockSeverity.Suspend,
Note = account.ModerationNote
};
}
static string Handle(ForeignAvatar account) =>
string.IsNullOrEmpty(account.UserName) ? account.ActorURI
: $"{account.UserName}@{account.Domain ?? (Uri.TryCreate(account.ActorURI, UriKind.Absolute, out var uri) ? uri.Host : string.Empty)}";
}
}
@@ -0,0 +1,75 @@
using MongoDB.Bson;
using MongoDB.Driver;
namespace PrivaPub.Domain.Moderation
{
// Every post's AuthorSuspended and AuthorLimited worked out again from its author's and its server's moderation (R6),
// and every account's AccountState from when it was silenced or suspended: after a restore merged the moderation it keeps
// (ProtectiveMerge), and once by migration _020 for the servers silenced before posts carried the flags. Raw documents,
// since a restore runs before the entities are used. A banned login's personas are never flagged (Restrictions).
public static class ModerationFlags
{
public static async Task<long> Recompute(IMongoDatabase database, CancellationToken token)
{
var posts = database.GetCollection<BsonDocument>("Post");
var flagged = 0L;
await posts.UpdateManyAsync(new BsonDocument("AuthorSuspended", true), Builders<BsonDocument>.Update.Set("AuthorSuspended", false), cancellationToken: token);
await posts.UpdateManyAsync(new BsonDocument("AuthorLimited", true), Builders<BsonDocument>.Update.Set("AuthorLimited", false), cancellationToken: token);
var moderated = new BsonDocument("$or", new BsonArray
{
new BsonDocument("SuspendedAt", new BsonDocument("$ne", BsonNull.Value)),
new BsonDocument("SilencedAt", new BsonDocument("$ne", BsonNull.Value))
});
// personas here: their own posts and boosts
var avatars = database.GetCollection<BsonDocument>("Avatar");
await avatars.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })),
Builders<BsonDocument>.Update.Set("AccountState", 0), cancellationToken: token);
foreach (var avatar in await (await avatars.FindAsync(moderated, cancellationToken: token)).ToListAsync(token))
{
var (suspended, silenced) = (IsSet(avatar, "SuspendedAt"), IsSet(avatar, "SilencedAt"));
await avatars.UpdateOneAsync(new BsonDocument("_id", avatar["_id"]), Builders<BsonDocument>.Update.Set("AccountState", suspended ? 2 : 1),
cancellationToken: token);
flagged += (await posts.UpdateManyAsync(new BsonDocument { { "GroupUserId", avatar["_id"].ToString() }, { "IsFederatedCopy", false } },
Builders<BsonDocument>.Update.Set("AuthorSuspended", suspended).Set("AuthorLimited", silenced), cancellationToken: token)).ModifiedCount;
}
// accounts elsewhere: what they wrote and boosted (an account that deleted itself stays Deleted)
var foreign = database.GetCollection<BsonDocument>("ForeignAvatar");
await foreign.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })),
Builders<BsonDocument>.Update.Set("AccountState", 0), cancellationToken: token);
foreach (var account in await (await foreign.FindAsync(moderated, cancellationToken: token)).ToListAsync(token))
{
if (account.GetValue("ActorURI", BsonNull.Value) is not BsonString actor)
continue;
var (suspended, silenced) = (IsSet(account, "SuspendedAt"), IsSet(account, "SilencedAt"));
if (account.GetValue("AccountState", 0).ToInt32() != 4)
await foreign.UpdateOneAsync(new BsonDocument("_id", account["_id"]), Builders<BsonDocument>.Update.Set("AccountState", suspended ? 2 : 1),
cancellationToken: token);
var update = Builders<BsonDocument>.Update.Set("AuthorSuspended", suspended);
if (silenced)
update = update.Set("AuthorLimited", true);
flagged += (await posts.UpdateManyAsync(new BsonDocument { { "ActorURI", actor }, { "IsFederatedCopy", true } }, update, cancellationToken: token))
.ModifiedCount;
}
// servers silenced: every post of theirs, and of their subdomains
var blocks = database.GetCollection<BsonDocument>("DomainBlock");
foreach (var block in await (await blocks.FindAsync(new BsonDocument("Severity", 1), cancellationToken: token)).ToListAsync(token))
if (block.GetValue("Domain", BsonNull.Value) is BsonString domain && !string.IsNullOrWhiteSpace(domain.AsString))
flagged += (await posts.UpdateManyAsync(new BsonDocument
{
{ "ActorURI", OfDomain(domain.AsString) },
{ "IsFederatedCopy", true }
}, Builders<BsonDocument>.Update.Set("AuthorLimited", true), cancellationToken: token)).ModifiedCount;
return flagged;
}
// an actor's address on a server or one of its subdomains
public static BsonRegularExpression OfDomain(string domain) =>
new($"^https?://([^/]*\\.)?{System.Text.RegularExpressions.Regex.Escape(Federation.Moderation.DomainBlocks.Normalise(domain))}(:[0-9]+)?(/|$)", "i");
static bool IsSet(BsonDocument row, string field) => row.TryGetValue(field, out var value) && !value.IsBsonNull;
}
}
+34 -3
View File
@@ -15,7 +15,8 @@ namespace PrivaPub.Domain.Moderation
// The staff's log (completion program R5): administrators read all of it, moderators what isn't about a login // The staff's log (completion program R5): administrators read all of it, moderators what isn't about a login
// (ModeratorController's /clientapi/moderator/log). An entry never holds an IP address, and never pairs a root with its // (ModeratorController's /clientapi/moderator/log). An entry never holds an IP address, and never pairs a root with its
// own personas: an act on a persona that the acting root owns is kept without who acted. // own personas: an act on a persona that the acting root owns, on a post of it, a group it owns or a report about it
// (R6), is kept without who acted.
public class ModerationLog : IModerationLog public class ModerationLog : IModerationLog
{ {
const int MaxLabel = 200; const int MaxLabel = 200;
@@ -33,8 +34,7 @@ namespace PrivaPub.Domain.Moderation
{ {
try try
{ {
if (targetKind == AdminTargets.Persona && byRootId != default && byRootId != AdminAction.Cli && targetId != default if (byRootId != default && byRootId != AdminAction.Cli && await OwnPersona(byRootId, targetKind, targetId, reportId, token))
&& await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == byRootId && r.AvatarId == targetId).ExecuteAnyAsync(token))
byRootId = default; byRootId = default;
await DB.Default.SaveAsync(new AdminAction await DB.Default.SaveAsync(new AdminAction
{ {
@@ -53,6 +53,37 @@ namespace PrivaPub.Domain.Moderation
} }
} }
// whether what an entry is about is a persona of the acting login: the persona itself, a post of it, a group it owns,
// or a report about it (R6) — any of them would pair the login with that persona
static async Task<bool> OwnPersona(string rootId, string targetKind, string targetId, string reportId, CancellationToken token)
{
var personas = new List<string>();
if (targetId != default)
switch (targetKind)
{
case AdminTargets.Persona:
personas.Add(targetId);
break;
case AdminTargets.Post when MongoDB.Bson.ObjectId.TryParse(targetId, out _):
if (await DB.Default.Find<Models.Post.Post>().MatchID(targetId).Match(p => !p.IsFederatedCopy)
.Project(p => p.Include(x => x.GroupUserId)).ExecuteFirstAsync(token) is { GroupUserId: { } author })
personas.Add(author);
break;
case AdminTargets.Group when MongoDB.Bson.ObjectId.TryParse(targetId, out _):
if (await DB.Default.Find<Models.Group.Group>().MatchID(targetId).Project(g => g.Include(x => x.OwnerAvatarId))
.ExecuteFirstAsync(token) is { OwnerAvatarId: { } owner })
personas.Add(owner);
break;
}
var report = reportId ?? (targetKind == AdminTargets.Report ? targetId : default);
if (report != default && MongoDB.Bson.ObjectId.TryParse(report, out _)
&& await DB.Default.Find<Models.Social.Report>().MatchID(report).Project(r => r.Include(x => x.TargetAccountId)).ExecuteFirstAsync(token)
is { TargetAccountId: { } reported })
personas.Add(reported);
return personas.Count > 0
&& await DB.Default.Find<RootToAvatar>().Match(r => r.RootId == rootId && personas.Contains(r.AvatarId)).ExecuteAnyAsync(token);
}
static string Cut(string text, int max) => static string Cut(string text, int max) =>
string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…"; string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…";
} }
+111
View File
@@ -0,0 +1,111 @@
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.Domain.Privacy;
using PrivaPub.Models.User;
namespace PrivaPub.Domain.Moderation
{
// Who the staff's moderation holds back right now, in memory (completion program R6), for the places that decide one
// item at a time and must not ask the database each time: a token's persona, a delivery's signer and inbox, a post
// shown or not, a stream. The posts of a suspended or silenced account carry their own flags (Post.AuthorSuspended,
// AuthorLimited) for queries; a banned login's personas never do, since flagging all of them at once would pair them
// in stored data, so they are suspended here alone (Counted.HeldBackPersonas).
//
// Read at start, again after every act that changes it (AccountModeration, a ban, a removal) and when five minutes old;
// a process that never loaded it (the CLI) starts with nobody held back and loads it on first use.
public static class Restrictions
{
static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5);
public sealed class Set
{
public static readonly Set Empty = new(new HashSet<string>(), new HashSet<string>(), new HashSet<string>(), new HashSet<string>());
public Set(IReadOnlySet<string> suspendedPersonas, IReadOnlySet<string> silencedPersonas, IReadOnlySet<string> suspendedActors,
IReadOnlySet<string> suspendedInboxes)
{
SuspendedPersonas = suspendedPersonas;
SilencedPersonas = silencedPersonas;
SuspendedActors = suspendedActors;
SuspendedInboxes = suspendedInboxes;
}
// suspended themselves, or held back with their login (banned or deleted)
public IReadOnlySet<string> SuspendedPersonas { get; }
public IReadOnlySet<string> SilencedPersonas { get; }
// accounts elsewhere the staff suspended: their actors' ids, and their own inboxes (never a shared one, which
// serves the rest of their server)
public IReadOnlySet<string> SuspendedActors { get; }
public IReadOnlySet<string> SuspendedInboxes { get; }
}
static volatile Set _current = Set.Empty;
static DateTime _loadedAt = DateTime.MinValue;
static int _reloading;
public static Set Current
{
get
{
RefreshIfStale();
return _current;
}
}
public static bool IsSuspended(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SuspendedPersonas.Contains(personaId);
public static bool IsSilenced(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SilencedPersonas.Contains(personaId);
public static bool IsSuspendedActor(string actorUri) => !string.IsNullOrEmpty(actorUri) && Current.SuspendedActors.Contains(actorUri);
public static bool IsSuspendedInbox(string inbox) => !string.IsNullOrEmpty(inbox) && Current.SuspendedInboxes.Contains(inbox);
public static async Task Reload(CancellationToken token = default)
{
var suspended = (await Counted.HeldBackPersonas(token)).ToHashSet(StringComparer.Ordinal);
var silenced = (await DB.Default.Find<Avatar>().Match(a => a.SilencedAt != null && a.DeletionAt == null)
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(StringComparer.Ordinal);
var actors = await DB.Default.Find<ForeignAvatar>().Match(f => f.SuspendedAt != null)
.Project(f => f.Include(x => x.ActorURI).Include(x => x.InboxURL).Include(x => x.SharedInboxURL)).ExecuteAsync(token);
// a single-user server's actor may name its server's shared inbox as its own: that one still serves the others there
var inboxes = actors.Where(a => !string.IsNullOrEmpty(a.InboxURL) && a.InboxURL != a.SharedInboxURL)
.Select(a => a.InboxURL).ToHashSet(StringComparer.Ordinal);
if (inboxes.Count > 0)
{
var candidates = inboxes.ToList();
var shared = await DB.Default.Find<ForeignAvatar>().Match(f => candidates.Contains(f.SharedInboxURL))
.Project(f => f.Include(x => x.SharedInboxURL)).ExecuteAsync(token);
inboxes.ExceptWith(shared.Select(f => f.SharedInboxURL));
}
_current = new Set(suspended, silenced,
actors.Select(a => a.ActorURI).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.Ordinal),
inboxes);
_loadedAt = DateTime.UtcNow;
}
// what a reload says, without waiting for it where waiting is not possible
public static void ReloadSoon() => _loadedAt = DateTime.MinValue;
static void RefreshIfStale()
{
if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1)
return;
_ = Task.Run(async () =>
{
try
{
await Reload(CancellationToken.None);
}
catch (Exception ex)
{
Serilog.Log.ForContext(typeof(Restrictions)).Warning(ex, "The moderation's restrictions could not be reloaded");
}
finally
{
Interlocked.Exchange(ref _reloading, 0);
}
});
}
}
}
@@ -328,6 +328,7 @@ namespace PrivaPub.Domain.Portability
ID = id, ID = id,
GroupUserId = me.Id, GroupUserId = me.Id,
AuthorAccountId = me.Id, AuthorAccountId = me.Id,
AuthorLimited = me.Silenced,
ActorURI = me.Uri, ActorURI = me.Uri,
Visibility = visibility, Visibility = visibility,
Title = Clean(Text(note["name"])), Title = Clean(Text(note["name"])),
@@ -492,6 +493,7 @@ namespace PrivaPub.Domain.Portability
ID = id, ID = id,
GroupUserId = run.Me.Id, GroupUserId = run.Me.Id,
AuthorAccountId = run.Me.Id, AuthorAccountId = run.Me.Id,
AuthorLimited = run.Me.Silenced,
ActorURI = run.Me.Uri, ActorURI = run.Me.Uri,
Visibility = PostVisibility.LocalGeo, Visibility = PostVisibility.LocalGeo,
IsLocalOnly = true, IsLocalOnly = true,
+11
View File
@@ -46,6 +46,17 @@ namespace PrivaPub.Domain.Privacy
: (await DB.Default.Find<RootToAvatar>().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList(); : (await DB.Default.Find<RootToAvatar>().Match(ra => roots.Contains(ra.RootId)).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
} }
// Personas nobody finds or is shown: the barred ones, and those the staff suspended (completion program R6). A
// suspended persona still counts among the users and its posts among the posts: the counts other servers read tell
// nothing new of it (owner decision 2026-10-17). Moderation's Restrictions holds the same set in memory.
public static async Task<List<string>> HeldBackPersonas(CancellationToken token)
{
var held = await BarredPersonas(token);
held.AddRange((await DB.Default.Find<Avatar>().Match(a => a.SuspendedAt != null).Project(a => a.Include(x => x.ID)).ExecuteAsync(token))
.Select(a => a.ID));
return held.Distinct().ToList();
}
public static async Task<long> Users(CancellationToken token) public static async Task<long> Users(CancellationToken token)
{ {
var barred = await BarredPersonas(token); var barred = await BarredPersonas(token);
+15 -4
View File
@@ -14,16 +14,27 @@ namespace PrivaPub.Domain.Privacy
{ {
public static class VisibilityPolicy public static class VisibilityPolicy
{ {
//neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03) //neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03), nor held
public static readonly Expression<Func<Post, bool>> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone; //back by the staff: its author suspended, or the post itself taken down (completion program R6)
public static readonly Expression<Func<Post, bool>> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended && !p.ModeratedAt.HasValue;
public static readonly Expression<Func<Post, bool>> IsPublic = p => public static readonly Expression<Func<Post, bool>> IsPublic = p =>
!p.DeletedAt.HasValue && !p.AuthorGone && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted); !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended && !p.ModeratedAt.HasValue
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted);
// where strangers browse (the public and hashtag timelines and streams, trends, search, the relays): never a post of a
// silenced author or server, which its followers still get and anyone may still open (R6)
public static readonly Expression<Func<Post, bool>> IsListed = p => !p.AuthorLimited;
static readonly Func<Post, bool> IsShownCompiled = IsShown.Compile(); static readonly Func<Post, bool> IsShownCompiled = IsShown.Compile();
static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile(); static readonly Func<Post, bool> IsPublicCompiled = IsPublic.Compile();
public static bool Shown(Post post) => post != default && IsShownCompiled(post); // a banned login's personas are suspended in Restrictions alone, never on their posts, so a post read one at a time
// is asked there too; a query that lists posts leaves theirs to the Mastodon mapper, which shows no account of theirs
public static bool Shown(Post post) => post != default && IsShownCompiled(post) && !Barred(post);
public static bool Barred(Post post) =>
!post.IsFederatedCopy && !string.IsNullOrEmpty(post.GroupUserId) && Moderation.Restrictions.IsSuspended(post.GroupUserId);
public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token) public static async Task<bool> CanSee(Post post, string viewerAvatarId, CancellationToken token)
{ {
+2 -1
View File
@@ -336,7 +336,8 @@ namespace PrivaPub.Domain.Social
return default; return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token)) if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default; return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token); // a suspended persona does nothing through the client API either (R6)
return await _localActors.FindById(LocalActorKind.Person, avatarId, token) is { Suspended: false } persona ? persona : default;
} }
static ViewFollowing ToView(Following following) => new() static ViewFollowing ToView(Following following) => new()
+27
View File
@@ -47,5 +47,32 @@ namespace PrivaPub.Domain.Social
{ {
} }
} }
// A warning from this server's staff (completion program R6): Mastodon's moderation_warning, from the persona itself as
// Mastodon's are (an AccountWarning has no account behind it), never judged by the notification policy, one for each
// act. A suspended persona reads it once its suspension is lifted; until then its login sees it suspended in its list
// of personas (/clientapi/avatar/private/list).
public static async Task Warn(string avatarId, string personaUri, string action, string text, IReadOnlyList<string> postIds, CancellationToken token)
{
if (string.IsNullOrEmpty(avatarId))
return;
var ids = postIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List<string>();
var notification = new Notification
{
AvatarId = avatarId,
Type = NotificationType.ModerationWarning,
FromAccountId = avatarId,
FromActorURI = personaUri,
PostId = ids.FirstOrDefault(),
StatusIds = ids.Count > 1 ? ids : null,
Text = string.IsNullOrWhiteSpace(text) ? default : text.Trim(),
WarningAction = action,
DedupeKey = $"{NotificationType.ModerationWarning}|{avatarId}|staff|{MongoDB.Bson.ObjectId.GenerateNewId()}"
};
await DB.Default.SaveAsync(notification, CancellationToken.None);//an act that was done is told even when its request went away
var told = new Timelines.Streams.Event("notification", notification.ID);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
}
} }
} }
+7 -3
View File
@@ -67,6 +67,7 @@ namespace PrivaPub.Domain.Social
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly .Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
&& p.ReblogOfPostId == null && p.InReplyToURI == null && p.AnsweringToPostId == null && p.CreationDate >= since && p.ReblogOfPostId == null && p.InReplyToURI == null && p.AnsweringToPostId == null && p.CreationDate >= since
&& p.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0) && p.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0)
.Match(Privacy.VisibilityPolicy.IsPublic).Match(Privacy.VisibilityPolicy.IsListed)
.ExecuteAsync(token); .ExecuteAsync(token);
var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token); var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token);
return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId)) return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId))
@@ -80,6 +81,7 @@ namespace PrivaPub.Domain.Social
return _dbEntities.Posts return _dbEntities.Posts
.Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly .Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly
&& p.ReblogOfPostId == null && p.CreationDate >= since) && p.ReblogOfPostId == null && p.CreationDate >= since)
.Match(Privacy.VisibilityPolicy.IsPublic).Match(Privacy.VisibilityPolicy.IsListed)
.Match(with) .Match(with)
.Project(p => p.Include(x => x.ID).Include(x => x.Tags).Include(x => x.Link).Include(x => x.CreationDate) .Project(p => p.Include(x => x.ID).Include(x => x.Tags).Include(x => x.Link).Include(x => x.CreationDate)
.Include(x => x.AuthorAccountId).Include(x => x.GroupUserId)) .Include(x => x.AuthorAccountId).Include(x => x.GroupUserId))
@@ -115,10 +117,12 @@ namespace PrivaPub.Domain.Social
static async Task<HashSet<string>> Discoverable(IEnumerable<string> accountIds, CancellationToken token) static async Task<HashSet<string>> Discoverable(IEnumerable<string> accountIds, CancellationToken token)
{ {
var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList();
var barred = await Privacy.Counted.BarredPersonas(token); var barred = await Privacy.Counted.HeldBackPersonas(token);
var local = await DB.Default.Find<Avatar>().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) var local = await DB.Default.Find<Avatar>().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)
&& !a.SilencedAt.HasValue)
.Project(a => a.Include(x => x.ID)).ExecuteAsync(token); .Project(a => a.Include(x => x.ID)).ExecuteAsync(token);
var remote = await DB.Default.Find<ForeignAvatar>().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue) var remote = await DB.Default.Find<ForeignAvatar>().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue
&& !f.SilencedAt.HasValue)
.Project(f => f.Include(x => x.ID)).ExecuteAsync(token); .Project(f => f.Include(x => x.ID)).ExecuteAsync(token);
return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet(); return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet();
} }
@@ -105,6 +105,11 @@ namespace PrivaPub.Domain.Statuses
if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30)) if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30))
return JobOutcome.Done;//dropped, or moved to a later time that has its own job return JobOutcome.Done;//dropped, or moved to a later time that has its own job
using var scope = _scopes.CreateScope(); using var scope = _scopes.CreateScope();
// a persona suspended since, or whose login was banned, waits: lifting it publishes what came due meanwhile
// (R6), unless that was a month ago
if (Moderation.Restrictions.IsSuspended(scheduled.AvatarId) && scheduled.ScheduledAt > DateTime.UtcNow.AddDays(-30)
&& await DB.Default.Find<Avatar>().Match(a => a.ID == scheduled.AvatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token))
return JobOutcome.Defer(DateTime.UtcNow.AddHours(1), "its author is suspended");
// a persona deleted or banned since it was scheduled publishes nothing, and what it held is trashed // a persona deleted or banned since it was scheduled publishes nothing, and what it held is trashed
if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token)) if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token))
{ {
@@ -203,6 +203,7 @@ namespace PrivaPub.Domain.Statuses
{ {
GroupUserId = author.Id, GroupUserId = author.Id,
AuthorAccountId = author.Id, AuthorAccountId = author.Id,
AuthorLimited = author.Silenced,//a silenced persona's posts stay out of the public timelines (R6)
GroupId = group?.Id, GroupId = group?.Id,
Visibility = visibility, Visibility = visibility,
Title = Clean(draft.Title), Title = Clean(draft.Title),
@@ -621,6 +622,7 @@ namespace PrivaPub.Domain.Statuses
ID = reblogId, ID = reblogId,
GroupUserId = me.Id, GroupUserId = me.Id,
AuthorAccountId = me.Id, AuthorAccountId = me.Id,
AuthorLimited = me.Silenced,
ReblogOfPostId = original.ID, ReblogOfPostId = original.ID,
Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public, Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public,
ObjectURI = announceId, ObjectURI = announceId,
+5 -4
View File
@@ -57,8 +57,9 @@ namespace PrivaPub.Domain.Timelines
foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token)) foreach (var follower in await _dbEntities.Followings.Match(f => f.TargetActorURI == post.WallOwnerURI && f.State == FollowState.Accepted).ExecuteAsync(token))
recipients.Add(follower.AvatarId); recipients.Add(follower.AvatarId);
} }
// a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon // a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon,
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId) // but a silenced author's (R6)
if (post.Visibility == PostVisibility.Public && post.Tags.Count > 0 && string.IsNullOrEmpty(post.ReblogOfPostId) && !post.AuthorLimited
&& string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId)) && string.IsNullOrEmpty(post.InReplyToURI) && string.IsNullOrEmpty(post.AnsweringToPostId))
{ {
var tags = post.Tags.ToList(); var tags = post.Tags.ToList();
@@ -126,7 +127,7 @@ namespace PrivaPub.Domain.Timelines
Streams.ToPersona(avatarId, new Streams.Key(Streams.List, listId), update); Streams.ToPersona(avatarId, new Streams.Key(Streams.List, listId), update);
} }
} }
if (post.Visibility != PostVisibility.Public || !string.IsNullOrEmpty(post.ReblogOfPostId) || !Privacy.VisibilityPolicy.Shown(post)) if (post.Visibility != PostVisibility.Public || !string.IsNullOrEmpty(post.ReblogOfPostId) || !Privacy.VisibilityPolicy.Shown(post) || post.AuthorLimited)
return; return;
var local = !post.IsFederatedCopy; var local = !post.IsFederatedCopy;
if (!post.IsLocalOnly) if (!post.IsLocalOnly)
@@ -177,7 +178,7 @@ namespace PrivaPub.Domain.Timelines
var homes = (await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAsync(token)).Select(e => e.AvatarId).Distinct(); var homes = (await DB.Default.Find<TimelineEntry>().Match(e => e.PostId == post.ID).ExecuteAsync(token)).Select(e => e.AvatarId).Distinct();
foreach (var avatarId in homes.Where(Streams.Listening)) foreach (var avatarId in homes.Where(Streams.Listening))
Streams.ToPersona(avatarId, new Streams.Key(Streams.User), update); Streams.ToPersona(avatarId, new Streams.Key(Streams.User), update);
if (post.Visibility == PostVisibility.Public && Privacy.VisibilityPolicy.Shown(post)) if (post.Visibility == PostVisibility.Public && Privacy.VisibilityPolicy.Shown(post) && !post.AuthorLimited)
{ {
if (!post.IsLocalOnly) if (!post.IsLocalOnly)
Streams.ToAll(new Streams.Key(Streams.Public), update); Streams.ToAll(new Streams.Key(Streams.Public), update);
+7
View File
@@ -73,6 +73,13 @@ namespace PrivaPub.Domain.Timelines
listener.Queue.Writer.TryComplete(); listener.Queue.Writer.TryComplete();
} }
// every connection of a persona ends (its suspension, R6): its queue completes, and the connection closes behind it
public static void CloseFor(string avatarId)
{
foreach (var listener in Listeners.Keys.Where(l => l.AvatarId == avatarId).ToList())
Close(listener);
}
public static int Count => Listeners.Count; public static int Count => Listeners.Count;
// whether the persona has a connection open, so the work of finding its lists can be skipped when it has none // whether the persona has a connection open, so the work of finding its lists can be skipped when it has none
@@ -34,6 +34,11 @@ namespace PrivaPub.Federation.Actors
public bool PostingRestrictedToModerators { get; init; } public bool PostingRestrictedToModerators { get; init; }
public bool IsBot { get; init; } public bool IsBot { get; init; }
public bool Indexable { get; init; } public bool Indexable { get; init; }
// the staff's moderation of a persona (completion program R6): suspended (by itself or with its banned login), it
// delivers nothing, its posts and collections answer 404 and its tokens are refused, while its actor stays as it was;
// silenced, its posts stay out of the public timelines, trends, search and the relays
public bool Suspended { get; init; }
public bool Silenced { get; init; }
public AvatarSettings Settings { get; init; } = new(); public AvatarSettings Settings { get; init; } = new();
public DateTime Published { get; init; } public DateTime Published { get; init; }
public string BaseAddress { get; init; } public string BaseAddress { get; init; }
@@ -335,6 +340,8 @@ namespace PrivaPub.Federation.Actors
IsBot = avatar.Settings?.IsBot == true, IsBot = avatar.Settings?.IsBot == true,
Indexable = avatar.Settings?.IsIndexable == true, Indexable = avatar.Settings?.IsIndexable == true,
Settings = avatar.Settings ?? new AvatarSettings(), Settings = avatar.Settings ?? new AvatarSettings(),
Suspended = avatar.SuspendedAt.HasValue || Domain.Moderation.Restrictions.IsSuspended(avatar.ID),
Silenced = avatar.SilencedAt.HasValue,
BaseAddress = BaseAddress BaseAddress = BaseAddress
}; };
@@ -110,6 +110,8 @@ namespace PrivaPub.Federation.Controllers
var items = new List<JsonNode>(); var items = new List<JsonNode>();
foreach (var post in latest) foreach (var post in latest)
{ {
if (VisibilityPolicy.Barred(post))
continue;
if (local.Kind == LocalActorKind.Group) if (local.Kind == LocalActorKind.Group)
{ {
items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate)); items.Add(ActivityPubRenderer.Announce(local, post.ObjectURI, $"announce-{post.ID}", post.CreationDate));
@@ -152,7 +154,8 @@ namespace PrivaPub.Federation.Controllers
if (local is not { HasWall: true }) if (local is not { HasWall: true })
return NotFound(); return NotFound();
var wall = local.Wall; var wall = local.Wall;
System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly System.Linq.Expressions.Expression<Func<PostEntity, bool>> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended
&& !p.ModeratedAt.HasValue && !p.IsLocalOnly
&& (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted) && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted)
&& (p.WallURI == wall && (p.WallURI == wall
|| p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null); || p.GroupUserId == local.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.AnsweringToPostId == null && p.InReplyToURI == null && p.GroupId == null);
@@ -282,6 +285,7 @@ namespace PrivaPub.Federation.Controllers
return NotFound(); return NotFound();
var replies = await _dbEntities.Posts.Match(p => p.AnsweringToPostId == post.ID && !p.IsLocalOnly && p.ReblogOfPostId == null) var replies = await _dbEntities.Posts.Match(p => p.AnsweringToPostId == post.ID && !p.IsLocalOnly && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread).ExecuteAsync(token); .Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread).ExecuteAsync(token);
replies.RemoveAll(VisibilityPolicy.Barred);
return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count, return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count,
replies.Select(r => (JsonNode)r.ObjectURI))); replies.Select(r => (JsonNode)r.ObjectURI)));
} }
@@ -299,7 +303,7 @@ namespace PrivaPub.Federation.Controllers
{ {
var next = await _dbEntities.Posts.Match(p => level.Contains(p.AnsweringToPostId) && !p.IsLocalOnly && p.ReblogOfPostId == null) var next = await _dbEntities.Posts.Match(p => level.Contains(p.AnsweringToPostId) && !p.IsLocalOnly && p.ReblogOfPostId == null)
.Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread - thread.Count).ExecuteAsync(token); .Match(VisibilityPolicy.IsPublic).Sort(p => p.CreationDate, Order.Ascending).Limit(MaxThread - thread.Count).ExecuteAsync(token);
thread.AddRange(next); thread.AddRange(next.Where(p => !VisibilityPolicy.Barred(p)));
level = next.Select(p => p.ID).ToList(); level = next.Select(p => p.ID).ToList();
} }
var collection = ActivityPubRenderer.OrderedCollection(root.ContextURI, thread.Count, thread.Select(p => (JsonNode)p.ObjectURI)); var collection = ActivityPubRenderer.OrderedCollection(root.ContextURI, thread.Count, thread.Select(p => (JsonNode)p.ObjectURI));
@@ -541,6 +545,15 @@ namespace PrivaPub.Federation.Controllers
context.Result = StatusCode(StatusCodes.Status401Unauthorized); context.Result = StatusCode(StatusCodes.Status401Unauthorized);
return; return;
} }
// a suspended persona (by itself or with its banned login) answers 404 for everything but its actor and its inbox:
// nothing new is told of it, and lifting the suspension serves it all again (owner decision 2026-10-17)
if (HttpMethods.IsGet(Request.Method) && context.RouteData.Values["actor"] is string name
&& context.ActionDescriptor is Microsoft.AspNetCore.Mvc.Controllers.ControllerActionDescriptor { ActionName: not nameof(GetActor) }
&& await _localActors.FindByUserName(name, HttpContext.RequestAborted) is { Kind: LocalActorKind.Person, Suspended: true })
{
context.Result = NotFound();
return;
}
await next(); await next();
} }
@@ -39,10 +39,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
public IEnumerable<IActivityHandler> Relays { get; set; } public IEnumerable<IActivityHandler> Relays { get; set; }
readonly PrivaPub.Federation.Relays.IRelays _relayService; readonly PrivaPub.Federation.Relays.IRelays _relayService;
readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks;
public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors, public AnnounceHandler(DbEntities dbEntities, ILocalActorService localActors, IRemotePosts remotePosts, IFanout fanout, IRemoteActorService remoteActors,
IObjectRecords records, IQuoteService quotes, IServiceProvider services = default, PrivaPub.Federation.Relays.IRelays relays = default) IObjectRecords records, IQuoteService quotes, IServiceProvider services = default, PrivaPub.Federation.Relays.IRelays relays = default,
PrivaPub.Federation.Moderation.IDomainBlocks domainBlocks = default)
{ {
_domainBlocks = domainBlocks;
_relayService = relays; _relayService = relays;
_services = services; _services = services;
_records = records; _records = records;
@@ -121,6 +124,8 @@ namespace PrivaPub.Federation.Inbox.Handlers
To = to, To = to,
Cc = cc, Cc = cc,
IsFederatedCopy = true, IsFederatedCopy = true,
AuthorSuspended = actor.SuspendedAt.HasValue,
AuthorLimited = RemotePosts.Limited(actor, _domainBlocks),
CreationDate = published, CreationDate = published,
UpdateDate = published UpdateDate = published
}; };
@@ -49,6 +49,13 @@ namespace PrivaPub.Federation.Inbox.Handlers
Arrival.Drop("unknown-recipient"); Arrival.Drop("unknown-recipient");
return; return;
} }
// a suspended persona answers nothing, so it takes no follower it could not answer (R6): the request stays
// pending on the follower's side, as it would for any persona that has not answered yet
if (target is { Kind: LocalActorKind.Person, Suspended: true })
{
Arrival.Drop("recipient-suspended");
return;
}
if (target.Kind == LocalActorKind.Person if (target.Kind == LocalActorKind.Person
&& await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) && await DB.Default.Find<Models.Social.Block>().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token))
{ {
@@ -65,6 +65,13 @@ namespace PrivaPub.Federation.Inbox
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable"); Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable");
return JobOutcome.Retry("the actor could not be loaded"); return JobOutcome.Retry("the actor could not be loaded");
} }
// an account the staff suspended (R6): what it sends is dropped, but a Delete or an Undo of what it sent, which only
// take away what we hold (a post, a follow, a like)
if (actor.SuspendedAt.HasValue && type is not ("Delete" or "Undo"))
{
Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Dropped, "account-suspended");
return JobOutcome.Done;
}
if (payload.ForwardedBy != default && Forwarded.NamesUnknownKey(activity, actor)) if (payload.ForwardedBy != default && Forwarded.NamesUnknownKey(activity, actor))
actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor; actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor;
if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor)) if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor))
+8
View File
@@ -133,6 +133,9 @@ namespace PrivaPub.Federation.Inbox
InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId,
IsFederatedCopy = true, IsFederatedCopy = true,
AuthorGone = author.AccountState == AvatarAccountState.Deleted, AuthorGone = author.AccountState == AvatarAccountState.Deleted,
// the staff's moderation of its author or its server (R6), as AccountModeration sets it on what came before
AuthorSuspended = author.SuspendedAt.HasValue,
AuthorLimited = Limited(author, _domainBlocks),
CreationDate = note.Published, CreationDate = note.Published,
UpdateDate = note.Updated ?? note.Published, UpdateDate = note.Updated ?? note.Published,
EditedAt = note.Updated, EditedAt = note.Updated,
@@ -143,6 +146,11 @@ namespace PrivaPub.Federation.Inbox
return post; return post;
} }
// an account elsewhere is limited when the staff silenced it or its server
public static bool Limited(ForeignAvatar author, IDomainBlocks domainBlocks) =>
author.SilencedAt.HasValue || Uri.TryCreate(author.ActorURI, UriKind.Absolute, out var uri)
&& domainBlocks?.Find(uri.Host)?.Severity == DomainBlockSeverity.Silence;
// a post's flairs, filled in from its community's list // a post's flairs, filled in from its community's list
public static async Task<List<PostFlair>> Flairs(NoteDocument note, string communityUri, CancellationToken token) public static async Task<List<PostFlair>> Flairs(NoteDocument note, string communityUri, CancellationToken token)
{ {
+24 -1
View File
@@ -43,12 +43,24 @@ namespace PrivaPub.Federation.Outbox
_queue = queue; _queue = queue;
} }
// what the staff's moderation holds back (completion program R6): a suspended persona (by itself or with its banned
// login) delivers nothing, but the Delete of itself, which its deletion sends; and nothing goes to the own inbox of an
// account elsewhere the staff suspended (its server's shared inbox still serves the others there)
public static bool HeldBack(LocalActor signer, JsonNode activity) =>
signer is { Kind: LocalActorKind.Person, Suspended: true } && !DeletesItself(signer, activity);
static bool DeletesItself(LocalActor signer, JsonNode activity) =>
ActivityJson.Value(activity, "type") == "Delete" && ActivityJson.Id(activity?["object"]) == signer.Uri;
public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token, string again = default) public async Task Enqueue(LocalActor signer, IEnumerable<string> inboxes, JsonObject activity, CancellationToken token, string again = default)
{ {
if (HeldBack(signer, activity))
return;
var body = activity.ToJsonString(); var body = activity.ToJsonString();
var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default; var activityId = activity["id"] is JsonValue id && id.TryGetValue<string>(out var text) ? text : default;
var targets = inboxes var targets = inboxes
.Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)) .Where(i => !string.IsNullOrEmpty(i) && !i.StartsWith(signer.BaseAddress + "/", StringComparison.OrdinalIgnoreCase)
&& !Domain.Moderation.Restrictions.IsSuspendedInbox(i))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToList(); .ToList();
// what goes to a relay carries the signer's proof (FEP-8b32): the relay passes it on as it came, signed with its own // what goes to a relay carries the signer's proof (FEP-8b32): the relay passes it on as it came, signed with its own
@@ -223,6 +235,17 @@ namespace PrivaPub.Federation.Outbox
attempt.Reason = "signer-gone"; attempt.Reason = "signer-gone";
return JobOutcome.Dead("the signing actor no longer exists"); return JobOutcome.Dead("the signing actor no longer exists");
} }
// suspended since it was queued, or the account it goes to (R6): never sent
if (signer.Suspended && DeliveryService.HeldBack(signer, JsonNode.Parse(payload.Body)))
{
attempt.Reason = "signer-suspended";
return JobOutcome.Dead("the signing persona is suspended");
}
if (Domain.Moderation.Restrictions.IsSuspendedInbox(payload.Inbox))
{
attempt.Reason = "inbox-suspended";
return JobOutcome.Dead("the inbox's account is suspended");
}
var body = Encoding.UTF8.GetBytes(payload.Body); var body = Encoding.UTF8.GetBytes(payload.Body);
using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) }; using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) };
@@ -52,9 +52,11 @@ namespace PrivaPub.Federation.Outbox
//nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that) //nothing is addressed to an account that blocked the author (it would refuse it, and Mastodon counts on that)
var blockers = (await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id).ExecuteAsync(token)) var blockers = (await DB.Default.Find<BlockedBy>().Match(b => b.AvatarId == author.Id).ExecuteAsync(token))
.Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal); .Select(b => b.ActorURI).ToHashSet(StringComparer.Ordinal);
//nor to an account elsewhere the staff suspended (R6), through its server's shared inbox either
var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI) var addressed = post.Mentions.Where(m => !m.IsLocal).Select(m => m.ActorURI)
.Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>()) .Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty<string>())
.Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri)) .Where(uri => !uri.StartsWith(author.BaseAddress + "/", StringComparison.OrdinalIgnoreCase) && !blockers.Contains(uri)
&& !Domain.Moderation.Restrictions.IsSuspendedActor(uri))
.Distinct(StringComparer.Ordinal) .Distinct(StringComparer.Ordinal)
.ToList(); .ToList();
foreach (var uri in addressed) foreach (var uri in addressed)
@@ -67,7 +69,7 @@ namespace PrivaPub.Federation.Outbox
if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId)) if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId))
{ {
var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token); var parentAuthor = await _dbEntities.ForeignAvatars.MatchID(post.InReplyToAccountId).ExecuteFirstAsync(token);
if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI)) if (Preferred(parentAuthor) is { } inbox && !blockers.Contains(parentAuthor.ActorURI) && !parentAuthor.SuspendedAt.HasValue)
inboxes.Add(inbox); inboxes.Add(inbox);
} }
@@ -77,14 +79,15 @@ namespace PrivaPub.Federation.Outbox
var quotedAuthor = quoted is { IsFederatedCopy: true } var quotedAuthor = quoted is { IsFederatedCopy: true }
? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token) ? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token)
: default; : default;
if (Preferred(quotedAuthor) is { } inbox) if (Preferred(quotedAuthor) is { } inbox && !quotedAuthor.SuspendedAt.HasValue)
inboxes.Add(inbox); inboxes.Add(inbox);
} }
// a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision // a persona's own public post, outside any group, also goes to the relays we subscribe to (owner decision
// 2026-10-06), its edits and deletion with it // 2026-10-06), its edits and deletion with it; a silenced persona's never does (R6): the relays are where strangers
// read
if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person if (_relays != default && post.Visibility == PostVisibility.Public && author.Kind == LocalActorKind.Person
&& string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId)) && string.IsNullOrEmpty(post.ReblogOfPostId) && string.IsNullOrEmpty(post.GroupId) && !post.AuthorLimited && !author.Silenced)
inboxes.AddRange(await _relays.Inboxes(token)); inboxes.AddRange(await _relays.Inboxes(token));
return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList();
@@ -10,7 +10,8 @@ namespace PrivaPub.Infrastructure.Backup
// - blocks, mutes, domain blocks (the server's and the personas'), being blocked, reserved names, deletion tombstones, // - blocks, mutes, domain blocks (the server's and the personas'), being blocked, reserved names, deletion tombstones,
// reports, filters and OAuth applications are the union of both, P's row winning; // reports, filters and OAuth applications are the union of both, P's row winning;
// - deletions win: a root, persona, group, post or remote account deleted since is deleted again (P's row, as its // - deletions win: a root, persona, group, post or remote account deleted since is deleted again (P's row, as its
// deletion left it), with what a deletion takes away; P's moderation of a persona (silenced, suspended, banned) stays; // deletion left it), with what a deletion takes away; P's moderation of a persona or of an account elsewhere (silenced,
// suspended) stays, as do the posts the staff took down, and every post's moderation flags are worked out again;
// - a root keeps P's password, e-mail, ban and policies; // - a root keeps P's password, e-mail, ban and policies;
// - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer // - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer
// 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them), // 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them),
@@ -103,9 +104,12 @@ namespace PrivaPub.Infrastructure.Backup
await database.GetCollection<BsonDocument>(collection).DeleteManyAsync(new BsonDocument("AvatarId", avatar["_id"].ToString()), token); await database.GetCollection<BsonDocument>(collection).DeleteManyAsync(new BsonDocument("AvatarId", avatar["_id"].ToString()), token);
continue; continue;
} }
var moderated = new[] { "SilencedAt", "SuspendedAt", "BannedAt" }.Where(field => IsSet(avatar, field)).ToArray(); var moderated = new[] { "SilencedAt", "SuspendedAt", "BannedAt", "ModerationNote" }.Where(field => IsSet(avatar, field)).ToArray();
if (moderated.Length > 0) if (moderated.Length > 0)
{
await avatars.UpdateOneAsync(Id(avatar["_id"]), Copy(avatar, moderated), cancellationToken: token); await avatars.UpdateOneAsync(Id(avatar["_id"]), Copy(avatar, moderated), cancellationToken: token);
report.ProtectiveKept++;
}
} }
var links = database.GetCollection<BsonDocument>("RootToAvatar"); var links = database.GetCollection<BsonDocument>("RootToAvatar");
@@ -131,13 +135,27 @@ namespace PrivaPub.Infrastructure.Backup
await groups.ReplaceOneAsync(Id(group["_id"]), group, cancellationToken: token); await groups.ReplaceOneAsync(Id(group["_id"]), group, cancellationToken: token);
} }
// remote accounts deleted or gone since: as P has them // remote accounts deleted or gone since: as P has them; the staff's silence or suspension of one stays (R6)
var foreign = database.GetCollection<BsonDocument>("ForeignAvatar"); var foreign = database.GetCollection<BsonDocument>("ForeignAvatar");
foreach (var account in P("ForeignAvatar").Where(a => IsSet(a, "DeletionAt") || IsSet(a, "ForgottenAt"))) foreach (var account in P("ForeignAvatar"))
{
if (IsSet(account, "DeletionAt") || IsSet(account, "ForgottenAt"))
{
await foreign.ReplaceOneAsync(Id(account["_id"]), account, cancellationToken: token); await foreign.ReplaceOneAsync(Id(account["_id"]), account, cancellationToken: token);
continue;
}
var moderated = new[] { "SilencedAt", "SuspendedAt", "ModerationNote" }.Where(field => IsSet(account, field)).ToArray();
if (moderated.Length > 0 && (await foreign.UpdateOneAsync(Id(account["_id"]), Copy(account, moderated), cancellationToken: token)).MatchedCount > 0)
report.ProtectiveKept++;
}
await Posts(database, P("Post"), report, token); await Posts(database, P("Post"), report, token);
await Media(database, P("MediaAttachment"), report, token); await Media(database, P("MediaAttachment"), report, token);
// what the staff took down stays down, and every post's flags follow the moderation now kept (R6)
foreach (var post in P("Post").Where(p => IsSet(p, "ModeratedAt")))
await database.GetCollection<BsonDocument>("Post").UpdateOneAsync(
new BsonDocument { { "_id", post["_id"] }, { "ModeratedAt", BsonNull.Value } }, Copy(post, "ModeratedAt"), cancellationToken: token);
await Domain.Moderation.ModerationFlags.Recompute(database, token);
// every session ends: a new stamp for each root's tokens, and the personas' OAuth tokens go // every session ends: a new stamp for each root's tokens, and the personas' OAuth tokens go
foreach (var root in await (await roots.FindAsync(FilterDefinition<BsonDocument>.Empty, cancellationToken: token)).ToListAsync(token)) foreach (var root in await (await roots.FindAsync(FilterDefinition<BsonDocument>.Empty, cancellationToken: token)).ToListAsync(token))
+22 -9
View File
@@ -19,6 +19,7 @@ namespace PrivaPub.Infrastructure.Cli
{ {
const string Usage = """ const string Usage = """
usage: PrivaPub admin promote|demote <root> usage: PrivaPub admin promote|demote <root>
PrivaPub admin role <root> <user|moderator|admin> a login's role; promote and demote are role admin and user
PrivaPub admin create-root <login> [--admin] the password is read from standard input PrivaPub admin create-root <login> [--admin] the password is read from standard input
PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end PrivaPub admin move-persona <persona> <root> gives a persona to another login; its Mastodon tokens end
PrivaPub admin delete-root <root> deletes a login and every persona it still has PrivaPub admin delete-root <root> deletes a login and every persona it still has
@@ -47,7 +48,9 @@ namespace PrivaPub.Infrastructure.Cli
switch (args) switch (args)
{ {
case [("promote" or "demote") and var verb, var userName]: case [("promote" or "demote") and var verb, var userName]:
return await Promote(services, verb == "promote", userName, output); return await Role(services, userName, verb == "promote" ? RootRole.Admin : RootRole.User, output);
case ["role", var userName, var role] when RootUser.TryParseRole(role, out var parsed):
return await Role(services, userName, parsed, output);
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"): case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output); return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
case ["move-persona", var persona, var root]: case ["move-persona", var persona, var root]:
@@ -194,27 +197,37 @@ namespace PrivaPub.Infrastructure.Cli
static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) => static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) =>
services.GetRequiredService<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note); services.GetRequiredService<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note);
static async Task<int> Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false) // The command line is the way back, so it may leave the server without an administrator (the client's route refuses
// to); it says so, and `role <root> admin` gives one back.
static async Task<int> Role(IServiceProvider services, string userName, RootRole role, TextWriter output, bool created = false)
{ {
userName = userName.ToLowerInvariant(); userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync(); var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync();
if (user == default) if (user == default)
{ {
Console.Error.WriteLine($"no root user '{userName}'"); Console.Error.WriteLine($"no root user '{userName}'");
return 1; return 1;
} }
user.SetAdmin(promote); var was = user.Role;
await DB.Default.SaveAsync(user); user.SetRole(role);
if (promote) await DB.Default.Update<RootUser>().MatchID(user.ID).Modify(u => u.Policies, user.Policies).Modify(u => u.UpdatedAt, user.UpdatedAt).ExecuteAsync();
if (role == RootRole.Admin)
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
if (!created) if (!created && was != role)
await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName); await Log(services, role == RootRole.Admin && was == RootRole.User ? AdminActions.RootPromote
: role == RootRole.User && was == RootRole.Admin ? AdminActions.RootDemote
: AdminActions.RootRole, AdminTargets.Root, user.ID, userName, $"{Name(was)} → {Name(role)}");
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}"); output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
if (was == RootRole.Admin && role != RootRole.Admin
&& !await DB.Default.Find<RootUser>().Match(u => u.DeletedAt == null && !u.IsBanned && u.Policies.Contains(Policies.IsAdmin)).ExecuteAnyAsync())
Console.Error.WriteLine("no administrator is left: `PrivaPub admin role <root> admin` makes one");
return 0; return 0;
} }
static string Name(RootRole role) => role switch { RootRole.Admin => "admin", RootRole.Moderator => "moderator", _ => "user" };
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations // With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
// make the rest. // make the rest.
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output) static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
@@ -234,7 +247,7 @@ namespace PrivaPub.Infrastructure.Cli
} }
var made = await DB.Default.Find<RootUser>().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync(); var made = await DB.Default.Find<RootUser>().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync();
await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default); await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default);
return admin ? await Promote(services, true, login, output, created: true) : await Report(login, output); return admin ? await Role(services, login, RootRole.Admin, output, created: true) : await Report(login, output);
} }
static Task<int> Report(string login, TextWriter output) static Task<int> Report(string login, TextWriter output)
+8
View File
@@ -62,6 +62,14 @@ namespace PrivaPub.Infrastructure.Data
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token); await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token);
await Plain<ForeignAvatar>(token, a => a.PublicKeyId); await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
// the staff's moderation (R6): the moderated accounts, here and elsewhere, and the posts taken down
await Plain<ForeignAvatar>(token, a => a.SuspendedAt);
await Plain<ForeignAvatar>(token, a => a.SilencedAt);
await Plain<Avatar>(token, a => a.SuspendedAt);
await Plain<Avatar>(token, a => a.SilencedAt);
await DB.Default.Index<Post>().Key(p => p.ModeratedAt, KeyType.Ascending)
.Option(o => o.PartialFilterExpression = Builders<Post>.Filter.Type(p => p.ModeratedAt, MongoDB.Bson.BsonType.DateTime))
.CreateAsync(token);
await DB.Default.Index<Follower>() await DB.Default.Index<Follower>()
.Key(f => f.LocalActorId, KeyType.Ascending) .Key(f => f.LocalActorId, KeyType.Ascending)
@@ -0,0 +1,13 @@
using MongoDB.Entities;
namespace PrivaPub.Infrastructure.Data.Migrations
{
// Posts carry their author's moderation since completion program R6 (Post.AuthorLimited, AuthorSuspended): the posts
// already held from servers silenced before then are limited, as those arriving from now on are, and any account
// already silenced or suspended gets the same (Domain/Moderation/ModerationFlags.cs, which a restore runs too)
public class _020_silenced_servers_limit_their_posts : IMigration
{
public async Task UpgradeAsync() =>
await Domain.Moderation.ModerationFlags.Recompute(DB.Default.Database(), CancellationToken.None);
}
}
@@ -230,6 +230,9 @@ namespace PrivaPub.Middleware
.AddTransient<IStatusService, StatusService>() .AddTransient<IStatusService, StatusService>()
.AddTransient<IRelationshipService, RelationshipService>() .AddTransient<IRelationshipService, RelationshipService>()
.AddTransient<IReportService, ReportService>() .AddTransient<IReportService, ReportService>()
.AddTransient<Domain.Moderation.IAccountModeration, Domain.Moderation.AccountModeration>()
.AddScoped<IPersonaRemoval, PersonaRemoval>()
.AddScoped<IGroupRemoval, GroupRemoval>()
.AddTransient<IFollowService, FollowService>() .AddTransient<IFollowService, FollowService>()
.AddTransient<IFeedFollows, FeedFollows>() .AddTransient<IFeedFollows, FeedFollows>()
.AddTransient<ITimelineService, TimelineService>() .AddTransient<ITimelineService, TimelineService>()
+17
View File
@@ -45,6 +45,21 @@ namespace PrivaPub.Models
public const string RootDelete = "root.delete"; public const string RootDelete = "root.delete";
public const string RootResetTotp = "root.reset_totp"; public const string RootResetTotp = "root.reset_totp";
public const string PersonaMove = "persona.move"; public const string PersonaMove = "persona.move";
// moderation (completion program R6)
public const string RootRole = "root.role";
public const string RootBan = "root.ban";
public const string RootUnban = "root.unban";
public const string PersonaSilence = "persona.silence";
public const string PersonaUnsilence = "persona.unsilence";
public const string PersonaSuspend = "persona.suspend";
public const string PersonaUnsuspend = "persona.unsuspend";
public const string PersonaDelete = "persona.delete";
public const string AccountSilence = "account.silence";
public const string AccountUnsilence = "account.unsilence";
public const string AccountSuspend = "account.suspend";
public const string AccountUnsuspend = "account.unsuspend";
public const string GroupDelete = "group.delete";
public const string PostRemove = "post.remove";
} }
public static class AdminTargets public static class AdminTargets
@@ -60,5 +75,7 @@ namespace PrivaPub.Models
public const string Emoji = "emoji"; public const string Emoji = "emoji";
public const string Relay = "relay"; public const string Relay = "relay";
public const string Backup = "backup"; public const string Backup = "backup";
public const string Group = "group";
public const string Post = "post";
} }
} }
+8
View File
@@ -59,6 +59,14 @@ namespace PrivaPub.Models.Post
public bool HasContentWarning { get; set; } = false; public bool HasContentWarning { get; set; } = false;
public bool IsLocalOnly { get; set; } public bool IsLocalOnly { get; set; }
public bool AuthorGone { get; set; }//its remote author deleted their account: kept, but hidden everywhere (VisibilityPolicy) public bool AuthorGone { get; set; }//its remote author deleted their account: kept, but hidden everywhere (VisibilityPolicy)
// the staff's moderation (completion program R6, Domain/Moderation/AccountModeration.cs), copied from the author so a
// query needs no join: suspended, it is hidden everywhere as AuthorGone is (until the suspension is lifted); limited
// (the author or its server silenced), it stays out of the public and hashtag timelines, trends, search and the
// relays, and its followers still get it. A banned login's personas are never flagged (that would pair them):
// Restrictions treats them as suspended when posts are read.
public bool AuthorSuspended { get; set; }
public bool AuthorLimited { get; set; }
public DateTime? ModeratedAt { get; set; }//a remote copy the staff took down: kept, hidden everywhere, never fetched again
public bool IsFederatedCopy { get; set; } public bool IsFederatedCopy { get; set; }
public string ObjectURI { get; set; }//the Note's id public string ObjectURI { get; set; }//the Note's id
+4
View File
@@ -13,6 +13,10 @@ namespace PrivaPub.Models.Social
public string Emoji { get; set; }//a reaction's emoji public string Emoji { get; set; }//a reaction's emoji
public string EmojiURL { get; set; } public string EmojiURL { get; set; }
public string Text { get; set; }//a moderation warning's reason public string Text { get; set; }//a moderation warning's reason
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreIfNull]
public string WarningAction { get; set; }//a warning from this server's staff: Mastodon's AccountWarning action (silence, suspend, delete_statuses)
[MongoDB.Bson.Serialization.Attributes.BsonIgnoreIfNull]
public List<string> StatusIds { get; set; }//the posts a warning is about, when more than PostId
public bool IsRead { get; set; } public bool IsRead { get; set; }
public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
+2
View File
@@ -77,6 +77,8 @@ namespace PrivaPub.Models.Social
public bool Forwarded { get; set; } public bool Forwarded { get; set; }
public bool IsResolved { get; set; } public bool IsResolved { get; set; }
public string ResolvedBy { get; set; } public string ResolvedBy { get; set; }
public string Resolution { get; set; }//what the staff did when resolving it (R6): none, silence, suspend, remove_posts
public string ResolutionNote { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? ResolvedAt { get; set; } public DateTime? ResolvedAt { get; set; }
public DateTime? RemoteResolvedAt { get; set; }//the server holding the reported content says it dealt with it (Lemmy's Resolve{Flag}) public DateTime? RemoteResolvedAt { get; set; }//the server holding the reported content says it dealt with it (Lemmy's Resolve{Flag})
+33 -3
View File
@@ -25,15 +25,45 @@ namespace PrivaPub.Models.User
public RootTotp Totp { get; set; }//two-factor sign-in, when the login set it up (Services/TwoFactor.cs) public RootTotp Totp { get; set; }//two-factor sign-in, when the login set it up (Services/TwoFactor.cs)
/// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary> /// <summary>An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user.</summary>
public void SetAdmin(bool admin) public void SetAdmin(bool admin) => SetRole(admin ? RootRole.Admin : RootRole.User);
/// <summary>The login's role, as its policies say it: every login is a user, a moderator is one too, and an
/// administrator is all three.</summary>
public void SetRole(RootRole role)
{ {
Policies.RemoveAll(p => p is ClientModels.Policies.IsAdmin or ClientModels.Policies.IsModerator); Policies.RemoveAll(p => p is ClientModels.Policies.IsAdmin or ClientModels.Policies.IsModerator);
if (admin) if (role is RootRole.Moderator or RootRole.Admin)
Policies.AddRange([ClientModels.Policies.IsAdmin, ClientModels.Policies.IsModerator]); Policies.Add(ClientModels.Policies.IsModerator);
if (role == RootRole.Admin)
Policies.Add(ClientModels.Policies.IsAdmin);
if (!Policies.Contains(ClientModels.Policies.IsUser)) if (!Policies.Contains(ClientModels.Policies.IsUser))
Policies.Add(ClientModels.Policies.IsUser); Policies.Add(ClientModels.Policies.IsUser);
UpdatedAt = DateTime.UtcNow; UpdatedAt = DateTime.UtcNow;
} }
public RootRole Role => Policies.Contains(ClientModels.Policies.IsAdmin) ? RootRole.Admin
: Policies.Contains(ClientModels.Policies.IsModerator) ? RootRole.Moderator
: RootRole.User;
public static bool TryParseRole(string value, out RootRole role)
{
role = value?.Trim().ToLowerInvariant() switch
{
"user" => RootRole.User,
"moderator" => RootRole.Moderator,
"admin" or "administrator" => RootRole.Admin,
_ => (RootRole)(-1)
};
return Enum.IsDefined(role);
}
}
// never stored: a login's role lives in its Policies
public enum RootRole
{
User,
Moderator,
Admin
} }
// TOTP (RFC 6238: 30 s, 6 digits, SHA-1, as every authenticator app reads it). PendingSecret waits for its first code; // TOTP (RFC 6238: 30 s, 6 digits, SHA-1, as every authenticator app reads it). PendingSecret waits for its first code;
+1
View File
@@ -231,6 +231,7 @@ try
var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities; var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities;
await dbClient.Init(); await dbClient.Init();
await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None); await app.Services.GetRequiredService<PrivaPub.Federation.Moderation.IDomainBlocks>().Reload(CancellationToken.None);
await PrivaPub.Domain.Moderation.Restrictions.Reload(CancellationToken.None);//who the staff's moderation holds back (R6)
await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None); await PrivaPub.Api.Mastodon.Auth.PersonaExchange.EnsureFirstPartyClient(app.Services, CancellationToken.None);
} }
catch (Exception ex) catch (Exception ex)
@@ -111,6 +111,9 @@ namespace PrivaPub.Services.ClientToServer.Private
var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync(); var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync();
if (avatar == default || avatar.DeletionAt.HasValue) if (avatar == default || avatar.DeletionAt.HasValue)
return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound); return result.Invalidate(_localizer["Avatar not found."], StatusCodes.Status404NotFound);
// a suspended persona's actor stays as it was until the suspension is lifted (R6)
if (avatar.SuspendedAt.HasValue)
return result.Invalidate(_localizer["This persona is suspended."], StatusCodes.Status403Forbidden);
avatar.Name = form.Name; avatar.Name = form.Name;
avatar.Biography = form.Biography; avatar.Biography = form.Biography;
+2 -1
View File
@@ -476,8 +476,9 @@ namespace PrivaPub.Services
return role is GroupRole.Owner or GroupRole.Moderator ? group : default; return role is GroupRole.Owner or GroupRole.Moderator ? group : default;
} }
// a suspended persona does nothing in its groups (R6)
async Task<bool> OwnsAvatar(string rootUserId, string avatarId, CancellationToken token) => async Task<bool> OwnsAvatar(string rootUserId, string avatarId, CancellationToken token) =>
!string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId) !string.IsNullOrEmpty(rootUserId) && !string.IsNullOrEmpty(avatarId) && !Domain.Moderation.Restrictions.IsSuspended(avatarId)
&& await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token); && await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token);
static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch
+3
View File
@@ -18,6 +18,9 @@ namespace PrivaPub.Services
Task<WebResult> RemoveUserAsync(UsersIds usersIds); Task<WebResult> RemoveUserAsync(UsersIds usersIds);
Task<WebResult> RemoveSelfAsync(string rootId, string password); Task<WebResult> RemoveSelfAsync(string rootId, string password);
/// <summary>Whether a login's password is this one, after the same hashing whatever the answer.</summary>
Task<bool> PasswordMatches(string rootId, string password);
Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId); Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId);
Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default); Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default);
+173
View File
@@ -0,0 +1,173 @@
using MongoDB.Entities;
using OpenIddict.Abstractions;
using PrivaPub.Domain.Media;
using PrivaPub.Domain.Timelines;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User;
using PrivaPub.StaticServices;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Services
{
public interface IPersonaRemoval
{
/// <summary>Deletes one persona, and every group it owns: false when there is no such persona, or it is gone.</summary>
Task<bool> Remove(string avatarId, CancellationToken token);
}
public interface IGroupRemoval
{
/// <summary>Deletes one group: false when there is no such group, or it is gone.</summary>
Task<bool> Remove(string groupId, CancellationToken token);
}
// What deleting an actor tells: Delete{Actor} to its followers' (shared) inboxes, and to the inboxes of the other accounts
// named (those a persona follows, a group's members elsewhere). A suspended persona sends it too: it is the one thing a
// suspension lets through (DeliveryService.HeldBack).
public static class ActorDeletion
{
public static async Task Announce(LocalActor actor, IEnumerable<string> others, DbEntities dbEntities, IDeliveryService delivery, CancellationToken token)
{
var inboxes = (await delivery.FollowerInboxes(actor, token)).ToList();
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
if (named.Count > 0)
inboxes.AddRange((await dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
await delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri("delete-actor"),
["type"] = "Delete",
["actor"] = actor.Uri,
["object"] = actor.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(actor.Followers)
}, token);
}
}
// Deleting a persona (completion program R6: by its own login with its password, by an administrator with theirs, and
// for each persona of a deleted login through RootRemoval) deletes everything public it had, as a deleted login's
// personas always were: its Mastodon tokens end first, each group it owns goes (GroupRemoval), a Delete of the actor goes
// to its followers and to the accounts it follows, its posts are emptied, and its name stays reserved while its documents
// answer 410. What it kept for itself (lists, filters, followed hashtags, scheduled posts, what it did with the
// announcements, the feeds it read, its media) goes with it. Its link to its login stays, so the login keeps no other
// trace than that.
public class PersonaRemoval : IPersonaRemoval
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IMediaService _media;
readonly IGroupRemoval _groups;
readonly IOpenIddictTokenManager _tokens;
readonly IOpenIddictAuthorizationManager _authorizations;
public PersonaRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IMediaService media, IGroupRemoval groups,
IOpenIddictTokenManager tokens, IOpenIddictAuthorizationManager authorizations)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_media = media;
_groups = groups;
_tokens = tokens;
_authorizations = authorizations;
}
public async Task<bool> Remove(string avatarId, CancellationToken token)
{
var avatar = string.IsNullOrEmpty(avatarId) ? default : await _dbEntities.Avatars.MatchID(avatarId).Match(a => !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default)
return false;
var now = DateTime.UtcNow;
// nothing acts as it while it goes
await _tokens.RevokeBySubjectAsync(avatar.ID, token);
await _authorizations.RevokeBySubjectAsync(avatar.ID, token);
Streams.CloseFor(avatar.ID);
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
await _groups.Remove(group.ID, token);
var persona = _localActors.FromAvatar(avatar);
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
.Select(f => f.TargetActorURI);
await ActorDeletion.Announce(persona, followed, _dbEntities, _delivery, token);
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
await Empty(avatar.ID, now, token);
await DB.Default.DeleteAsync<PersonaListMember>(m => m.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<FollowedTag>(t => t.AvatarId == avatar.ID);
// what it did with the server's announcements, kept for it alone
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<AnnouncementReaction>(r => r.AvatarId == avatar.ID);
// (the reader of feeds stops following what nobody reads any more at its next round)
await DB.Default.DeleteAsync<FeedSubscription>(s => s.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and what its
// scheduled posts held
await DB.Default.DeleteAsync<ScheduledStatus>(s => s.AvatarId == avatar.ID);
await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "persona removed", token);
return true;
}
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
{
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
if (postIds.Count == 0)
return;
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
.Modify(p => p.DeletedAt, now)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
}
}
// Deleting a group (completion program R6: by its owner with the login's password, by an administrator with theirs, and
// with the persona that owns it): a Delete of the group's actor to its followers and its members elsewhere, its name kept
// reserved while its documents answer 410, and the personas here that followed or joined it no longer do. The posts made
// in it stay their authors'.
public class GroupRemoval : IGroupRemoval
{
readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
public GroupRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery)
{
_dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
}
public async Task<bool> Remove(string groupId, CancellationToken token)
{
var group = string.IsNullOrEmpty(groupId) ? default : await _dbEntities.Groups.MatchID(groupId).Match(g => !g.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (group == default)
return false;
var actor = _localActors.FromGroup(group);
await ActorDeletion.Announce(actor, group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), _dbEntities, _delivery, token);
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, DateTime.UtcNow).ExecuteAsync(token);
await DB.Default.DeleteAsync<Following>(f => f.TargetActorURI == actor.Uri);
await DB.Default.DeleteAsync<Follower>(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group);
return true;
}
}
}
+4 -3
View File
@@ -125,7 +125,7 @@ namespace PrivaPub.Services
} }
var posts = await query.Match(VisibilityPolicy.IsShown).Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token); var posts = await query.Match(VisibilityPolicy.IsShown).Sort(p => p.CreationDate, Order.Descending).Limit(PageSize).ExecuteAsync(token);
result.Data = posts.Select(ToView).ToList(); result.Data = posts.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -188,7 +188,7 @@ namespace PrivaPub.Services
.Sort(p => p.CreationDate, Order.Descending) .Sort(p => p.CreationDate, Order.Descending)
.Limit(PageSize) .Limit(PageSize)
.ExecuteAsync(token); .ExecuteAsync(token);
result.Data = dms.Select(ToView).ToList(); result.Data = dms.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList();
return result; return result;
} }
catch (Exception ex) catch (Exception ex)
@@ -263,7 +263,8 @@ namespace PrivaPub.Services
return default; return default;
if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token)) if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token))
return default; return default;
return await _localActors.FindById(LocalActorKind.Person, avatarId, token); // a suspended persona does nothing through the client API either (R6)
return await _localActors.FindById(LocalActorKind.Person, avatarId, token) is { Suspended: false } persona ? persona : default;
} }
static ViewPost ToView(PostEntity post) => new() static ViewPost ToView(PostEntity post) => new()
+10 -89
View File
@@ -1,18 +1,7 @@
using MongoDB.Entities; using MongoDB.Entities;
using PrivaPub.Federation.Actors;
using PrivaPub.Federation.Outbox;
using PrivaPub.Federation.Rendering;
using PrivaPub.Models.Federation;
using PrivaPub.Models.Social;
using PrivaPub.Models.User; using PrivaPub.Models.User;
using PrivaPub.StaticServices; using PrivaPub.StaticServices;
using PrivaPub.Domain.Media;
using System.Text.Json.Nodes;
using GroupEntity = PrivaPub.Models.Group.Group;
using PostEntity = PrivaPub.Models.Post.Post;
namespace PrivaPub.Services namespace PrivaPub.Services
{ {
@@ -21,25 +10,21 @@ namespace PrivaPub.Services
Task<bool> Remove(string rootId, CancellationToken token); Task<bool> Remove(string rootId, CancellationToken token);
} }
// Deleting a root deletes everything public it had (owner decision 2026-10-04): each persona, and each group a persona // Deleting a root deletes everything public it had (owner decision 2026-10-04): each of its personas goes as a persona
// owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it // is deleted (PersonaRemoval: each group it owns, a Delete of the actor to everyone who follows it and everyone it
// follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's // follows, its posts emptied, its name kept reserved and its documents answering 410). The root's sessions end first, so
// sessions end first, so nothing acts as it while it goes. // nothing acts as it while it goes, and it keeps only a deleted-{id} name.
public class RootRemoval : IRootRemoval public class RootRemoval : IRootRemoval
{ {
readonly DbEntities _dbEntities; readonly DbEntities _dbEntities;
readonly ILocalActorService _localActors;
readonly IDeliveryService _delivery;
readonly IRootSessions _sessions; readonly IRootSessions _sessions;
readonly IMediaService _media; readonly IPersonaRemoval _personas;
public RootRemoval(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, IRootSessions sessions, IMediaService media) public RootRemoval(DbEntities dbEntities, IRootSessions sessions, IPersonaRemoval personas)
{ {
_media = media;
_dbEntities = dbEntities; _dbEntities = dbEntities;
_localActors = localActors;
_delivery = delivery;
_sessions = sessions; _sessions = sessions;
_personas = personas;
} }
public async Task<bool> Remove(string rootId, CancellationToken token) public async Task<bool> Remove(string rootId, CancellationToken token)
@@ -51,34 +36,8 @@ namespace PrivaPub.Services
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList(); var avatarIds = (await _dbEntities.RootToAvatars.Match(ra => ra.RootId == root.ID).ExecuteAsync(token)).Select(ra => ra.AvatarId).ToList();
var avatars = await _dbEntities.Avatars.Match(a => avatarIds.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token); foreach (var avatarId in avatarIds)
foreach (var avatar in avatars) await _personas.Remove(avatarId, token);
{
foreach (var group in await _dbEntities.Groups.Match(g => g.OwnerAvatarId == avatar.ID && !g.DeletionAt.HasValue).ExecuteAsync(token))
{
await Announce(_localActors.FromGroup(group), group.Members.Where(m => m.IsForeign).Select(m => m.AvatarId), token);
await DB.Default.Update<GroupEntity>().MatchID(group.ID).Modify(g => g.DeletionAt, now).ExecuteAsync(token);
}
var persona = _localActors.FromAvatar(avatar);
var followed = (await _dbEntities.Followings.Match(f => f.AvatarId == avatar.ID && !f.TargetIsLocal).ExecuteAsync(token))
.Select(f => f.TargetActorURI);
await Announce(persona, followed, token);
await DB.Default.Update<Avatar>().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token);
await Empty(avatar.ID, now, token);
await DB.Default.DeleteAsync<Models.Social.PersonaListMember>(m => m.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
// what it did with the server's announcements, kept for it alone
await DB.Default.DeleteAsync<Models.Social.AnnouncementRead>(r => r.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.AnnouncementReaction>(r => r.AvatarId == avatar.ID);
// (the reader of feeds stops following what nobody reads any more at its next round)
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
// what its scheduled posts held
await DB.Default.DeleteAsync<Models.Social.ScheduledStatus>(s => s.AvatarId == avatar.ID);
await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "root removed", token);
}
await DB.Default.Update<RootUser>().MatchID(root.ID) await DB.Default.Update<RootUser>().MatchID(root.ID)
.Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first .Modify(u => u.UserName, $"deleted-{root.ID}")//unique, so a second deletion never collides with the first
@@ -90,46 +49,8 @@ namespace PrivaPub.Services
.Modify(u => u.DeletedAt, now) .Modify(u => u.DeletedAt, now)
.ExecuteAsync(token); .ExecuteAsync(token);
await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID); await DB.Default.DeleteAsync<EmailRecovery>(r => r.RootUserId == root.ID);
await Domain.Moderation.Restrictions.Reload(CancellationToken.None);
return true; return true;
} }
// Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named
async Task Announce(LocalActor actor, IEnumerable<string> others, CancellationToken token)
{
var inboxes = (await _delivery.FollowerInboxes(actor, token)).ToList();
var named = others.Where(uri => !string.IsNullOrEmpty(uri)).Distinct().ToList();
if (named.Count > 0)
inboxes.AddRange((await _dbEntities.ForeignAvatars.Match(a => named.Contains(a.ActorURI)).ExecuteAsync(token))
.Select(a => string.IsNullOrEmpty(a.SharedInboxURL) ? a.InboxURL : a.SharedInboxURL));
await _delivery.Enqueue(actor, inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(), new JsonObject
{
["@context"] = ActivityPubRenderer.Context(),
["id"] = actor.ActivityUri("delete-actor"),
["type"] = "Delete",
["actor"] = actor.Uri,
["object"] = actor.Uri,
["to"] = new JsonArray(ActivityPubRenderer.Public),
["cc"] = new JsonArray(actor.Followers)
}, token);
}
// a persona's posts keep their ids and lose their content, like a single deleted post; a group writes none of its own
static async Task Empty(string avatarId, DateTime now, CancellationToken token)
{
var postIds = (await DB.Default.Find<PostEntity>().Match(p => p.GroupUserId == avatarId && !p.IsFederatedCopy && !p.DeletedAt.HasValue)
.Project(p => p.Include(x => x.ID)).ExecuteAsync(token)).Select(p => p.ID).ToList();
if (postIds.Count == 0)
return;
await DB.Default.Update<PostEntity>().Match(p => postIds.Contains(p.ID))
.Modify(p => p.DeletedAt, now)
.Modify(p => p.Text, null)
.Modify(p => p.ContentHtml, null)
.Modify(p => p.Title, null)
.Modify(p => p.SpoilerText, null)
.Modify(p => p.Media, new List<Models.Post.PostMedia>())
.Modify(p => p.Revisions, new List<Models.Post.PostRevision>())
.ExecuteAsync(token);
await DB.Default.DeleteAsync<TimelineEntry>(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId));
}
} }
} }
+8
View File
@@ -258,6 +258,14 @@ namespace PrivaPub.Services
} }
} }
// what can't be undone asks for the password again (a persona's or a group's deletion, by its login or an administrator)
public async Task<bool> PasswordMatches(string rootId, string password)
{
var user = string.IsNullOrEmpty(rootId) ? default : await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
return user?.HashedPassword != default && verified;
}
public async Task<WebResult> BanUserAsync(UsersIds usersIds) public async Task<WebResult> BanUserAsync(UsersIds usersIds)
{ {
var result = new WebResult(); var result = new WebResult();
+2 -2
View File
@@ -112,7 +112,7 @@ namespace PrivaPub.Web.Pages.OAuth
var owns = !string.IsNullOrEmpty(avatarId) var owns = !string.IsNullOrEmpty(avatarId)
&& await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token); && await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token);
var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default; var avatar = owns ? await _localActors.FindById(LocalActorKind.Person, avatarId, token) : default;
if (avatar == default) if (avatar is not { Suspended: false })//a suspended persona signs in nowhere (R6)
return await Show(token); return await Show(token);
var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role); var identity = new ClaimsIdentity(TokenValidationParameters.DefaultAuthenticationType, Claims.Name, Claims.Role);
@@ -145,7 +145,7 @@ namespace PrivaPub.Web.Pages.OAuth
var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList(); var avatarIds = (await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId).ExecuteAsync(token)).Select(r => r.AvatarId).ToList();
var avatars = new List<LocalActor>(); var avatars = new List<LocalActor>();
foreach (var id in avatarIds) foreach (var id in avatarIds)
if (await _localActors.FindById(LocalActorKind.Person, id, token) is { } avatar) if (await _localActors.FindById(LocalActorKind.Person, id, token) is { Suspended: false } avatar)
avatars.Add(avatar); avatars.Add(avatar);
Avatars = avatars; Avatars = avatars;
Parameters = request.GetParameters() Parameters = request.GetParameters()
+5 -4
View File
@@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, CancellationToken token) public async Task<IActionResult> OnGetAsync(string user, CancellationToken token)
{ {
Actor = await _localActors.FindByUserName(user, token); Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, IsCircle: false } || Actor.IsServerActor) if (Actor is not { IsFederated: true, IsCircle: false, Suspended: false } || Actor.IsServerActor)
return NotFound(); return NotFound();
if (WantsActivityJson()) if (WantsActivityJson())
return Redirect(Actor.Uri); return Redirect(Actor.Uri);
@@ -84,7 +84,7 @@ namespace PrivaPub.Web.Pages
continue; continue;
if (!authors.TryGetValue(post.GroupUserId, out var author)) if (!authors.TryGetValue(post.GroupUserId, out var author))
authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author != default) if (author is { Suspended: false })
views.Add(PostView.From(post, author)); views.Add(PostView.From(post, author));
} }
Posts = views; Posts = views;
@@ -118,6 +118,7 @@ namespace PrivaPub.Web.Pages
var posts = await _dbEntities.Posts var posts = await _dbEntities.Posts
.Match(p => p.Tags.Contains(Tag) && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.Visibility == PostVisibility.Public) .Match(p => p.Tags.Contains(Tag) && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.Visibility == PostVisibility.Public)
.Match(VisibilityPolicy.IsPublic) .Match(VisibilityPolicy.IsPublic)
.Match(VisibilityPolicy.IsListed)
.Sort(p => p.ID, Order.Descending) .Sort(p => p.ID, Order.Descending)
.Limit(PageSize) .Limit(PageSize)
.ExecuteAsync(token); .ExecuteAsync(token);
@@ -127,7 +128,7 @@ namespace PrivaPub.Web.Pages
{ {
if (!authors.TryGetValue(post.GroupUserId, out var author)) if (!authors.TryGetValue(post.GroupUserId, out var author))
authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token); authors[post.GroupUserId] = author = await _localActors.FindById(LocalActorKind.Person, post.GroupUserId, token);
if (author is { IsFederated: true }) if (author is { IsFederated: true, Suspended: false })
views.Add(PostView.From(post, author)); views.Add(PostView.From(post, author));
} }
Posts = views; Posts = views;
@@ -153,7 +154,7 @@ namespace PrivaPub.Web.Pages
public async Task<IActionResult> OnGetAsync(string user, string id, CancellationToken token) public async Task<IActionResult> OnGetAsync(string user, string id, CancellationToken token)
{ {
Actor = await _localActors.FindByUserName(user, token); Actor = await _localActors.FindByUserName(user, token);
if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person }) if (Actor is not { IsFederated: true, Kind: LocalActorKind.Person, Suspended: false })
return NotFound(); return NotFound();
var post = await _dbEntities.Posts var post = await _dbEntities.Posts
.Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null) .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null)
+10 -1
View File
@@ -117,6 +117,15 @@ Written 2026-10-01 from the original 2023 code, the decePubClient UI, a federati
accounts (email, recovery, sessions, TOTP); the client catching up; the server's own settings; moderation; custom accounts (email, recovery, sessions, TOTP); the client catching up; the server's own settings; moderation; custom
emoji; the Mastodon API made real; Web Push; P7's communities; P8's signatures; the long tail with HEIC/HEIF; emoji; the Mastodon API made real; Web Push; P7's communities; P8's signatures; the long tail with HEIC/HEIF;
hosting events; town drivers for every peer; public `/stargazing` and Bluesky bridging; the close-out. hosting events; town drivers for every peer; public `/stargazing` and Bluesky bridging; the close-out.
- R6 moderation (built 2026-10-17, not yet deployed; `tools/pasture/scenarios/moderation.sh`): roles (user, moderator,
administrator) from the client and `admin role`; silencing and suspending personas and accounts elsewhere
(`Post.AuthorLimited`/`AuthorSuspended`), a silenced server's posts limited too; bans held in memory, never written on
the personas; posts removed (ours deleted, copies hidden); a persona and a group deleted on their own; reports
resolved with an act; every act in the staff's log. Chosen while building it, for the owner to confirm: a suspended
persona is warned with Mastodon's `moderation_warning` (read once it is lifted; its login sees it suspended
meanwhile), still counts among the users (counts tell nothing new), leaves a follow request unanswered, and its
scheduled posts wait; a suspended account elsewhere may still delete or undo what it sent; deleting a group asks its
owner's password, like deleting a persona.
## Intent ## Intent
@@ -346,7 +355,7 @@ statistics and Bluesky bridging, R16 the close-out.
| Approved | **Web Push, the public `/stargazing` statistics, per-persona Bluesky bridging, email recovery in the client, local custom emoji, admin endpoints in production.** | | Approved | **Web Push, the public `/stargazing` statistics, per-persona Bluesky bridging, email recovery in the client, local custom emoji, admin endpoints in production.** |
| Built after all | **Hosting events, HEIC/HEIF uploads, town drivers for every peer.** Discourse, NeoDB and Loops peers stay out. | | Built after all | **Hosting events, HEIC/HEIF uploads, town drivers for every peer.** Discourse, NeoDB and Loops peers stay out. |
| Two-factor | **TOTP, right after email recovery** (R3). | | Two-factor | **TOTP, right after email recovery** (R3). |
| A suspended local persona | **Tells other servers nothing new**: its posts and collections answer 404 and nothing is delivered from it; the actor stays as it is, so lifting the suspension is clean. | | A suspended local persona | **Tells other servers nothing new**: its posts and collections answer 404 and nothing is delivered from it; the actor stays as it is, so lifting the suspension is clean. (Built in R6, see FEDERATION.md, Moderation.) |
| Releases | **Pushed to master, not tagged**: the owner tags when they choose. | | Releases | **Pushed to master, not tagged**: the owner tags when they choose. |
| Cut | FASP; rewriting HLS playlists; `Announce(object)` per peer; cross-post detection, PieFed's genAI flag, accepted answers and batched Announces; Ed25519 inbound RFC 9421; typed book reviews; the `Api/ClientApi` folder move; Pleroma `Listen` and `acceptsChatMessages` out; Owncast live state; a dislikes collection; Lemmy's `removeData`; translation; the Mastodon admin API; `POST /api/v1/accounts`; S3. | | Cut | FASP; rewriting HLS playlists; `Announce(object)` per peer; cross-post detection, PieFed's genAI flag, accepted answers and batched Announces; Ed25519 inbound RFC 9421; typed book reviews; the `Api/ClientApi` folder move; Pleroma `Listen` and `acceptsChatMessages` out; Owncast live state; a dislikes collection; Lemmy's `removeData`; translation; the Mastodon admin API; `POST /api/v1/accounts`; S3. |
+268
View File
@@ -0,0 +1,268 @@
# Moderation (completion program R6), with GoToSocial and Mastodon. A second login is made a moderator through the
# administrator's route (its own role refused, a moderator refused the logins); a silenced persona's post stays out of the
# public and hashtag timelines and still reaches its followers, here and on Mastodon; a suspended persona's token answers
# 401 and its account, posts and collections 404, while its actor is still served and Mastodon is told nothing; a
# suspended GoToSocial account's new activity is dropped, nothing goes to it, and unsuspending shows its earlier post
# again; a silenced server's posts leave the federated timeline; a banned login's personas answer nothing (a Mastodon
# follow stays requested) and carry no moderation of their own; a persona deleted by an administrator is gone on Mastodon
# and GoToSocial, another deleted by its own login, groups deleted by their owner and by an administrator; posts removed
# directly and through a report are gone on Mastodon; the staff's log lists every act, without an address. Needs the gts
# and mastodon peers; run it after instance (batch A), whose log check expects only the administrator's acts.
G=https://gts.test:6443
gcurl() { curl -sk --resolve gts.test:6443:127.0.0.1 "$@"; }
M=https://mastodon.test:6443
mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh"
api() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
code_of() { local jwt=$1 method=$2 path=$3; shift 3; curl -s -o /dev/null -w '%{http_code}' -X "$method" -H "Authorization: Bearer $jwt" -H 'Content-Type: application/json' "$P$path" "$@"; }
login() { curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$1\",\"password\":\"$2\"}" | j "print(d['token'])"; }
code() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
# m_sql <query>: one value from Mastodon's database (its API caches relationships for a day)
m_sql() { podman exec pasture-postgres psql -U pasture -d mastodon -Atc "$1" 2>/dev/null; }
# signed_status <url>: what a GET of one of our documents signed by Mastodon's instance actor gets (SecureMode answers an
# unsigned one 401 whatever it asks for)
signed_status() { podman exec pasture-mastodon bin/rails runner "Request.new(:get, '$1').on_behalf_of(Account.representative).add_headers('Accept' => 'application/activity+json').perform { |r| puts r.code }" 2>/dev/null | tail -1; }
p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1" 2>/dev/null | tail -1; }
# login_token <login> <password> <persona>: the persona, made under that login if needed, and a Mastodon token for it
login_token() {
local jwt
jwt=$(login "$1" "$2")
curl -s -o /dev/null -X POST "$P/clientapi/avatar/private/insert" -H 'Content-Type: application/json' -H "Authorization: Bearer $jwt" \
-d "{\"userName\":\"$3\",\"name\":\"$3 of PrivaPub\",\"biography\":\"moderated in the pasture\"}"
"$here/../smoke/oauth.sh" "$P" "$1" "$2" "$3" "read write follow" 2>/dev/null | cut -d' ' -f1
}
me_id() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])"; }
post_public() { curl -s -X POST -H "Authorization: Bearer $1" "$P/api/v1/statuses" --data-urlencode "status=$2" -d visibility=public; }
in_public() { curl -s "$P/api/v1/timelines/public?limit=40${2:-}" | j "print('$1' in [s['id'] for s in d])"; }
in_tag() { curl -s "$P/api/v1/timelines/tag/$2?limit=40" | j "print('$1' in [s['id'] for s in d])"; }
in_home() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/timelines/home?limit=40" | j "print('$2' in [s['id'] for s in d])"; }
warned() { curl -s -H "Authorization: Bearer $1" "$P/api/v1/notifications?types[]=moderation_warning" | j "print(any(n['type'] == 'moderation_warning' and n['moderation_warning']['action'] == '$2' for n in d))"; }
moderate() { api "$MOD" POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$1\",\"action\":\"$2\",\"note\":\"pasture $2\",\"warning\":\"${3:-}\"}"; }
m_resolve() { mcurl -H "$MH" "$M/api/v2/search?q=@$1@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])"; }
m_follow() { mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$1/follow"; }
m_holds() { m_sql "select count(*) from statuses where uri = '$1' and deleted_at is null"; }
gts_token() {
local app id secret code jar
jar=$(mktemp); app=$(gcurl -X POST $G/api/v1/apps -d 'client_name=pasture&redirect_uris=urn:ietf:wg:oauth:2.0:oob&scopes=read+write+follow')
id=$(echo "$app" | j "print(d['client_id'])"); secret=$(echo "$app" | j "print(d['client_secret'])")
gcurl -o /dev/null -c "$jar" -b "$jar" "$G/oauth/authorize?client_id=$id&redirect_uri=urn:ietf:wg:oauth:2.0:oob&response_type=code&scope=read+write+follow"
gcurl -o /dev/null -c "$jar" -b "$jar" -X POST $G/auth/sign_in --data-urlencode 'username=gtsuser@gts.test' --data-urlencode 'password=Gts-Pasture-Pass-1!'
code=$(gcurl -o /dev/null -w '%{redirect_url}' -c "$jar" -b "$jar" -X POST "$G/oauth/authorize" | sed -n 's/.*[?&]code=\([^&]*\).*/\1/p')
rm -f "$jar"
gcurl -X POST $G/oauth/token -d "grant_type=authorization_code&code=$code&client_id=$id&client_secret=$secret&redirect_uri=urn:ietf:wg:oauth:2.0:oob&scope=read+write+follow" | j "print(d['access_token'])"
}
g_resolve() { gcurl -H "$GH" "$G/api/v2/search?q=@$1@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])"; }
echo "moderation"
run=$(date +%s)
STAFF_PASS='Moderation-Pass-1!'
privapub_root >/dev/null
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
ADMIN=$(login "$ROOT_USER" "$ROOT_PASS")
[ -n "$ADMIN" ] && ok "the pasture's root signs in as the administrator" || { ko "no administrator's login"; return 1; }
MT=$(mastodon_token); MH="Authorization: Bearer $MT"
GT=$(gts_token); GH="Authorization: Bearer $GT"
[ -n "$MT" ] && [ -n "$GT" ] && ok "tokens for mastouser and gtsuser" || { ko "no peer tokens"; return 1; }
m_me=$(mcurl -H "$MH" "$M/api/v1/accounts/verify_credentials" | j "print(d['id'])")
echo " roles"
echo "$STAFF_PASS" | podman exec -i -w /app pasture-privapub /app/PrivaPub admin create-root modr_staff >/dev/null 2>&1 || true
staff_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=modr_staff" | j "print(next(r['id'] for r in d['items'] if r['userName'] == 'modr_staff'))")
root_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=$ROOT_USER" | j "print(next(r['id'] for r in d['items'] if r['userName'] == '$ROOT_USER'))")
[ -n "$staff_id" ] && [ -n "$root_id" ] && ok "the administrator reads the logins" || { ko "the logins could not be read"; return 1; }
[ "$(api "$ADMIN" POST "/clientapi/admin/roots/$staff_id/role" -d '{"role":"moderator"}' | j "print(d['role'])")" = moderator ] \
&& ok "a login is made a moderator" || ko "the moderator's role was not given"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$root_id/role" -d '{"role":"user"}')" = 409 ] \
&& [ "$(api "$ADMIN" GET "/clientapi/admin/roots/$root_id" | j "print(d['role'])")" = admin ] \
&& ok "an administrator can't change its own role, so the last one stays (409)" || ko "the administrator changed its own role"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$staff_id/role" -d '{"role":"emperor"}')" = 422 ] && ok "an unknown role is refused (422)" || ko "an unknown role was taken"
MOD=$(login modr_staff "$STAFF_PASS")
[ "$(code_of "$MOD" POST "/clientapi/admin/roots/$root_id/role" -d '{"role":"user"}')" = 403 ] \
&& [ "$(code_of "$MOD" GET /clientapi/admin/roots)" = 403 ] && [ "$(code_of "$MOD" POST "/clientapi/admin/roots/$root_id/totp/reset")" = 403 ] \
&& ok "a moderator changes no login and reads none" || ko "a moderator reached the logins"
[ "$(code_of "$MOD" GET /clientapi/moderator/log)" = 200 ] && ok "a moderator reads the staff's log" || ko "the moderator can't read the log"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$staff_id/totp/reset")" = 200 ] && [ "$(code_of "$ADMIN" POST "/clientapi/admin/roots/$root_id/totp/reset")" = 409 ] \
&& ok "an administrator resets another login's two-factor sign-in, never its own" || ko "the two-factor reset answered wrongly"
echo " a silenced persona"
QT=$(privapub_token modr_quiet); RT=$(privapub_token modr_reader)
quiet_id=$(me_id "$QT"); reader_id=$(me_id "$RT")
curl -s -o /dev/null -X POST -H "Authorization: Bearer $RT" "$P/api/v1/accounts/$quiet_id/follow"
quiet_on_m=$(m_resolve modr_quiet); m_follow "$quiet_on_m"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $quiet_on_m")" = 1 ]' \
&& ok "mastouser and a persona follow modr_quiet" || ko "mastouser's follow of modr_quiet was not accepted"
[ "$(moderate "$quiet_id" silence 'Too loud' | j "print(d['silencedAt'] is not None)")" = True ] && ok "the moderator silences it" || ko "modr_quiet was not silenced"
quiet_post=$(post_public "$QT" "a quiet word #modrquiet$run")
quiet_post_id=$(echo "$quiet_post" | j "print(d['id'])"); quiet_uri=$(echo "$quiet_post" | j "print(d['uri'])")
[ "$(in_public "$quiet_post_id")" = False ] && [ "$(in_public "$quiet_post_id" '&local=true')" = False ] && ok "its new post stays out of the public timelines" || ko "the silenced post is in the public timeline"
[ "$(in_tag "$quiet_post_id" "modrquiet$run")" = False ] && ok "and out of its hashtag's" || ko "the silenced post is in its hashtag's timeline"
[ "$(in_home "$RT" "$quiet_post_id")" = True ] && ok "its follower here still gets it" || ko "the follower's home lost the silenced post"
until_true 30 '[ "$(m_holds "$quiet_uri")" = 1 ]' && ok "and so does its follower on Mastodon" || ko "the silenced post never reached Mastodon"
[ "$(code "$P/api/v1/statuses/$quiet_post_id")" = 200 ] && [ "$(curl -s "$P/api/v1/accounts/$quiet_id" | j "print(d['limited'])")" = True ] \
&& ok "it can still be opened, its account says it is limited" || ko "the silenced post or account: $(curl -s "$P/api/v1/accounts/$quiet_id" | head -c 200)"
[ "$(curl -s -H "Authorization: Bearer $RT" "$P/api/v1/accounts/search?q=modr_qu" | j "print(any(a['id'] == '$quiet_id' for a in d))")" = False ] \
&& ok "search finds it no more by a part of its name" || ko "search still finds the silenced persona"
[ "$(warned "$QT" silence)" = True ] && ok "it is warned (moderation_warning, silence)" || ko "modr_quiet was not warned"
moderate "$quiet_id" unsilence >/dev/null
loud_again=$(post_public "$QT" "heard again #modrquiet$run" | j "print(d['id'])")
[ "$(in_public "$loud_again")" = True ] && [ "$(in_public "$quiet_post_id")" = True ] && ok "unsilenced, its posts are in the public timeline again" || ko "the unsilenced posts are not public"
echo " a suspended persona"
HT=$(privapub_token modr_held)
held_id=$(me_id "$HT")
held_post=$(post_public "$HT" "before the suspension $run")
held_post_id=$(echo "$held_post" | j "print(d['id'])"); held_uri=$(echo "$held_post" | j "print(d['uri'])")
held_on_m=$(m_resolve modr_held); m_follow "$held_on_m"
until_true 30 '[ "$(m_holds "$held_uri")" = 1 ] || [ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $held_on_m")" = 1 ]' \
&& ok "mastouser follows modr_held" || ko "mastouser's follow of modr_held was not accepted"
[ "$(moderate "$held_id" suspend 'Off for a while' | j "print(d['suspendedAt'] is not None)")" = True ] && ok "the moderator suspends it" || ko "modr_held was not suspended"
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 401 ] \
&& [ "$(code -H "Authorization: Bearer $HT" -X POST "$P/api/v1/statuses" -d 'status=still here?')" = 401 ] \
&& ok "its token answers 401" || ko "the suspended persona's token still works"
[ "$(code "$P/api/v1/accounts/$held_id")" = 404 ] && [ "$(code -H "Authorization: Bearer $RT" "$P/api/v1/accounts/$held_id")" = 404 ] \
&& [ "$(code "$P/api/v1/accounts/$held_id/statuses")" = 404 ] && [ "$(code "$P/api/v1/statuses/$held_post_id")" = 404 ] \
&& [ "$(code "$P/api/v1/accounts/lookup?acct=modr_held")" = 404 ] \
&& ok "its account, posts and lookup answer 404 to everyone else" || ko "the suspended persona is still shown"
[ "$(signed_status https://privapub.test/peasants/modr_held)" = 200 ] && ok "its actor is still served as it was" || ko "the suspended persona's actor is not served"
[ "$(signed_status https://privapub.test/peasants/modr_held/anus)" = 404 ] && [ "$(signed_status "$held_uri")" = 404 ] \
&& ok "its outbox and posts answer 404, even signed" || ko "the suspended persona's outbox or post is served"
"$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" modr_held read >/dev/null 2>&1 && ko "OAuth still offers the suspended persona" || ok "OAuth no longer offers it"
[ "$(api "$ADMIN" GET /clientapi/avatar/private/list | j "print([a['accountState'] for a in d if a['userName'] == 'modr_held'])")" = "['Suspended']" ] \
&& ok "its login sees it suspended among its personas" || ko "the login does not see the suspension"
[ "$(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(any(a['id'] == '$held_id' and a['suspendedAt'] for a in d['items']))")" = True ] \
&& ok "the moderators list it" || ko "the moderated accounts miss modr_held"
[ "$(m_sql "select count(*) from accounts where id = $held_on_m and suspended_at is null")" = 1 ] && ok "Mastodon was told nothing: it still holds the account as it was" || ko "Mastodon changed the account"
moderate "$held_id" unsuspend >/dev/null
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 401 ] && ok "unsuspended, its old token stays revoked" || ko "the revoked token works again"
HT=$("$here/../smoke/oauth.sh" "$P" "$ROOT_USER" "$ROOT_PASS" modr_held "read write follow" 2>/dev/null | cut -d' ' -f1)
[ "$(code -H "Authorization: Bearer $HT" "$P/api/v1/accounts/verify_credentials")" = 200 ] && [ "$(code "$P/api/v1/statuses/$held_post_id")" = 200 ] \
&& [ "$(signed_status "$held_uri")" = 200 ] && ok "a new token works, and its posts are served again" || ko "modr_held did not come back whole"
[ "$(warned "$HT" suspend)" = True ] && ok "it reads its warning (moderation_warning, suspend)" || ko "modr_held was not warned"
echo " an account elsewhere suspended"
WT=$(privapub_token modr_watch); WH="Authorization: Bearer $WT"
gts_on_p=$(curl -s -H "$WH" "$P/api/v2/search?q=gtsuser@gts.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
watch_on_g=$(g_resolve modr_watch)
curl -s -o /dev/null -X POST -H "$WH" "$P/api/v1/accounts/$gts_on_p/follow"
until_true 20 'gcurl -H "$GH" "$G/api/v1/follow_requests" | j "print(any(a[\"id\"] == \"$watch_on_g\" for a in d))" | grep -q True'
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/follow_requests/$watch_on_g/authorize"
until_true 30 '[ "$(curl -s -H "$WH" "$P/api/v1/accounts/relationships?id[]=$gts_on_p" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "modr_watch follows gtsuser" || ko "modr_watch's follow of gtsuser was not accepted"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=before the suspension $run" -d visibility=public
until_true 30 'curl -s -H "$WH" "$P/api/v1/timelines/home" | grep -q "before the suspension $run"' && ok "gtsuser's post reaches modr_watch" || ko "gtsuser's post never arrived"
g_post=$(curl -s -H "$WH" "$P/api/v1/timelines/home" | j "print(next(s['id'] for s in d if 'before the suspension $run' in s['content']))")
[ "$(api "$MOD" GET "/clientapi/moderator/accounts?q=gtsuser@gts.test" | j "print(any(a['id'] == '$gts_on_p' and not a['isLocal'] for a in d['items']))")" = True ] \
&& ok "the moderators find it by its handle" || ko "the moderators' search misses gtsuser"
[ "$(moderate "$gts_on_p" suspend | j "print(d['suspendedAt'] is not None)")" = True ] && ok "and suspend it" || ko "gtsuser was not suspended"
[ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 404 ] && [ "$(code -H "$WH" "$P/api/v1/accounts/$gts_on_p")" = 404 ] \
&& [ "$(in_home "$WT" "$g_post")" = False ] && ok "its post and account are gone here, from its follower's home too" || ko "the suspended account is still shown"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=during the suspension $run" -d visibility=public
curl -s -o /dev/null -X POST -H "$WH" "$P/api/v1/statuses" --data-urlencode "status=@gtsuser@gts.test are you there $run" -d visibility=direct
sleep 20
[ "$(p_mongo "print(db.Post.countDocuments({ContentHtml: /during the suspension $run/}))")" = 0 ] && ok "what it sends meanwhile is dropped" || ko "the suspended account's new post was taken in"
gcurl -H "$GH" "$G/api/v1/conversations" | grep -q "are you there $run" && ko "a direct message reached the suspended account" || ok "and nothing is delivered to it"
moderate "$gts_on_p" unsuspend >/dev/null
[ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 200 ] && [ "$(in_home "$WT" "$g_post")" = True ] && ok "unsuspended, its earlier post shows again" || ko "the earlier post did not come back"
gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/statuses" --data-urlencode "status=after the suspension $run" -d visibility=public
until_true 30 'curl -s -H "$WH" "$P/api/v1/timelines/home" | grep -q "after the suspension $run"' && ok "and what it sends arrives again" || ko "gtsuser's post after the suspension never arrived"
echo " a silenced server"
[ "$(in_public "$g_post" '&remote=true')" = True ] && ok "gtsuser's post is in the federated timeline" || ko "gtsuser's post is not in the federated timeline"
api "$ADMIN" POST /clientapi/admin/domainblocks/insert -d '{"domain":"gts.test","suspend":false,"privateComment":"pasture moderation"}' >/dev/null
[ "$(in_public "$g_post" '&remote=true')" = False ] && [ "$(curl -s -H "$WH" "$P/api/v1/accounts/$gts_on_p" | j "print(d['limited'])")" = True ] \
&& [ "$(in_home "$WT" "$g_post")" = True ] && ok "its server silenced, the post leaves it (its follower keeps it), the account is limited" || ko "the silenced server's post is still listed"
api "$ADMIN" POST "/clientapi/admin/domainblocks/delete?domain=gts.test" >/dev/null
[ "$(in_public "$g_post" '&remote=true')" = True ] && ok "and is back when the block goes" || ko "the post stayed out after the block went"
echo " a banned login"
echo "$STAFF_PASS" | podman exec -i -w /app pasture-privapub /app/PrivaPub admin create-root modr_banned >/dev/null 2>&1 || true
B1=$(login_token modr_banned "$STAFF_PASS" modr_ban1); B2=$(login_token modr_banned "$STAFF_PASS" modr_ban2)
ban1_id=$(me_id "$B1"); ban2_id=$(me_id "$B2")
banned_id=$(api "$ADMIN" GET "/clientapi/admin/roots?q=modr_banned" | j "print(next(r['id'] for r in d['items'] if r['userName'] == 'modr_banned'))")
[ -n "$ban1_id" ] && [ -n "$ban2_id" ] && [ -n "$banned_id" ] && ok "a login with two personas" || { ko "the banned login's personas"; return 1; }
ban2_on_m=$(m_resolve modr_ban2)
ban1_post=$(post_public "$B1" "before the ban $run" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST /clientapi/admin/ban/users -d "{\"userIdList\":[\"$banned_id\"]}")" = 200 ] && ok "the administrator bans it" || ko "the ban was refused"
[ "$(code -H "Authorization: Bearer $B1" "$P/api/v1/accounts/verify_credentials")" = 401 ] && [ "$(code -H "Authorization: Bearer $B2" "$P/api/v1/accounts/verify_credentials")" = 401 ] \
&& ok "both personas' tokens answer 401" || ko "a banned persona's token still works"
[ "$(code "$P/api/v1/accounts/$ban1_id")" = 404 ] && [ "$(code "$P/api/v1/statuses/$ban1_post")" = 404 ] && [ "$(in_public "$ban1_post")" = False ] \
&& ok "their accounts and posts are shown to nobody" || ko "a banned persona is still shown"
m_follow "$ban2_on_m"
sleep 20
[ "$(m_sql "select count(*) from follow_requests where account_id = $m_me and target_account_id = $ban2_on_m")" = 1 ] \
&& [ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $ban2_on_m")" = 0 ] \
&& ok "they send nothing: mastouser's follow of modr_ban2 waits unanswered" || ko "a banned persona answered a follow"
[ "$(p_mongo "print(db.Avatar.countDocuments({_id: {\$in: [ObjectId('$ban1_id'), ObjectId('$ban2_id')]}, \$or: [{SuspendedAt: {\$ne: null}}, {SilencedAt: {\$ne: null}}, {BannedAt: {\$ne: null}}]}) + db.Post.countDocuments({GroupUserId: {\$in: ['$ban1_id', '$ban2_id']}, AuthorSuspended: true}))")" = 0 ] \
&& [ "$(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(any(a['id'] in ('$ban1_id', '$ban2_id') for a in d['items']))")" = False ] \
&& ok "nothing is written on either persona, and the moderators see neither" || ko "the ban was written on the personas"
[ "$(code_of "$ADMIN" POST /clientapi/admin/unban/users -d "{\"userIdList\":[\"$banned_id\"]}")" = 200 ] \
&& [ "$(code "$P/api/v1/accounts/$ban1_id")" = 200 ] && [ "$(code "$P/api/v1/statuses/$ban1_post")" = 200 ] \
&& ok "unbanned, both are shown again" || ko "the unbanned personas are not back"
mcurl -o /dev/null -X POST -H "$MH" "$M/api/v1/accounts/$ban2_on_m/unfollow"
echo " deletions"
doom="modr_doom_$run"
DT=$(privapub_token "$doom"); doom_id=$(me_id "$DT")
doom_on_m=$(m_resolve "$doom"); m_follow "$doom_on_m"
doom_on_g=$(g_resolve "$doom"); gcurl -o /dev/null -X POST -H "$GH" "$G/api/v1/accounts/$doom_on_g/follow"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $doom_on_m")" = 1 ] && [ "$(gcurl -H "$GH" "$G/api/v1/accounts/relationships?id[]=$doom_on_g" | j "print(d[0][\"following\"])")" = True ]' \
&& ok "mastouser and gtsuser follow $doom" || ko "the follows of $doom were not accepted"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/personas/$doom_id/delete" -d '{"password":"not it"}')" = 403 ] \
&& [ "$(code_of "$MOD" POST "/clientapi/admin/personas/$doom_id/delete" -d "{\"password\":\"$STAFF_PASS\"}")" = 403 ] \
&& ok "deleting a persona needs an administrator and its password" || ko "a persona's deletion was let through"
[ "$(code_of "$ADMIN" POST "/clientapi/admin/personas/$doom_id/delete" -d "{\"password\":\"$ROOT_PASS\",\"note\":\"pasture\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$doom" && ok "the administrator deletes it: its actor answers 410" || ko "the persona was not deleted"
until_true 30 '[ "$(m_sql "select count(*) from accounts where id = $doom_on_m and suspended_at is null")" = 0 ]' && ok "it is gone on Mastodon" || ko "Mastodon still holds the deleted persona"
until_true 30 '[ "$(gcurl -H "$GH" "$G/api/v1/accounts/$doom_on_g" | j "print(d is None or \"error\" in d or d.get(\"suspended\") is True)")" = True ]' \
&& ok "and on GoToSocial" || ko "GoToSocial still shows the deleted persona: $(gcurl -H "$GH" "$G/api/v1/accounts/$doom_on_g" | head -c 200)"
self="modr_self_$run"
privapub_token "$self" >/dev/null
self_id=$(api "$ADMIN" GET /clientapi/avatar/private/list | j "print(next(a['id'] for a in d if a['userName'] == '$self'))")
[ "$(code_of "$ADMIN" POST /clientapi/avatar/private/delete -d "{\"avatarId\":\"$self_id\",\"password\":\"not it\"}")" = 403 ] \
&& [ "$(code_of "$ADMIN" POST /clientapi/avatar/private/delete -d "{\"avatarId\":\"$self_id\",\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$self" && ok "a login deletes its own persona with its password" || ko "the login could not delete its persona"
group="modr_grp_$run"
group_id=$(api "$ADMIN" POST /clientapi/group/insert -d "{\"avatarId\":\"$reader_id\",\"userName\":\"$group\",\"name\":\"Pasture group $run\",\"isCommunity\":true}" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST /clientapi/group/delete -d "{\"avatarId\":\"$reader_id\",\"groupId\":\"$group_id\",\"password\":\"not it\"}")" = 403 ] \
&& [ "$(code_of "$ADMIN" POST /clientapi/group/delete -d "{\"avatarId\":\"$reader_id\",\"groupId\":\"$group_id\",\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/$group" && ok "a group's owner deletes it" || ko "the owner could not delete the group"
group2_id=$(api "$ADMIN" POST /clientapi/group/insert -d "{\"avatarId\":\"$reader_id\",\"userName\":\"${group}b\",\"name\":\"Pasture group b $run\",\"isCommunity\":true}" | j "print(d['id'])")
[ "$(code_of "$ADMIN" POST "/clientapi/admin/groups/$group2_id/delete" -d "{\"password\":\"$ROOT_PASS\"}")" = 200 ] \
&& gone_unsigned "https://privapub.test/peasants/${group}b" && ok "and an administrator deletes another" || ko "the administrator could not delete the group"
echo " posts removed"
LT=$(privapub_token modr_loud); loud_id=$(me_id "$LT")
loud_on_m=$(m_resolve modr_loud); m_follow "$loud_on_m"
until_true 30 '[ "$(m_sql "select count(*) from follows where account_id = $m_me and target_account_id = $loud_on_m")" = 1 ]' && ok "mastouser follows modr_loud" || ko "mastouser's follow of modr_loud was not accepted"
first=$(post_public "$LT" "removed directly $run"); first_id=$(echo "$first" | j "print(d['id'])"); first_uri=$(echo "$first" | j "print(d['uri'])")
second=$(post_public "$LT" "removed through a report $run"); second_id=$(echo "$second" | j "print(d['id'])"); second_uri=$(echo "$second" | j "print(d['uri'])")
until_true 30 '[ "$(m_holds "$first_uri")" = 1 ] && [ "$(m_holds "$second_uri")" = 1 ]' && ok "both its posts reach Mastodon" || ko "modr_loud's posts never reached Mastodon"
[ "$(code_of "$MOD" POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$first_id\",\"note\":\"pasture\",\"warning\":\"That one broke a rule.\"}")" = 200 ] \
&& [ "$(code "$P/api/v1/statuses/$first_id")" = 404 ] && ok "the moderator removes one: it answers 404 here" || ko "the post was not removed"
until_true 30 '[ "$(m_holds "$first_uri")" = 0 ]' && ok "and is gone on Mastodon" || ko "Mastodon still holds the removed post"
report_id=$(curl -s -X POST -H "Authorization: Bearer $RT" "$P/api/v1/reports" -d "account_id=$loud_id" -d "status_ids[]=$second_id" --data-urlencode "comment=pasture $run" | j "print(d['id'])")
[ "$(code_of "$MOD" POST "/clientapi/moderator/reports/$report_id/resolve" -d '{"action":"remove_posts","note":"pasture"}')" = 200 ] \
&& [ "$(code "$P/api/v1/statuses/$second_id")" = 404 ] && ok "a report resolved by removing what it names" || ko "the report's post was not removed"
until_true 30 '[ "$(m_holds "$second_uri")" = 0 ]' && ok "gone on Mastodon too" || ko "Mastodon still holds the reported post"
[ "$(api "$MOD" GET "/clientapi/moderator/reports?resolved=true" | j "print(any(r['id'] == '$report_id' for r in d))")" = True ] && ok "and the report is resolved" || ko "the report is not resolved"
[ "$(warned "$LT" delete_statuses)" = True ] && ok "its author is warned (moderation_warning, delete_statuses)" || ko "modr_loud was not warned"
[ "$(code_of "$MOD" POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$g_post\"}")" = 200 ] && [ "$(code -H "$WH" "$P/api/v1/statuses/$g_post")" = 404 ] \
&& ok "a copy of gtsuser's post is taken down here" || ko "the remote copy was not taken down"
echo " the staff's log"
log=$(api "$ADMIN" GET "/clientapi/moderator/log?limit=100")
missing=$(echo "$log" | j "
seen={i['action'] for i in d['items']}
print(' '.join(sorted({'root.role','root.reset_totp','persona.silence','persona.unsilence','persona.suspend','persona.unsuspend','account.suspend','account.unsuspend','domain_block.insert','domain_block.delete','root.ban','root.unban','persona.delete','group.delete','post.remove','report.resolve'} - seen)))")
[ -z "$missing" ] && [ -n "$log" ] && ok "it lists every act of the scenario" || ko "the log misses: $missing"
[ "$(echo "$log" | j "print(any(i['action'] == 'report.resolve' and i.get('reportId') == '$report_id' and (i.get('note') or '').startswith('remove_posts') for i in d['items']) and any(i['action'] == 'post.remove' and i.get('reportId') == '$report_id' for i in d['items']))")" = True ] \
&& ok "a resolution says what it did, and the removal names its report" || ko "the resolution's entries: $(echo "$log" | j "print([i for i in d['items'] if i['action'] in ('report.resolve','post.remove')][:3])")"
[ "$(echo "$log" | j "print(any(i['action'] == 'persona.suspend' and i.get('by') == 'modr_staff' and i['targetKind'] == 'persona' for i in d['items']))")" = True ] \
&& [ "$(echo "$log" | j "print(not any(i['targetKind'] == 'root' and '@' in (i.get('targetLabel') or '') for i in d['items']))")" = True ] \
&& ok "each act names who did it, and a login's entries name no persona" || ko "the log pairs a login with a persona"
[ "$(api "$MOD" GET "/clientapi/moderator/log?limit=100" | j "print(not any(i['targetKind'] == 'root' for i in d['items']))")" = True ] \
&& ok "a moderator reads none of the logins' entries" || ko "the moderator read a login's entry"
echo "$log" | grep -qE '127\.0\.0\.1|11\.42\.|::1' && ko "the log holds an address" || ok "and it holds no address"
for id in $(api "$MOD" GET /clientapi/moderator/accounts/moderated | j "print(' '.join(a['id'] for a in d['items']))"); do
moderate "$id" unsuspend >/dev/null; moderate "$id" unsilence >/dev/null
done
+29 -3
View File
@@ -1,8 +1,9 @@
# Backup and restore (owner decisions 2026-10-07), on the live pasture and through the administrator's endpoints: the # Backup and restore (owner decisions 2026-10-07), on the live pasture and through the administrator's endpoints: the
# server is backed up, then life goes on: alice_restore deletes a post and makes another, mastouser follows her, she # server is backed up, then life goes on: alice_restore deletes a post and makes another, mastouser follows her, she
# blocks bob_restore, and carol_restore is made. The backup is restored: PrivaPub stops, restores it as it starts again, # blocks bob_restore, carol_restore is made, and the staff suspend dave_restore, silence mrestore on Mastodon and take
# and nothing protective is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still # down a post of alice's. The backup is restored: PrivaPub stops, restores it as it starts again, and nothing protective
# follows her, the block holds and carol's name stays taken. Every session ends, so the scenario signs in again, and at # is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still follows her, the block
# holds, carol's name stays taken, and the suspension, the silence and the takedown hold (R6). Every session ends, so the scenario signs in again, and at
# the end the town's PrivaPub accounts too (town.sh renew privapub). Run it alone: it restores the whole server. Needs # the end the town's PrivaPub accounts too (town.sh renew privapub). Run it alone: it restores the whole server. Needs
# the mastodon peer. # the mastodon peer.
M=https://mastodon.test:6443 M=https://mastodon.test:6443
@@ -10,6 +11,7 @@ mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; }
. "$here/peers/mastodon.sh" . "$here/peers/mastodon.sh"
m_rails() { podman exec pasture-mastodon bin/rails runner "$1" 2>/dev/null | tail -1; } m_rails() { podman exec pasture-mastodon bin/rails runner "$1" 2>/dev/null | tail -1; }
p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1"; } p_mongo() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval "$1"; }
staff() { local method=$1 path=$2; shift 2; curl -s -X "$method" -H "$RH" -H 'Content-Type: application/json' "$P$path" "$@"; }
echo "restore" echo "restore"
podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true
@@ -26,6 +28,14 @@ alice_uri=$(echo "$alice" | j "print(d['url'])" | sed 's|/@|/peasants/|')
bob_id=$(curl -s -H "Authorization: Bearer $BT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") bob_id=$(curl -s -H "Authorization: Bearer $BT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
regret=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post regretted later $run&visibility=public") regret=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post regretted later $run&visibility=public")
regret_id=$(echo "$regret" | j "print(d['id'])"); regret_uri=$(echo "$regret" | j "print(d['uri'])") regret_id=$(echo "$regret" | j "print(d['id'])"); regret_uri=$(echo "$regret" | j "print(d['uri'])")
DT=$(privapub_token dave_restore)
dave_id=$(curl -s -H "Authorization: Bearer $DT" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])")
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
down_id=$(curl -s -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post the staff take down later $run&visibility=public" | j "print(d['id'])")
mastodon_user mrestore
mr_id=$(curl -s -H "$AH" "$P/api/v2/search?q=mrestore@mastodon.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
[ -n "$dave_id" ] && [ -n "$down_id" ] && [ -n "$mr_id" ] && ok "dave_restore, a post to take down and mrestore on Mastodon" || ko "the moderation's subjects: $dave_id $down_id $mr_id"
# mastouser follows nobody here yet: the follow comes after the backup # mastouser follows nobody here yet: the follow comes after the backup
m_follows() { m_rails "puts Follow.exists?(account: Account.find_local(\"mastouser\"), target_account: Account.find_by(uri: \"$alice_uri\")) ? \"True\" : \"False\""; } m_follows() { m_rails "puts Follow.exists?(account: Account.find_local(\"mastouser\"), target_account: Account.find_by(uri: \"$alice_uri\")) ? \"True\" : \"False\""; }
alice_on_m=$(mcurl -H "Authorization: Bearer $(mastodon_token)" "$M/api/v2/search?q=@alice_restore@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])") alice_on_m=$(mcurl -H "Authorization: Bearer $(mastodon_token)" "$M/api/v2/search?q=@alice_restore@privapub.test&resolve=true&type=accounts" | j "print(d['accounts'][0]['id'])")
@@ -57,6 +67,13 @@ curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$bob_id/block"
carol="carol_restore_$run" carol="carol_restore_$run"
curl -s -o /dev/null -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \ curl -s -o /dev/null -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"made after the backup\"}" -d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"made after the backup\"}"
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"suspend\",\"note\":\"after the backup\"}" >/dev/null
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"silence\",\"note\":\"after the backup\"}" >/dev/null
staff POST /clientapi/moderator/posts/remove -d "{\"postId\":\"$down_id\",\"note\":\"after the backup\"}" >/dev/null
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
&& [ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
&& [ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
&& ok "the staff suspend dave_restore, silence mrestore and take a post down after the backup" || ko "the staff's acts after the backup"
echo " restored" echo " restored"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/admin/backups/$backup/restore" \ [ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/admin/backups/$backup/restore" \
@@ -81,6 +98,15 @@ gone_unsigned "$since_uri" && ok "the post made after the backup answers as gone
&& ok "alice still blocks bob_restore" || ko "the block made after the backup was undone" && ok "alice still blocks bob_restore" || ko "the block made after the backup was undone"
[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \ [ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$RH" -H 'Content-Type: application/json' "$P/clientapi/avatar/private/insert" \
-d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"again\"}")" != "200" ] && ok "carol's name stays taken" || ko "carol's name was free again" -d "{\"userName\":\"$carol\",\"name\":\"carol\",\"biography\":\"again\"}")" != "200" ] && ok "carol's name stays taken" || ko "carol's name was free again"
[ "$(staff GET "/clientapi/moderator/accounts/$dave_id" | j "print(bool(d.get('suspendedAt')))")" = True ] \
&& ok "dave_restore stays suspended" || ko "the suspension made after the backup was undone"
[ -z "$(privapub_token dave_restore)" ] && ok "and gets no token" || ko "the suspended persona got a token after the restore"
[ "$(staff GET "/clientapi/moderator/accounts/$mr_id" | j "print(bool(d.get('silencedAt')))")" = True ] \
&& ok "mrestore stays silenced" || ko "the silence made after the backup was undone"
[ "$(curl -s -o /dev/null -w '%{http_code}' -H "$AH" "$P/api/v1/statuses/$down_id")" = 404 ] \
&& ok "the post taken down stays down" || ko "the post taken down after the backup came back"
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$dave_id\",\"action\":\"unsuspend\"}" >/dev/null
staff POST /clientapi/moderator/accounts/moderate -d "{\"accountId\":\"$mr_id\",\"action\":\"unsilence\"}" >/dev/null
status=$(podman exec -w /app pasture-privapub /app/PrivaPub admin restore --status 2>/dev/null | grep -o "personas [^,;]*" | head -1) status=$(podman exec -w /app pasture-privapub /app/PrivaPub admin restore --status 2>/dev/null | grep -o "personas [^,;]*" | head -1)
echo "$status" | grep -q "$carol" && ok "the report names carol among the personas made since" || ko "the report: $status" echo "$status" | grep -q "$carol" && ok "the report names carol among the personas made since" || ko "the report: $status"
+4 -3
View File
@@ -7,8 +7,9 @@
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log. # run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order) # usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
# A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync # A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync
# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings, last: it # pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings: it changes
# changes them all and puts them back) # them all and puts them back) moderation (gts and mastodon; after instance, whose log check expects only the
# administrator's acts)
# B lemmy lemmy19 piefed mbin nodebb # B lemmy lemmy19 piefed mbin nodebb
# C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale # C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale
# D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla # D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla
@@ -28,7 +29,7 @@ batch_peers() {
} }
batch_scenarios() { batch_scenarios() {
case "$1" in case "$1" in
A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance" ;; A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance moderation" ;;
*) batch_peers "$1" ;; *) batch_peers "$1" ;;
esac esac
} }