diff --git a/CLAUDE.md b/CLAUDE.md index b5316dd..826908a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,14 +83,15 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS) Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar, Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports, - the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the - server's settings, thumbnail, announcements, relays) + accounts and posts moderated, the staff's log), Admin (logins: roles, bans, deletions, + two-factor resets), DomainBlock, Statistics, Backup, InstanceAdmin (the server's + settings, thumbnail, announcements, relays) Infrastructure/ Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker Ids/ PrivacyIds (day-only ids for personas and groups, arrival-ordered ids for remote posts) Data/ Indexes (created at start), EntityMaps.Warm, Migrations/_NNN_*.cs - Cli/ AdminCommands (`PrivaPub admin promote|demote|create-root|move-persona|delete-root|reset-totp`, + Cli/ AdminCommands (`PrivaPub admin promote|demote|role|create-root|move-persona|delete-root|reset-totp`, `media audit`, `backup`, `backups`, `restore`) Backup/ ServerBackup, ServerRestore, ProtectiveMerge, MaintenanceLock, TransferStore Geo/ GeoLocator, GeoUpdater (DB-IP Lite), CdnCatalog, CdnUpdater @@ -120,7 +121,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags; Document: the server's own texts) Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings - Moderation/ ModerationLog (the staff's log, AdminAction) + Moderation/ ModerationLog (the staff's log, AdminAction), AccountModeration (silence, suspend, posts + removed), Restrictions (who is held back, in memory), ModerationFlags (posts' flags again) Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Privacy/ VisibilityPolicy (IsPublic expression, CanSee) @@ -140,7 +142,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 provenance; the stubs are `StubsController` in `SearchController.cs` Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex); OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent) - Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … + Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, RootRemoval, + PersonaRemoval and GroupRemoval (deleting a login, a persona, a group), … Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup, ServerSettings, AdminAction StaticServices/ DbEntities (Find accessors), AuthTokenManager (JWT), PasswordHasher @@ -188,6 +191,7 @@ made (signing up as "admin" grants nothing): cd /var/www/privapub.thepra.dev echo '' | ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin create-root --admin # password on stdin ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin promote|demote +ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin role # promote/demote are admin/user ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin move-persona # its link moves, its Mastodon tokens end ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin delete-root # as the client deletes a login: its personas go too ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp # a login that lost its authenticator and codes @@ -195,8 +199,9 @@ ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp # a logi The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through group www-data and reaches the private mongod; `sudo -u www-data` works too. Every one of them that changes a login or a -persona (promote, demote, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as -the command line's (`cli`). +persona (promote, demote, role, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's +log as the command line's (`cli`). The command line may leave the server without an administrator (it says so); the +client's role route never does. ## The server's own settings (completion program R5) @@ -262,13 +267,85 @@ the command line's (`cli`). by their creation (their dates are spread on purpose). - **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action, the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas - (an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It + (an act on a persona the acting root owns, or since R6 on a post of it, a group it owns or a report about it, is kept + without who acted; a root's entry names none of its personas). It keeps domain blocks, backups (create, delete, restore), settings saves (the fields changed, never their values), - rules, the thumbnail, announcements, relays, report resolutions and the CLI's acts. `GET + rules, the thumbnail, announcements, relays, report resolutions, the CLI's acts and, since R6, every act of moderation + (below). `GET /clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was the command line. Writing never fails the act it records. +## Moderation (completion program R6) + +- **Roles.** A login is a user, a moderator (also a user) or an administrator (all three): `RootUser.SetRole` writes its + `Policies` (`SetAdmin` is a wrapper), and JwtEvents reads them from the database on every request. Moderators handle + reports, silence and suspend personas and accounts elsewhere, and remove posts; never logins (roles, bans, deletions, + two-factor), settings, emoji, relays, backups or statistics, which are the administrators'. +- **What an act does** (`Domain/Moderation/AccountModeration.cs`, following the `AuthorGone` pattern): the account keeps + when it was silenced or suspended (`Avatar`/`ForeignAvatar.SilencedAt`, `SuspendedAt`, `AccountState`, the staff's + `ModerationNote`), and its posts carry `Post.AuthorLimited` or `Post.AuthorSuspended`, set on what it already wrote + and on what arrives or is written later (`RemotePosts.Build`, `AnnounceHandler`, `StatusService`, archive imports), so + a query needs no join. `Restrictions` (static, in memory, reloaded after every act and when five minutes old) holds + the suspended personas, the silenced ones, and the suspended accounts elsewhere with their own inboxes. + - **Silenced** (a persona, an account elsewhere, or a server: a domain block of `Silence` limits every post of its + accounts and subdomains, on arrival and when the block is made or lifted, `AccountModeration.DomainChanged`): + `VisibilityPolicy.IsListed` keeps its posts out of the public and hashtag timelines and streams, trends, followed + hashtags, status search (but what the reader met itself), the `/tags` page and the relays; its account out of the + directory, trends and search by a part of its name (its exact name still finds it). Its followers still get it, and + anyone may still open it; the Mastodon account says `limited`. + - **Suspended, a persona** (owner decision 2026-10-17, **tells other servers nothing new**): its Mastodon tokens are + revoked and refused (`MastodonController`, 401; `TokenController` and `/oauth/authorize` give it none), its streams + close, `/clientapi` acts as it nowhere (posts, follows, groups, profile edits, archive imports; its archive may still + be exported), it delivers nothing (`DeliveryService.HeldBack`, at enqueueing and again at sending; the one exception + is the Delete of itself when it is deleted), it takes no follower (`FollowHandler` leaves the request unanswered), + its scheduled posts wait (up to a month), `IsShown` hides its posts from everyone, the Mastodon mapper shows its + account to nobody (404 for the account, its statuses, lookup), and `PeasantsController` answers 404 for everything + of it but its actor and its inbox, signed or not; the actor, WebFinger and the server's counts stay exactly as they + were. Unsuspending gives everything back but the revoked tokens. **It is warned** with Mastodon's + `moderation_warning` notification (`Notifications.Warn`: from itself, as Mastodon's are, `Notification.WarningAction` + and the moderator's `Warning` text), which it reads once it can sign in again; meanwhile its login sees it + `Suspended` in `/clientapi/avatar/private/list`. Silencing warns it too, and a removed post warns its author + (`delete_statuses`). + - **Suspended, an account elsewhere:** its posts are hidden as above, `InboxProcessor` drops what it sends (but a + `Delete` or an `Undo`, which only take away what we hold), and nothing is addressed to it or delivered to its own + inbox (its server's shared inbox still serves the others there). It is told nothing. + - **A banned login** (`/clientapi/admin/ban/users`): its sessions end and `Restrictions` holds its personas back as + suspended ones (`Counted.HeldBackPersonas`, which is `BarredPersonas` plus the suspended personas), **without writing + anything on any of them**: flagging every persona of a login at once would pair them in stored data a moderator or a + backup reads. Where posts are read one at a time `VisibilityPolicy.Shown` asks `Restrictions` (`Barred`); lists leave + them to the Mastodon mapper, which shows no account of theirs and so none of their posts. Unbanning gives them back. + - **A post removed:** a post here is deleted as its author deletes it (`StatusService.Remove`: emptied, a Delete to its + audience, unless its author is suspended); a copy of a post elsewhere gets `Post.ModeratedAt`, hidden everywhere by + `IsShown` and never taken in again (its address stays known). + - **Deleting** a persona (`Services/PersonaRemoval.cs`) or a group (`Services/GroupRemoval.cs`) deletes everything + public it had, as `RootRemoval` always did for a login's personas (and now does through PersonaRemoval): its + tokens end, a persona's groups go with it, a Delete of the actor goes to its followers, the accounts it follows and + a group's members elsewhere, its posts are emptied, its name stays reserved and its documents answer 410. +- **Routes**, every one kept in the staff's log (`persona.silence|unsilence|suspend|unsuspend|delete`, + `account.silence|unsilence|suspend|unsuspend`, `post.remove`, `report.resolve` with its action in the note, + `group.delete`, `root.role|ban|unban|delete|reset_totp`); an entry about a persona of the acting login, a post of it, a + group it owns or a report about it is kept without who acted: + - moderators (`ModeratorController`): `GET /clientapi/moderator/accounts?q=` (a name, `user@host` or an actor's address: + personas here, never their logins, and accounts elsewhere), `GET /accounts/moderated?offset=&limit=`, + `GET /accounts/{id}` (`ViewModeratedAccount`, `ViewModeratedAccountsPage`), `POST /accounts/moderate` + (`ModerateAccountForm`: `silence`, `unsilence`, `suspend`, `unsuspend`, a `Note` for the staff, a `Warning` for the + persona), `POST /posts/remove` (`RemovePostForm`), `POST /reports/{id}/resolve` (an optional `ResolveReportForm`: + `none`, `silence`, `suspend` or `remove_posts`, the act first, so one that fails leaves the report open; kept as + `Report.Resolution` and `ResolutionNote`, which `ViewReport` shows); + - administrators (`AdminController`, `Authorize(IsAdmin)`): `GET /clientapi/admin/roots?q=&before=&limit=` and + `/roots/{id}` (`ViewAdminRoot`: the one place a login is shown with its personas and the groups they own; `q` matches + a login's name or a persona's), `POST /roots/{id}/role` (`RootRoleForm`; never one's own, 409, and never the last + administrator: a demotion that leaves none is undone, 409), `POST /roots/{id}/totp/reset` (as `admin reset-totp`; + never one's own), `POST /personas/{id}/delete` and `/groups/{id}/delete` (`AdminPasswordForm`: the administrator's own + password, 403 when wrong), and the older `/ban/users`, `/unban/users`, `DELETE /remove/users`, now logged; + - the login itself: `POST /clientapi/avatar/private/delete` (`DeletePersonaForm`, its password) and the group's owner + `POST /clientapi/group/delete` (`DeleteGroupForm`, the login's password). Neither is a staff act, so neither is logged. +- **Data:** migration `_020` and every restore run `ModerationFlags.Recompute` (the posts' flags and the accounts' + `AccountState` from their moderation and the silenced servers); `ProtectiveMerge` keeps P's silences and suspensions, + here and elsewhere, and the posts taken down, before it does; indexes on `SilencedAt` and `SuspendedAt` of both account + collections and a partial one on `Post.ModeratedAt`. No new collection, so backups hold it all as they did. + ## Federation invariants 1. **Every outbound request goes through `IFederationHttp`.** Its handler resolves the name itself and connects only @@ -341,13 +418,17 @@ the command line's (`cli`). 11. **Every number about posts and users goes through `Domain/Privacy/Counted`** (owner decision 2026-10-04: one answer everywhere): a persona's `statuses_count`, its outbox `totalItems`, NodeInfo `localPosts` and the instance `status_count` all count Mastodon's way (not deleted, not a DM, boosts included, circle and located posts too); - users exclude personas of banned or deleted roots; `replies_count` counts only public and unlisted replies; a + users exclude personas of banned or deleted roots (a suspended persona still counts, and its posts: a count tells + nothing new of it); `replies_count` counts only public and unlisted replies; a remote account that deletes itself takes its likes, votes, reactions, boosts and replies out of every count (`GoneActors`). Follower and following counts are public, their members never are (`hide_collections` is always true). 12. **Every "may anyone see this" goes through `VisibilityPolicy.IsPublic`;** a persona-specific read uses `CanSee`, and - any other read of stored posts filters by `IsShown`. All three hide deleted posts and the posts of a remote account - that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision). + any other read of stored posts filters by `IsShown`. All three hide deleted posts, the posts of a remote account + that deleted itself (`Post.AuthorGone`: kept, hidden everywhere, owner decision), of a suspended author + (`AuthorSuspended`) and those the staff took down (`ModeratedAt`); a post read one at a time (`Shown`) is also hidden + when its persona's login is banned (`Restrictions`). Where strangers browse (public and hashtag timelines, trends, + search, relays) `IsListed` leaves out a silenced author's posts (`AuthorLimited`, R6). 13. **Remote content is stored only when someone here asked for it:** a persona follows the author, is addressed or mentioned, it replies to a local post, or it is addressed to a community the author follows; a public parent is fetched as context. Followers-only is detected by the author's stored `followers` URL. @@ -379,7 +460,9 @@ the command line's (`cli`). deletion, session stamp). The issued token is the same kind as the authorization code flow's, so revocation and `RootSessions` cover it. 2. `/api/*` authenticates with OpenIddict validation, everything else with the old JWT (`PrivaPub` policy scheme). - Every `/api` request re-checks that the persona's root is neither banned nor deleted (`MastodonController`). + Every `/api` request re-checks that the persona's root is neither banned nor deleted and the persona neither deleted + nor suspended (`MastodonController`); a persona's token that can no longer act answers 401 everywhere, as a revoked + one does on Mastodon (R6). 3. Read parameters through `Params` (query, form and JSON merged Rails-style), never MVC binding. A value type read from a conditional must say `(int?)null`, not `default`: that bug once made every list one item long. 4. Answer with `Json(...)` (snake_case, explicit nulls) or `Error(status, message)`; page lists with `Page` and `Link`. @@ -511,7 +594,12 @@ the command line's (`cli`). - **Deleting a root deletes everything public it had** (`RootRemoval`, from the admin route or `/clientapi/user/delete` with the password): its sessions end, each persona and each group it owns sends `Delete{Actor}` to followers, members and the accounts it follows, the personas' posts are emptied, and `/peasants/{name}`, its inbox and WebFinger answer - 410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. + 410 (`LocalActorService.Gone`) while the names stay reserved. The root keeps only a `deleted-{id}` name. One persona + or one group is deleted the same way (`PersonaRemoval`, `GroupRemoval`, R6), by its login or owner with the password, + or by an administrator with theirs. +- **Moderation never pairs a login with its personas** (R6): a ban is held in memory (`Restrictions`) and never written + on the personas or their posts; moderators see personas and accounts, never logins (`ViewModeratedAccount`), and only + administrators read a login next to its personas (`ViewAdminRoot`); the staff's log follows R5's rule. - **Never derive a public name from the root username.** Invitation sign-up takes `AvatarUserName` and refuses one equal to the login. - **One username space:** personas, groups and the instance reserve their name in `ReservedName` (unique index) before @@ -650,7 +738,9 @@ the command line's (`cli`). 4. **the protective merge from P: a restore never undoes a protective act.** Followers and follows are P's; blocks, mutes, domain blocks, being blocked, reserved names, `DeletedObject`, reports, filters and OAuth applications are the union, P's row winning; roots, personas, groups, posts and remote accounts deleted since are deleted again, with - what their deletion takes away; P's moderation of a persona and a root's password, e-mail, ban and policies stay; + what their deletion takes away; P's moderation of a persona or an account elsewhere (silenced, suspended), the posts + the staff took down, and a root's password, e-mail, ban and policies stay, and every post's moderation flags are + worked out again (`ModerationFlags`); roots, personas and groups made since become tombstones (deleted, names kept), local posts made since answer 410, media made or trashed since go to the trash (but the server's own pictures, `ServerUse`); the staff's log (`AdminAction`) is the union, so a restore never loses an entry, its own request included, and the server's settings @@ -885,6 +975,21 @@ tools/pasture/run.sh down # removes e beside the configured actor), accepted and removed without ending the configured subscription; the activity once published; the staff's log listing it all without an address. It saves the settings it found at the start back at the end (the description's and the policy's dates then say today). +- **Moderation (`scenarios/moderation.sh`, needs gts and mastodon; after instance in batch A, whose log check expects + the administrator's acts alone):** a second login made a moderator by `create-root` and the role route (its own role + refused, 409; a moderator refused the logins, 403; two-factor reset); a silenced persona's post out of the public, + local and hashtag timelines and search, still in a follower's home here and on Mastodon, its account `limited`, the + persona warned; a suspended persona's token 401, its account, statuses, post and lookup 404, its actor 200 and its + outbox and post 404 to a signed GET (Mastodon's `Request` through `rails runner`, since SecureMode answers unsigned + ones 401), OAuth no longer offering it, Mastodon's copy of the account untouched, then back whole (a new token) and + warned; gtsuser suspended: its post and account gone here, what it sends meanwhile dropped (Mongo), a DM to it never + sent, then its earlier post back and new ones arriving; gts.test silenced through a domain block and back; a banned + login's personas 401 and 404, a Mastodon follow left waiting (`follow_requests`), nothing written on either persona, + back once unbanned; a persona deleted by the administrator (password checked, moderators refused) gone on Mastodon and + GoToSocial, one deleted by its own login, groups deleted by their owner and by the administrator (410); posts removed + directly and through a report gone on Mastodon, the author warned, a GoToSocial copy taken down here; the staff's log + listing every act, the report's action in its note, no login entry naming a persona, none shown to the moderator, and + no address. - **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json` sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to `https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the diff --git a/FEDERATION.md b/FEDERATION.md index fac0dfa..c3b3623 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -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 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 PrivaPub keeps statistics about servers, never about accounts (see `/stargazing` on the server). diff --git a/PrivaPub.ClientModels/Admin/Moderation.cs b/PrivaPub.ClientModels/Admin/Moderation.cs new file mode 100644 index 0000000..e3f849d --- /dev/null +++ b/PrivaPub.ClientModels/Admin/Moderation.cs @@ -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 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 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 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 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; } + } +} diff --git a/PrivaPub.ClientModels/Admin/ViewReport.cs b/PrivaPub.ClientModels/Admin/ViewReport.cs index 2e38025..9940201 100644 --- a/PrivaPub.ClientModels/Admin/ViewReport.cs +++ b/PrivaPub.ClientModels/Admin/ViewReport.cs @@ -16,6 +16,8 @@ namespace PrivaPub.ClientModels.Admin public string Comment { get; set; } public bool Forwarded { 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? ResolvedAt { get; set; } public DateTime? RemoteResolvedAt { get; set; }//the server holding what was reported said it dealt with it diff --git a/PrivaPub.ClientModels/Group/JoinGroupForm.cs b/PrivaPub.ClientModels/Group/JoinGroupForm.cs index ca3baf9..0d4cff1 100644 --- a/PrivaPub.ClientModels/Group/JoinGroupForm.cs +++ b/PrivaPub.ClientModels/Group/JoinGroupForm.cs @@ -17,6 +17,19 @@ namespace PrivaPub.ClientModels.Group 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 { [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource))] diff --git a/PrivaPub.ClientModels/User/DeletePersonaForm.cs b/PrivaPub.ClientModels/User/DeletePersonaForm.cs new file mode 100644 index 0000000..a996614 --- /dev/null +++ b/PrivaPub.ClientModels/User/DeletePersonaForm.cs @@ -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; } + } +} diff --git a/PrivaPub/Api/Mastodon/Auth/TokenController.cs b/PrivaPub/Api/Mastodon/Auth/TokenController.cs index 3c3ff5a..959704d 100644 --- a/PrivaPub/Api/Mastodon/Auth/TokenController.cs +++ b/PrivaPub/Api/Mastodon/Auth/TokenController.cs @@ -82,7 +82,8 @@ namespace PrivaPub.Api.Mastodon.Auth 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 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 diff --git a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs index f897e6d..a28508e 100644 --- a/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/AccountsController.cs @@ -158,11 +158,12 @@ namespace PrivaPub.Api.Mastodon.Controllers if (parts.Length == 1 || parts[1].Equals(localDomain, StringComparison.OrdinalIgnoreCase)) { 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 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)); } @@ -203,7 +204,8 @@ namespace PrivaPub.Api.Mastodon.Controllers public async Task Statuses(string id, CancellationToken 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(); if (Params.Bool("pinned") == true) { @@ -292,7 +294,8 @@ namespace PrivaPub.Api.Mastodon.Controllers public async Task Follow(string id, CancellationToken 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(); // a feed is read through the server, whose reader follows its communities; the persona follows nothing if (remote is { AvatarType: AvatarType.Feed }) diff --git a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs index b0424f2..684da3a 100644 --- a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs @@ -65,7 +65,7 @@ namespace PrivaPub.Api.Mastodon.Controllers if (string.IsNullOrEmpty(id)) return default; var avatar = await DB.Default.Find().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 await _mapper.Local(_localActors.FromAvatar(avatar), false, token); } diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 2e6c0a4..73f909a 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -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 async Task> 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); if (candidates.Count == 0) return new List(); @@ -128,7 +128,7 @@ namespace PrivaPub.Api.Mastodon.Controllers { var author = post.AuthorAccountId ?? post.GroupUserId; 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)) found.Add(post); if (found.Count >= offset + limit) diff --git a/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs b/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs index a43a2b3..c27bae3 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs @@ -159,6 +159,8 @@ namespace PrivaPub.Api.Mastodon.Controllers continue; 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; } catch (Exception ex) when (ex is OperationCanceledException or WebSocketException) @@ -202,20 +204,23 @@ namespace PrivaPub.Api.Mastodon.Controllers try { await Write(":)\n\n"); + using var done = CancellationTokenSource.CreateLinkedTokenSource(token); 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"); } - }, token); + }, done.Token); await foreach (var (_, e) in listener.Queue.Reader.ReadAllAsync(token)) { if (await Payload(e, token) is not { } payload) continue; await Write($"event: {e.Name}\ndata: {payload}\n\n"); } + // closed from here (the persona was suspended): the response ends + done.Cancel(); await beating; } catch (OperationCanceledException) diff --git a/PrivaPub/Api/Mastodon/Controllers/TagsController.cs b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs index e6b3c62..e528e83 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TagsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TagsController.cs @@ -44,6 +44,7 @@ namespace PrivaPub.Api.Mastodon.Controllers var since = today.AddDays(-6); var posts = await dbEntities.Posts .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)) .ExecuteAsync(token); return Enumerable.Range(0, 7).Select(back => today.AddDays(-back)).Select(day => diff --git a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs index 0dc8b02..e96735d 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TimelinesController.cs @@ -54,7 +54,8 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/timelines/public"), Scope("read:statuses", requiresUser: false), Microsoft.AspNetCore.Authorization.AllowAnonymous] public async Task 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) query.Match(p => !p.IsFederatedCopy); if (Params.Bool("remote") == true) @@ -88,7 +89,8 @@ namespace PrivaPub.Api.Mastodon.Controllers public async Task Tag(string hashtag, CancellationToken token) { 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) query.Match(p => !p.IsFederatedCopy); return await Respond(query, $"/api/v1/timelines/tag/{hashtag}", token); diff --git a/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs index dc36449..c24a133 100644 --- a/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/TrendsController.cs @@ -90,30 +90,30 @@ namespace PrivaPub.Api.Mastodon.Controllers var offset = Math.Max(0, Params.Int("offset") ?? 0); var limit = Limit(40, 80); var localOnly = Params.Bool("local") == true; - var barred = await Counted.BarredPersonas(token); + var barred = await Counted.HeldBackPersonas(token); var wanted = offset + limit; var ordered = new List(); 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 - 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); if (personas.Count == wanted) { var edge = personas[^1].PublishedOn; 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)); } var remote = localOnly ? new List() - : 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); if (remote.Count == wanted) { var edge = remote[^1].Published; 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)); } ordered = personas.Select(a => (a.ID, Joined: a.PublishedOn, Tie: Tie(a.UserName))) @@ -123,7 +123,7 @@ namespace PrivaPub.Api.Mastodon.Controllers } 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(); var seen = new HashSet(); 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 discoverableRemote = remoteIds.Count == 0 ? new HashSet() - : (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(); ordered.AddRange(fresh.Where(a => a.IsFederatedCopy ? discoverableRemote.Contains(a.Id) : discoverableLocal.Contains(a.Id)).Select(a => a.Id)); } diff --git a/PrivaPub/Api/Mastodon/Entities/Entities.cs b/PrivaPub/Api/Mastodon/Entities/Entities.cs index 694f57a..00f54c6 100644 --- a/PrivaPub/Api/Mastodon/Entities/Entities.cs +++ b/PrivaPub/Api/Mastodon/Entities/Entities.cs @@ -28,6 +28,7 @@ public List Emojis { get; set; } = new(); public List Roles { get; set; } = new(); 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 HeaderDescription { get; set; } = string.Empty; public List Fields { get; set; } = new(); diff --git a/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs b/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs index 64ee62c..552427a 100644 --- a/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs +++ b/PrivaPub/Api/Mastodon/Infrastructure/MastodonController.cs @@ -53,7 +53,15 @@ namespace PrivaPub.Api.Mastodon.Infrastructure context.Result = Error(StatusCodes.Status403Forbidden, "This action is outside the authorized scopes"); 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) { @@ -75,7 +83,10 @@ namespace PrivaPub.Api.Mastodon.Infrastructure var root = await db.RootUsers.MatchID(link.RootId).ExecuteFirstAsync(token); if (root is not { IsBanned: false, DeletedAt: null }) return default; - return await services.GetRequiredService().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().FromAvatar(avatar) is { Suspended: false } me ? me : default; } protected JsonResult Json(object value, int status = StatusCodes.Status200OK) => diff --git a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs index fa02018..e40b231 100644 --- a/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs +++ b/PrivaPub/Api/Mastodon/Mappers/AccountSearch.cs @@ -64,22 +64,25 @@ namespace PrivaPub.Api.Mastodon.Mappers var actorUri = await _remoteActors.ResolveHandle(q, 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)); return results; } 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 - .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) .Limit(offset + limit).ExecuteAsync(token); foreach (var avatar in locals.Skip(offset)) results.Add(await _mapper.Local(_localActors.FromAvatar(avatar), false, token)); var skipRemote = Math.Max(0, offset - locals.Count); 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) .Skip(skipRemote).Limit(limit - results.Count).ExecuteAsync(token)) results.Add(_mapper.Foreign(foreign)); diff --git a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs index 075731f..62b1645 100644 --- a/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs +++ b/PrivaPub/Api/Mastodon/Mappers/MastodonMapper.cs @@ -28,12 +28,15 @@ namespace PrivaPub.Api.Mastodon.Mappers readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; 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; _localActors = localActors; _proxy = proxy; + _domainBlocks = domainBlocks; } 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 warned = notifications.Any(n => n.Type == NotificationType.ModerationWarning) && viewerId != default ? await Account(viewerId, token) : default; 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 { Id = n.ID, @@ -66,8 +71,9 @@ namespace PrivaPub.Api.Mastodon.Mappers ModerationWarning = n.Type != NotificationType.ModerationWarning ? default : new AccountWarning { Id = n.ID, + Action = n.WarningAction ?? "none", Text = n.Text ?? string.Empty, - StatusIds = n.PostId == default ? new List() : new List { n.PostId }, + StatusIds = n.StatusIds ?? (n.PostId == default ? new List() : new List { n.PostId }), TargetAccount = warned, CreatedAt = MastodonJson.Time(n.CreatedAt) } @@ -104,6 +110,7 @@ namespace PrivaPub.Api.Mastodon.Mappers Noindex = !actor.Indexable, HideCollections = true,//follower and following lists are never shown to others, only their counts 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), Note = ActivityPubRenderer.Html(actor.Summary), Url = actor.HtmlUrl, @@ -144,6 +151,7 @@ namespace PrivaPub.Api.Mastodon.Mappers Discoverable = foreign.IsDiscoverable, Indexable = foreign.IsIndexable, Memorial = foreign.IsMemorial ? true : default(bool?), + Limited = foreign.SilencedAt.HasValue || DomainSilenced(foreign) ? true : default(bool?), CreatedAt = MastodonJson.Day(foreign.Published ?? foreign.CreatedAt), Emojis = Emojis(foreign.Emojis), 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 }; + 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(string id, CancellationToken token) => (await Accounts(new[] { id }, token)).GetValueOrDefault(id); @@ -171,11 +183,14 @@ namespace PrivaPub.Api.Mastodon.Mappers if (wanted.Count == 0) return accounts; - foreach (var avatar in await _dbEntities.Avatars.Match(a => wanted.Contains(a.ID) && !a.DeletionAt.HasValue).ExecuteAsync(token)) - accounts[avatar.ID] = await Local(_localActors.FromAvatar(avatar), false, token); + // a suspended account, here or elsewhere, and a banned login's personas are shown to nobody (R6): their posts and + // 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)) 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) 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) diff --git a/PrivaPub/Controllers/ClientToServer/AdminController.cs b/PrivaPub/Controllers/ClientToServer/AdminController.cs index 3269be6..fc2cba5 100644 --- a/PrivaPub/Controllers/ClientToServer/AdminController.cs +++ b/PrivaPub/Controllers/ClientToServer/AdminController.cs @@ -2,50 +2,90 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; +using MongoDB.Entities; + using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Admin; using PrivaPub.ClientModels.User; +using PrivaPub.Domain.Moderation; +using PrivaPub.Domain.Timelines; using PrivaPub.Extensions; +using PrivaPub.Federation.Actors; +using PrivaPub.Models; +using PrivaPub.Models.Group; +using PrivaPub.Models.User; using PrivaPub.Resources; using PrivaPub.Services; +using PrivaPub.StaticServices; using System.ComponentModel.DataAnnotations; +using GroupEntity = PrivaPub.Models.Group.Group; + 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, - Route("clientapi/admin")] + Route("clientapi/admin"), + Authorize(Policy = Policies.IsAdmin)] public class AdminController : ControllerBase { + const int MaxRootsPage = 100; + readonly IRootUsersService RootUsersService; readonly ILogger Logger; readonly IStringLocalizer Localizer; + readonly IModerationLog _log; + readonly DbEntities _dbEntities; + readonly ILocalActorService _localActors; public AdminController(ILogger logger, IStringLocalizer localizer, - IRootUsersService rootUsersService) + IRootUsersService rootUsersService, + IModerationLog log, + DbEntities dbEntities, + ILocalActorService localActors) { Logger = logger; Localizer = localizer; 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 UsersIds Others(UsersIds usersIds) { 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)] - public async Task RemoveUsers([Required] UsersIds usersIds) + // the logins a request names that exist and are not deleted, by id: what the log names them + static async Task> Named(UsersIds usersIds, CancellationToken token) + { + var ids = usersIds.UserIdList; + return ids.Count == 0 + ? new Dictionary() + : (await DB.Default.Find().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 RemoveUsers([Required] UsersIds usersIds, CancellationToken token) { var result = new WebResult(); 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) 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(); } catch (Exception ex) @@ -55,19 +95,31 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/ban/users")] - public async Task BanUsers([Required] UsersIds usersIds) + // A ban ends the login's sessions and holds its personas back as suspended ones (Restrictions), without writing a word + // on any of them; unbanning gives them back as they were. + [HttpPost, Route("/clientapi/admin/ban/users")] + public async Task BanUsers([Required] UsersIds usersIds, [FromServices] IRootSessions sessions, CancellationToken token) { if (!ModelState.IsValid) return BadRequest(usersIds); var result = new WebResult(); try { - result = await RootUsersService.BanUserAsync(Others(usersIds)); + var others = Others(usersIds); + var named = await Named(others, token); + var banned = (await DB.Default.Find().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet(); + result = await RootUsersService.BanUserAsync(others); if (!result.IsValid) 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(); } catch (Exception ex) @@ -77,18 +129,23 @@ namespace PrivaPub.Controllers.ClientToServer } } - [HttpPost, Authorize(Policy = Policies.IsAdmin), Route("/clientapi/admin/unban/users")] - public async Task UnbanUsers([Required] UsersIds usersIds) + [HttpPost, Route("/clientapi/admin/unban/users")] + public async Task UnbanUsers([Required] UsersIds usersIds, CancellationToken token) { if (!ModelState.IsValid) return BadRequest(usersIds); var result = new WebResult(); try { - result = await RootUsersService.UnbanUserAsync(Others(usersIds)); + var others = Others(usersIds); + var named = await Named(others, token); + var banned = (await DB.Default.Find().Match(u => others.UserIdList.Contains(u.ID) && u.IsBanned).ExecuteAsync(token)).Select(u => u.ID).ToHashSet(); + result = await RootUsersService.UnbanUserAsync(others); if (!result.IsValid) 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(); } catch (Exception ex) @@ -97,5 +154,187 @@ namespace PrivaPub.Controllers.ClientToServer 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 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().Match(u => u.DeletedAt == null); + var name = q?.Trim().TrimStart('@').Split('@')[0].ToLowerInvariant(); + if (!string.IsNullOrEmpty(name)) + { + var personaIds = (await DB.Default.Find().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() + : (await DB.Default.Find().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 Root(string id, CancellationToken token) + { + var root = await DB.Default.Find().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token); + return root == default ? NotFound() : Ok((await Views(new List { 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 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().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 { 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().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().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 { root }, token))[0]); + } + + // an administrator other than this login that can still act + static Task OtherAdministrators(string rootId, CancellationToken token) => + DB.Default.Find().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 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().MatchID(id).Match(u => u.DeletedAt == null).ExecuteFirstAsync(token); + if (root == default) + return NotFound(); + await DB.Default.Update().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 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 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> Views(List roots, CancellationToken token) + { + var ids = roots.Select(r => r.ID).ToList(); + var links = ids.Count == 0 ? new List() : 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() + : (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() + : 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" + }; } } diff --git a/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs index 6821109..1a28e73 100644 --- a/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs +++ b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs @@ -24,9 +24,12 @@ namespace PrivaPub.Controllers.ClientToServer readonly IStringLocalizer _localizer; readonly Domain.Media.IMediaProxy _proxy; readonly IModerationLog _log; + readonly IAccountModeration _moderation; - public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer localizer, Domain.Media.IMediaProxy proxy, IModerationLog log) + public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer localizer, Domain.Media.IMediaProxy proxy, IModerationLog log, + IAccountModeration moderation) { + _moderation = moderation; _domainBlocks = domainBlocks; _localizer = localizer; _proxy = proxy; @@ -57,6 +60,8 @@ namespace PrivaPub.Controllers.ClientToServer .Option(o => o.IsUpsert = true) .ExecuteAsync(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 if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia) _proxy.Purge(domain); @@ -72,6 +77,8 @@ namespace PrivaPub.Controllers.ClientToServer domain = DomainBlocks.Normalise(domain); var deleted = await DB.Default.DeleteAsync(b => b.Domain == domain); await _domainBlocks.Reload(token); + if (deleted.DeletedCount > 0) + await _moderation.DomainChanged(domain, token); if (deleted.DeletedCount > 0) await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token); return Ok(); diff --git a/PrivaPub/Controllers/ClientToServer/GroupController.cs b/PrivaPub/Controllers/ClientToServer/GroupController.cs index 7ed7b2a..f369cb8 100644 --- a/PrivaPub/Controllers/ClientToServer/GroupController.cs +++ b/PrivaPub/Controllers/ClientToServer/GroupController.cs @@ -60,6 +60,27 @@ namespace PrivaPub.Controllers.ClientToServer public async Task Remove(GroupMembershipForm form, CancellationToken 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 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 Answer(WebResult result) => result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); diff --git a/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs b/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs index 9cb8e90..8090499 100644 --- a/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs +++ b/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs @@ -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 static async Task IsContactable(string avatarId, CancellationToken token) => await DB.Default.Find().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(); diff --git a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs index 2c61c32..5ba95db 100644 --- a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs +++ b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs @@ -21,13 +21,22 @@ namespace PrivaPub.Controllers.ClientToServer { const int MaxLogPage = 100; + const int MaxAccountsPage = 100; + readonly IInstanceSettings _settings; readonly IModerationLog _log; + readonly IAccountModeration _moderation; + readonly Federation.Actors.ILocalActorService _localActors; + readonly Microsoft.Extensions.Localization.IStringLocalizer _localizer; - public ModeratorController(IInstanceSettings settings, IModerationLog log) + public ModeratorController(IInstanceSettings settings, IModerationLog log, IAccountModeration moderation, + Federation.Actors.ILocalActorService localActors, Microsoft.Extensions.Localization.IStringLocalizer localizer) { _settings = settings; _log = log; + _moderation = moderation; + _localActors = localActors; + _localizer = localizer; } [HttpGet, Route("/clientapi/moderator/reports")] @@ -49,6 +58,8 @@ namespace PrivaPub.Controllers.ClientToServer Comment = r.Comment, Forwarded = r.Forwarded, IsResolved = r.IsResolved, + Resolution = r.Resolution, + ResolutionNote = r.ResolutionNote, CreatedAt = r.CreatedAt, ResolvedAt = r.ResolvedAt, 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")] - public async Task Resolve(string id, CancellationToken token) + public async Task 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 result = await DB.Default.Update().MatchID(id) + var report = await DB.Default.Find().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().MatchID(id) .Modify(r => r.IsResolved, true) .Modify(r => r.ResolvedAt, DateTime.UtcNow) .Modify(r => r.ResolvedBy, by) - .ExecuteAsync(token); - if (result.MatchedCount == 0) - return NotFound(); - var report = await DB.Default.Find().MatchID(id).ExecuteFirstAsync(token); - await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token); + .Modify(r => r.Resolution, action) + .Modify(r => r.ResolutionNote, string.IsNullOrWhiteSpace(form.Note) ? null : form.Note.Trim()) + .ExecuteAsync(CancellationToken.None); + await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report.Category, reportId: id, + note: action == ModerationActions.None ? form.Note : string.IsNullOrWhiteSpace(form.Note) ? action : $"{action}: {form.Note.Trim()}", + token: CancellationToken.None); 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 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(); + 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().MatchID(local.Id).ExecuteFirstAsync(token) is { } persona) + items.Add(_moderation.View(persona)); + items.AddRange((await DB.Default.Find().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().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().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 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().Match(a => (a.SilencedAt != null || a.SuspendedAt != null) && a.DeletionAt == null).ExecuteAsync(token); + var remotes = await DB.Default.Find().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 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 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 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 // 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. diff --git a/PrivaPub/Controllers/ClientToServer/PersonaArchiveController.cs b/PrivaPub/Controllers/ClientToServer/PersonaArchiveController.cs index 141c517..51cdd2c 100644 --- a/PrivaPub/Controllers/ClientToServer/PersonaArchiveController.cs +++ b/PrivaPub/Controllers/ClientToServer/PersonaArchiveController.cs @@ -110,6 +110,9 @@ namespace PrivaPub.Controllers.ClientToServer { if (!await Owns(avatarId, token)) 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().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); if (state.ImportState is "queued" or "running") return Conflict(new WebResult().Invalidate("An import is running.")); diff --git a/PrivaPub/Controllers/ClientToServer/PrivateAvatarController.cs b/PrivaPub/Controllers/ClientToServer/PrivateAvatarController.cs index a8e1098..50a87cc 100644 --- a/PrivaPub/Controllers/ClientToServer/PrivateAvatarController.cs +++ b/PrivaPub/Controllers/ClientToServer/PrivateAvatarController.cs @@ -5,6 +5,7 @@ using PrivaPub.ClientModels; using PrivaPub.Extensions; using PrivaPub.Resources; using PrivaPub.Services.ClientToServer.Private; +using PrivaPub.ClientModels.User; using PrivaPub.ClientModels.User.Avatar; 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 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")] public async Task UpdateAvatar(UpdateAvatarForm model) { diff --git a/PrivaPub/Domain/Moderation/AccountModeration.cs b/PrivaPub/Domain/Moderation/AccountModeration.cs new file mode 100644 index 0000000..7a6168b --- /dev/null +++ b/PrivaPub/Domain/Moderation/AccountModeration.cs @@ -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 + { + /// Silences, unsilences, suspends or unsuspends a persona here or an account elsewhere (ModerationActions); + /// Data is the account as moderators see it. + Task Moderate(string byRootId, string accountId, string action, string note, string warning, string reportId, CancellationToken token); + + /// 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). + Task RemovePost(string byRootId, string postId, string note, string warning, string reportId, CancellationToken token); + + /// A server's block changed: its accounts' posts are limited while it is silenced, and only then. + Task DomainChanged(string domain, CancellationToken token); + + Task 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 _localizer; + readonly ILogger _logger; + + public AccountModeration(DbEntities dbEntities, ILocalActorService localActors, IModerationLog log, IOpenIddictTokenManager tokens, + IOpenIddictAuthorizationManager authorizations, IStatusService statuses, IDomainBlocks domainBlocks, IStringLocalizer localizer, + ILogger logger) + { + _dbEntities = dbEntities; + _localActors = localActors; + _log = log; + _tokens = tokens; + _authorizations = authorizations; + _statuses = statuses; + _domainBlocks = domainBlocks; + _localizer = localizer; + _logger = logger; + } + + public async Task 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 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().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().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 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().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().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 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().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().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().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().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 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)}"; + } +} diff --git a/PrivaPub/Domain/Moderation/ModerationFlags.cs b/PrivaPub/Domain/Moderation/ModerationFlags.cs new file mode 100644 index 0000000..8c47e09 --- /dev/null +++ b/PrivaPub/Domain/Moderation/ModerationFlags.cs @@ -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 Recompute(IMongoDatabase database, CancellationToken token) + { + var posts = database.GetCollection("Post"); + var flagged = 0L; + await posts.UpdateManyAsync(new BsonDocument("AuthorSuspended", true), Builders.Update.Set("AuthorSuspended", false), cancellationToken: token); + await posts.UpdateManyAsync(new BsonDocument("AuthorLimited", true), Builders.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("Avatar"); + await avatars.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })), + Builders.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.Update.Set("AccountState", suspended ? 2 : 1), + cancellationToken: token); + flagged += (await posts.UpdateManyAsync(new BsonDocument { { "GroupUserId", avatar["_id"].ToString() }, { "IsFederatedCopy", false } }, + Builders.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("ForeignAvatar"); + await foreign.UpdateManyAsync(new BsonDocument("AccountState", new BsonDocument("$in", new BsonArray { 1, 2 })), + Builders.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.Update.Set("AccountState", suspended ? 2 : 1), + cancellationToken: token); + var update = Builders.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("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.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; + } +} diff --git a/PrivaPub/Domain/Moderation/ModerationLog.cs b/PrivaPub/Domain/Moderation/ModerationLog.cs index 9d4e18c..dc1d092 100644 --- a/PrivaPub/Domain/Moderation/ModerationLog.cs +++ b/PrivaPub/Domain/Moderation/ModerationLog.cs @@ -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 // (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 { const int MaxLabel = 200; @@ -33,8 +34,7 @@ namespace PrivaPub.Domain.Moderation { try { - if (targetKind == AdminTargets.Persona && byRootId != default && byRootId != AdminAction.Cli && targetId != default - && await DB.Default.Find().Match(r => r.RootId == byRootId && r.AvatarId == targetId).ExecuteAnyAsync(token)) + if (byRootId != default && byRootId != AdminAction.Cli && await OwnPersona(byRootId, targetKind, targetId, reportId, token)) byRootId = default; 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 OwnPersona(string rootId, string targetKind, string targetId, string reportId, CancellationToken token) + { + var personas = new List(); + 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().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().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().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().Match(r => r.RootId == rootId && personas.Contains(r.AvatarId)).ExecuteAnyAsync(token); + } + static string Cut(string text, int max) => string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…"; } diff --git a/PrivaPub/Domain/Moderation/Restrictions.cs b/PrivaPub/Domain/Moderation/Restrictions.cs new file mode 100644 index 0000000..152398f --- /dev/null +++ b/PrivaPub/Domain/Moderation/Restrictions.cs @@ -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(), new HashSet(), new HashSet(), new HashSet()); + + public Set(IReadOnlySet suspendedPersonas, IReadOnlySet silencedPersonas, IReadOnlySet suspendedActors, + IReadOnlySet suspendedInboxes) + { + SuspendedPersonas = suspendedPersonas; + SilencedPersonas = silencedPersonas; + SuspendedActors = suspendedActors; + SuspendedInboxes = suspendedInboxes; + } + + // suspended themselves, or held back with their login (banned or deleted) + public IReadOnlySet SuspendedPersonas { get; } + public IReadOnlySet 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 SuspendedActors { get; } + public IReadOnlySet 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().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().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().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); + } + }); + } + } +} diff --git a/PrivaPub/Domain/Portability/ImportArchiveJob.cs b/PrivaPub/Domain/Portability/ImportArchiveJob.cs index 250d04a..a581c0c 100644 --- a/PrivaPub/Domain/Portability/ImportArchiveJob.cs +++ b/PrivaPub/Domain/Portability/ImportArchiveJob.cs @@ -328,6 +328,7 @@ namespace PrivaPub.Domain.Portability ID = id, GroupUserId = me.Id, AuthorAccountId = me.Id, + AuthorLimited = me.Silenced, ActorURI = me.Uri, Visibility = visibility, Title = Clean(Text(note["name"])), @@ -492,6 +493,7 @@ namespace PrivaPub.Domain.Portability ID = id, GroupUserId = run.Me.Id, AuthorAccountId = run.Me.Id, + AuthorLimited = run.Me.Silenced, ActorURI = run.Me.Uri, Visibility = PostVisibility.LocalGeo, IsLocalOnly = true, diff --git a/PrivaPub/Domain/Privacy/Counted.cs b/PrivaPub/Domain/Privacy/Counted.cs index 7aeba57..440aec3 100644 --- a/PrivaPub/Domain/Privacy/Counted.cs +++ b/PrivaPub/Domain/Privacy/Counted.cs @@ -46,6 +46,17 @@ namespace PrivaPub.Domain.Privacy : (await DB.Default.Find().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> HeldBackPersonas(CancellationToken token) + { + var held = await BarredPersonas(token); + held.AddRange((await DB.Default.Find().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 Users(CancellationToken token) { var barred = await BarredPersonas(token); diff --git a/PrivaPub/Domain/Privacy/VisibilityPolicy.cs b/PrivaPub/Domain/Privacy/VisibilityPolicy.cs index 31291c4..e3cbcb5 100644 --- a/PrivaPub/Domain/Privacy/VisibilityPolicy.cs +++ b/PrivaPub/Domain/Privacy/VisibilityPolicy.cs @@ -14,16 +14,27 @@ namespace PrivaPub.Domain.Privacy { public static class VisibilityPolicy { - //neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03) - public static readonly Expression> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone; + //neither deleted nor kept back after its remote author deleted their account (owner decision, 2026-10-03), nor held + //back by the staff: its author suspended, or the post itself taken down (completion program R6) + public static readonly Expression> IsShown = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended && !p.ModeratedAt.HasValue; public static readonly Expression> 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> IsListed = p => !p.AuthorLimited; static readonly Func IsShownCompiled = IsShown.Compile(); static readonly Func 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 CanSee(Post post, string viewerAvatarId, CancellationToken token) { diff --git a/PrivaPub/Domain/Social/FollowService.cs b/PrivaPub/Domain/Social/FollowService.cs index 05eba12..bd81a9b 100644 --- a/PrivaPub/Domain/Social/FollowService.cs +++ b/PrivaPub/Domain/Social/FollowService.cs @@ -336,7 +336,8 @@ namespace PrivaPub.Domain.Social return default; if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token)) 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() diff --git a/PrivaPub/Domain/Social/Notifications.cs b/PrivaPub/Domain/Social/Notifications.cs index 696eeaa..819db78 100644 --- a/PrivaPub/Domain/Social/Notifications.cs +++ b/PrivaPub/Domain/Social/Notifications.cs @@ -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 postIds, CancellationToken token) + { + if (string.IsNullOrEmpty(avatarId)) + return; + var ids = postIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List(); + 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); + } } } diff --git a/PrivaPub/Domain/Social/Trends.cs b/PrivaPub/Domain/Social/Trends.cs index 1a987b6..9a1fe8e 100644 --- a/PrivaPub/Domain/Social/Trends.cs +++ b/PrivaPub/Domain/Social/Trends.cs @@ -67,6 +67,7 @@ namespace PrivaPub.Domain.Social .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.FavouritesCount + p.ReblogsCount + p.RepliesCount > 0) + .Match(Privacy.VisibilityPolicy.IsPublic).Match(Privacy.VisibilityPolicy.IsListed) .ExecuteAsync(token); var discoverable = await Discoverable(posts.Select(p => p.AuthorAccountId ?? p.GroupUserId), token); return posts.Where(p => discoverable.Contains(p.AuthorAccountId ?? p.GroupUserId)) @@ -80,6 +81,7 @@ namespace PrivaPub.Domain.Social return _dbEntities.Posts .Match(p => p.Visibility == PostVisibility.Public && !p.DeletedAt.HasValue && !p.AuthorGone && !p.HasContentWarning && !p.IsLocalOnly && p.ReblogOfPostId == null && p.CreationDate >= since) + .Match(Privacy.VisibilityPolicy.IsPublic).Match(Privacy.VisibilityPolicy.IsListed) .Match(with) .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)) @@ -115,10 +117,12 @@ namespace PrivaPub.Domain.Social static async Task> Discoverable(IEnumerable accountIds, CancellationToken token) { var ids = accountIds.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList(); - var barred = await Privacy.Counted.BarredPersonas(token); - var local = await DB.Default.Find().Match(a => ids.Contains(a.ID) && a.Settings.IsDiscoverable && !a.DeletionAt.HasValue && !barred.Contains(a.ID)) + var barred = await Privacy.Counted.HeldBackPersonas(token); + var local = await DB.Default.Find().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); - var remote = await DB.Default.Find().Match(f => ids.Contains(f.ID) && f.IsDiscoverable && !f.DeletionAt.HasValue && !f.SuspendedAt.HasValue) + var remote = await DB.Default.Find().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); return local.Select(a => a.ID).Concat(remote.Select(f => f.ID)).ToHashSet(); } diff --git a/PrivaPub/Domain/Statuses/ScheduledStatuses.cs b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs index b2f9cd6..759735a 100644 --- a/PrivaPub/Domain/Statuses/ScheduledStatuses.cs +++ b/PrivaPub/Domain/Statuses/ScheduledStatuses.cs @@ -105,6 +105,11 @@ namespace PrivaPub.Domain.Statuses if (scheduled == default || scheduled.ScheduledAt > DateTime.UtcNow.AddSeconds(30)) return JobOutcome.Done;//dropped, or moved to a later time that has its own job 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().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 if (!await ScheduledStatuses.MayPublish(scheduled.AvatarId, token)) { diff --git a/PrivaPub/Domain/Statuses/StatusService.cs b/PrivaPub/Domain/Statuses/StatusService.cs index 2b63a32..48094b9 100644 --- a/PrivaPub/Domain/Statuses/StatusService.cs +++ b/PrivaPub/Domain/Statuses/StatusService.cs @@ -203,6 +203,7 @@ namespace PrivaPub.Domain.Statuses { GroupUserId = author.Id, AuthorAccountId = author.Id, + AuthorLimited = author.Silenced,//a silenced persona's posts stay out of the public timelines (R6) GroupId = group?.Id, Visibility = visibility, Title = Clean(draft.Title), @@ -621,6 +622,7 @@ namespace PrivaPub.Domain.Statuses ID = reblogId, GroupUserId = me.Id, AuthorAccountId = me.Id, + AuthorLimited = me.Silenced, ReblogOfPostId = original.ID, Visibility = visibility == PostVisibility.Unlisted ? PostVisibility.Unlisted : PostVisibility.Public, ObjectURI = announceId, diff --git a/PrivaPub/Domain/Timelines/Fanout.cs b/PrivaPub/Domain/Timelines/Fanout.cs index cd0c9e5..789e85d 100644 --- a/PrivaPub/Domain/Timelines/Fanout.cs +++ b/PrivaPub/Domain/Timelines/Fanout.cs @@ -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)) recipients.Add(follower.AvatarId); } - // 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) + // a public post (not a boost, not a reply) comes to those following one of its hashtags, as on Mastodon, + // 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)) { var tags = post.Tags.ToList(); @@ -126,7 +127,7 @@ namespace PrivaPub.Domain.Timelines 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; var local = !post.IsFederatedCopy; if (!post.IsLocalOnly) @@ -177,7 +178,7 @@ namespace PrivaPub.Domain.Timelines var homes = (await DB.Default.Find().Match(e => e.PostId == post.ID).ExecuteAsync(token)).Select(e => e.AvatarId).Distinct(); foreach (var avatarId in homes.Where(Streams.Listening)) 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) Streams.ToAll(new Streams.Key(Streams.Public), update); diff --git a/PrivaPub/Domain/Timelines/Streams.cs b/PrivaPub/Domain/Timelines/Streams.cs index 1199b4c..6f634b2 100644 --- a/PrivaPub/Domain/Timelines/Streams.cs +++ b/PrivaPub/Domain/Timelines/Streams.cs @@ -73,6 +73,13 @@ namespace PrivaPub.Domain.Timelines 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; // whether the persona has a connection open, so the work of finding its lists can be skipped when it has none diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 774a3f5..1464e72 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -34,6 +34,11 @@ namespace PrivaPub.Federation.Actors public bool PostingRestrictedToModerators { get; init; } public bool IsBot { 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 DateTime Published { get; init; } public string BaseAddress { get; init; } @@ -335,6 +340,8 @@ namespace PrivaPub.Federation.Actors IsBot = avatar.Settings?.IsBot == true, Indexable = avatar.Settings?.IsIndexable == true, Settings = avatar.Settings ?? new AvatarSettings(), + Suspended = avatar.SuspendedAt.HasValue || Domain.Moderation.Restrictions.IsSuspended(avatar.ID), + Silenced = avatar.SilencedAt.HasValue, BaseAddress = BaseAddress }; diff --git a/PrivaPub/Federation/Controllers/PeasantsController.cs b/PrivaPub/Federation/Controllers/PeasantsController.cs index 57bdf49..0b15768 100644 --- a/PrivaPub/Federation/Controllers/PeasantsController.cs +++ b/PrivaPub/Federation/Controllers/PeasantsController.cs @@ -110,6 +110,8 @@ namespace PrivaPub.Federation.Controllers var items = new List(); foreach (var post in latest) { + if (VisibilityPolicy.Barred(post)) + continue; if (local.Kind == LocalActorKind.Group) { 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 }) return NotFound(); var wall = local.Wall; - System.Linq.Expressions.Expression> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.IsLocalOnly + System.Linq.Expressions.Expression> onWall = p => !p.DeletedAt.HasValue && !p.AuthorGone && !p.AuthorSuspended + && !p.ModeratedAt.HasValue && !p.IsLocalOnly && (p.Visibility == PostVisibility.Public || p.Visibility == PostVisibility.Unlisted) && (p.WallURI == wall || 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(); 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); + replies.RemoveAll(VisibilityPolicy.Barred); return Activity(ActivityPubRenderer.OrderedCollection(local.PostUri(post.ID) + "/replies", replies.Count, 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) .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(); } 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); 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(); } diff --git a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs index 246e927..46573be 100644 --- a/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/AnnounceHandler.cs @@ -39,10 +39,13 @@ namespace PrivaPub.Federation.Inbox.Handlers public IEnumerable Relays { get; set; } readonly PrivaPub.Federation.Relays.IRelays _relayService; + readonly PrivaPub.Federation.Moderation.IDomainBlocks _domainBlocks; 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; _services = services; _records = records; @@ -121,6 +124,8 @@ namespace PrivaPub.Federation.Inbox.Handlers To = to, Cc = cc, IsFederatedCopy = true, + AuthorSuspended = actor.SuspendedAt.HasValue, + AuthorLimited = RemotePosts.Limited(actor, _domainBlocks), CreationDate = published, UpdateDate = published }; diff --git a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs index 1efc74c..67ad185 100644 --- a/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs +++ b/PrivaPub/Federation/Inbox/Handlers/FollowHandler.cs @@ -49,6 +49,13 @@ namespace PrivaPub.Federation.Inbox.Handlers Arrival.Drop("unknown-recipient"); 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 && await DB.Default.Find().Match(b => b.AvatarId == target.Id && b.TargetActorURI == follower.ActorURI).ExecuteAnyAsync(token)) { diff --git a/PrivaPub/Federation/Inbox/InboxProcessor.cs b/PrivaPub/Federation/Inbox/InboxProcessor.cs index 807aab9..6f66c33 100644 --- a/PrivaPub/Federation/Inbox/InboxProcessor.cs +++ b/PrivaPub/Federation/Inbox/InboxProcessor.cs @@ -65,6 +65,13 @@ namespace PrivaPub.Federation.Inbox Record(job, payload, activity, type, started, new ArrivalVerdict(), Interactions.Deferred, "actor-unavailable"); 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)) actor = await _remoteActors.GetActor(actor.ActorURI, refresh: true, token) ?? actor; if (payload.ForwardedBy != default && !Forwarded.Proven(activity, actor)) diff --git a/PrivaPub/Federation/Inbox/RemotePosts.cs b/PrivaPub/Federation/Inbox/RemotePosts.cs index 52f946e..dfb104e 100644 --- a/PrivaPub/Federation/Inbox/RemotePosts.cs +++ b/PrivaPub/Federation/Inbox/RemotePosts.cs @@ -133,6 +133,9 @@ namespace PrivaPub.Federation.Inbox InReplyToAccountId = parent?.AuthorAccountId ?? parent?.GroupUserId, IsFederatedCopy = true, 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, UpdateDate = note.Updated ?? note.Published, EditedAt = note.Updated, @@ -143,6 +146,11 @@ namespace PrivaPub.Federation.Inbox 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 public static async Task> Flairs(NoteDocument note, string communityUri, CancellationToken token) { diff --git a/PrivaPub/Federation/Outbox/DeliveryService.cs b/PrivaPub/Federation/Outbox/DeliveryService.cs index dbeccf8..ba72a52 100644 --- a/PrivaPub/Federation/Outbox/DeliveryService.cs +++ b/PrivaPub/Federation/Outbox/DeliveryService.cs @@ -43,12 +43,24 @@ namespace PrivaPub.Federation.Outbox _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 inboxes, JsonObject activity, CancellationToken token, string again = default) { + if (HeldBack(signer, activity)) + return; var body = activity.ToJsonString(); var activityId = activity["id"] is JsonValue id && id.TryGetValue(out var text) ? text : default; 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) .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 @@ -223,6 +235,17 @@ namespace PrivaPub.Federation.Outbox attempt.Reason = "signer-gone"; 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); using var request = new HttpRequestMessage(HttpMethod.Post, inbox) { Content = new ByteArrayContent(body) }; diff --git a/PrivaPub/Federation/Outbox/OutboxPublisher.cs b/PrivaPub/Federation/Outbox/OutboxPublisher.cs index 3ad1c17..ed06fea 100644 --- a/PrivaPub/Federation/Outbox/OutboxPublisher.cs +++ b/PrivaPub/Federation/Outbox/OutboxPublisher.cs @@ -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) var blockers = (await DB.Default.Find().Match(b => b.AvatarId == author.Id).ExecuteAsync(token)) .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) .Concat(post.Visibility == PostVisibility.Direct ? post.To.Concat(post.Cc) : Enumerable.Empty()) - .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) .ToList(); foreach (var uri in addressed) @@ -67,7 +69,7 @@ namespace PrivaPub.Federation.Outbox if (post.Visibility != PostVisibility.Direct && !string.IsNullOrEmpty(post.InReplyToAccountId)) { 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); } @@ -77,14 +79,15 @@ namespace PrivaPub.Federation.Outbox var quotedAuthor = quoted is { IsFederatedCopy: true } ? await _dbEntities.ForeignAvatars.Match(a => a.ActorURI == quoted.ActorURI).ExecuteFirstAsync(token) : default; - if (Preferred(quotedAuthor) is { } inbox) + if (Preferred(quotedAuthor) is { } inbox && !quotedAuthor.SuspendedAt.HasValue) inboxes.Add(inbox); } // 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 - && 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)); return inboxes.Where(i => !string.IsNullOrEmpty(i)).Distinct(StringComparer.Ordinal).ToList(); diff --git a/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs b/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs index 7abba57..e44b312 100644 --- a/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs +++ b/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs @@ -10,7 +10,8 @@ namespace PrivaPub.Infrastructure.Backup // - 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; // - 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; // - 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), @@ -103,9 +104,12 @@ namespace PrivaPub.Infrastructure.Backup await database.GetCollection(collection).DeleteManyAsync(new BsonDocument("AvatarId", avatar["_id"].ToString()), token); 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) + { await avatars.UpdateOneAsync(Id(avatar["_id"]), Copy(avatar, moderated), cancellationToken: token); + report.ProtectiveKept++; + } } var links = database.GetCollection("RootToAvatar"); @@ -131,13 +135,27 @@ namespace PrivaPub.Infrastructure.Backup 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("ForeignAvatar"); - foreach (var account in P("ForeignAvatar").Where(a => IsSet(a, "DeletionAt") || IsSet(a, "ForgottenAt"))) - await foreign.ReplaceOneAsync(Id(account["_id"]), account, cancellationToken: token); + foreach (var account in P("ForeignAvatar")) + { + if (IsSet(account, "DeletionAt") || IsSet(account, "ForgottenAt")) + { + 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 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("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 foreach (var root in await (await roots.FindAsync(FilterDefinition.Empty, cancellationToken: token)).ToListAsync(token)) diff --git a/PrivaPub/Infrastructure/Cli/AdminCommands.cs b/PrivaPub/Infrastructure/Cli/AdminCommands.cs index cd5216f..0491f98 100644 --- a/PrivaPub/Infrastructure/Cli/AdminCommands.cs +++ b/PrivaPub/Infrastructure/Cli/AdminCommands.cs @@ -19,6 +19,7 @@ namespace PrivaPub.Infrastructure.Cli { const string Usage = """ usage: PrivaPub admin promote|demote + PrivaPub admin role a login's role; promote and demote are role admin and user PrivaPub admin create-root [--admin] the password is read from standard input PrivaPub admin move-persona gives a persona to another login; its Mastodon tokens end PrivaPub admin delete-root deletes a login and every persona it still has @@ -47,7 +48,9 @@ namespace PrivaPub.Infrastructure.Cli switch (args) { 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"): return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output); 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) => services.GetRequiredService().Write(AdminAction.Cli, action, kind, id, label, note: note); - static async Task 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 admin` gives one back. + static async Task Role(IServiceProvider services, string userName, RootRole role, TextWriter output, bool created = false) { userName = userName.ToLowerInvariant(); - var user = await DB.Default.Find().Match(u => u.UserName == userName).ExecuteFirstAsync(); + var user = await DB.Default.Find().Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync(); if (user == default) { Console.Error.WriteLine($"no root user '{userName}'"); return 1; } - user.SetAdmin(promote); - await DB.Default.SaveAsync(user); - if (promote) + var was = user.Role; + user.SetRole(role); + await DB.Default.Update().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 - if (!created) - await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName); + if (!created && was != role) + 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)}"); + if (was == RootRole.Admin && role != RootRole.Admin + && !await DB.Default.Find().Match(u => u.DeletedAt == null && !u.IsBanned && u.Policies.Contains(Policies.IsAdmin)).ExecuteAnyAsync()) + Console.Error.WriteLine("no administrator is left: `PrivaPub admin role admin` makes one"); 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 // make the rest. static async Task 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().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync(); 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 Report(string login, TextWriter output) diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 5f685e2..032a45c 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -62,6 +62,14 @@ namespace PrivaPub.Infrastructure.Data await Unique(a => a.ActorURI, Builders.Filter.Gt(a => a.ActorURI, ""), token); await Plain(token, a => a.PublicKeyId); + // the staff's moderation (R6): the moderated accounts, here and elsewhere, and the posts taken down + await Plain(token, a => a.SuspendedAt); + await Plain(token, a => a.SilencedAt); + await Plain(token, a => a.SuspendedAt); + await Plain(token, a => a.SilencedAt); + await DB.Default.Index().Key(p => p.ModeratedAt, KeyType.Ascending) + .Option(o => o.PartialFilterExpression = Builders.Filter.Type(p => p.ModeratedAt, MongoDB.Bson.BsonType.DateTime)) + .CreateAsync(token); await DB.Default.Index() .Key(f => f.LocalActorId, KeyType.Ascending) diff --git a/PrivaPub/Infrastructure/Data/Migrations/_020_silenced_servers_limit_their_posts.cs b/PrivaPub/Infrastructure/Data/Migrations/_020_silenced_servers_limit_their_posts.cs new file mode 100644 index 0000000..c4fb308 --- /dev/null +++ b/PrivaPub/Infrastructure/Data/Migrations/_020_silenced_servers_limit_their_posts.cs @@ -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); + } +} diff --git a/PrivaPub/Middleware/SocialPubConfigurations.cs b/PrivaPub/Middleware/SocialPubConfigurations.cs index 961d9c5..9ff6e92 100644 --- a/PrivaPub/Middleware/SocialPubConfigurations.cs +++ b/PrivaPub/Middleware/SocialPubConfigurations.cs @@ -230,6 +230,9 @@ namespace PrivaPub.Middleware .AddTransient() .AddTransient() .AddTransient() + .AddTransient() + .AddScoped() + .AddScoped() .AddTransient() .AddTransient() .AddTransient() diff --git a/PrivaPub/Models/AdminAction.cs b/PrivaPub/Models/AdminAction.cs index 3cabd8a..d68260a 100644 --- a/PrivaPub/Models/AdminAction.cs +++ b/PrivaPub/Models/AdminAction.cs @@ -45,6 +45,21 @@ namespace PrivaPub.Models public const string RootDelete = "root.delete"; public const string RootResetTotp = "root.reset_totp"; 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 @@ -60,5 +75,7 @@ namespace PrivaPub.Models public const string Emoji = "emoji"; public const string Relay = "relay"; public const string Backup = "backup"; + public const string Group = "group"; + public const string Post = "post"; } } diff --git a/PrivaPub/Models/Post/Post.cs b/PrivaPub/Models/Post/Post.cs index d542600..5804879 100644 --- a/PrivaPub/Models/Post/Post.cs +++ b/PrivaPub/Models/Post/Post.cs @@ -59,6 +59,14 @@ namespace PrivaPub.Models.Post public bool HasContentWarning { get; set; } = false; public bool IsLocalOnly { get; set; } 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 string ObjectURI { get; set; }//the Note's id diff --git a/PrivaPub/Models/Social/Notification.cs b/PrivaPub/Models/Social/Notification.cs index 2f4b7cf..b506e41 100644 --- a/PrivaPub/Models/Social/Notification.cs +++ b/PrivaPub/Models/Social/Notification.cs @@ -13,6 +13,10 @@ namespace PrivaPub.Models.Social public string Emoji { get; set; }//a reaction's emoji public string EmojiURL { get; set; } 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 StatusIds { get; set; }//the posts a warning is about, when more than PostId public bool IsRead { get; set; } public bool Filtered { get; set; }//held back by the persona's notification policy, in a NotificationRequest public DateTime CreatedAt { get; set; } = DateTime.UtcNow; diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs index a94302b..5d4032e 100644 --- a/PrivaPub/Models/Social/Relationships.cs +++ b/PrivaPub/Models/Social/Relationships.cs @@ -77,6 +77,8 @@ namespace PrivaPub.Models.Social public bool Forwarded { get; set; } public bool IsResolved { 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? ResolvedAt { get; set; } public DateTime? RemoteResolvedAt { get; set; }//the server holding the reported content says it dealt with it (Lemmy's Resolve{Flag}) diff --git a/PrivaPub/Models/User/RootUser.cs b/PrivaPub/Models/User/RootUser.cs index 9f66368..a6d6180 100644 --- a/PrivaPub/Models/User/RootUser.cs +++ b/PrivaPub/Models/User/RootUser.cs @@ -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) /// An administrator is a moderator too (IsAdminPolicy asks for both); otherwise a plain user. - public void SetAdmin(bool admin) + public void SetAdmin(bool admin) => SetRole(admin ? RootRole.Admin : RootRole.User); + + /// 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. + public void SetRole(RootRole role) { Policies.RemoveAll(p => p is ClientModels.Policies.IsAdmin or ClientModels.Policies.IsModerator); - if (admin) - Policies.AddRange([ClientModels.Policies.IsAdmin, ClientModels.Policies.IsModerator]); + if (role is RootRole.Moderator or RootRole.Admin) + Policies.Add(ClientModels.Policies.IsModerator); + if (role == RootRole.Admin) + Policies.Add(ClientModels.Policies.IsAdmin); if (!Policies.Contains(ClientModels.Policies.IsUser)) Policies.Add(ClientModels.Policies.IsUser); 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; diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index b16778f..0270e65 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -231,6 +231,7 @@ try var dbClient = app.Services.GetService(typeof(DbEntities)) as DbEntities; await dbClient.Init(); await app.Services.GetRequiredService().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); } catch (Exception ex) diff --git a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs index 1625f6a..96dcf96 100644 --- a/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs +++ b/PrivaPub/Services/ClientToServer/Private/IPrivateAvatarUsersService.cs @@ -111,6 +111,9 @@ namespace PrivaPub.Services.ClientToServer.Private var avatar = await _dbEntities.Avatars.MatchID(form.AvatarId).ExecuteFirstAsync(); if (avatar == default || avatar.DeletionAt.HasValue) 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.Biography = form.Biography; diff --git a/PrivaPub/Services/GroupUsersService.cs b/PrivaPub/Services/GroupUsersService.cs index b9e43c6..d1ff2c8 100644 --- a/PrivaPub/Services/GroupUsersService.cs +++ b/PrivaPub/Services/GroupUsersService.cs @@ -476,8 +476,9 @@ namespace PrivaPub.Services return role is GroupRole.Owner or GroupRole.Moderator ? group : default; } + // a suspended persona does nothing in its groups (R6) async Task 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); static PostingPolicy? Policy(string value) => value?.ToLowerInvariant() switch diff --git a/PrivaPub/Services/IRootUsersService.cs b/PrivaPub/Services/IRootUsersService.cs index c323d8e..8954d0b 100644 --- a/PrivaPub/Services/IRootUsersService.cs +++ b/PrivaPub/Services/IRootUsersService.cs @@ -18,6 +18,9 @@ namespace PrivaPub.Services Task RemoveUserAsync(UsersIds usersIds); Task RemoveSelfAsync(string rootId, string password); + /// Whether a login's password is this one, after the same hashing whatever the answer. + Task PasswordMatches(string rootId, string password); + Task UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId); Task GetUserSettingsAsync(string userId, LoginForm loginForm = default); diff --git a/PrivaPub/Services/PersonaRemoval.cs b/PrivaPub/Services/PersonaRemoval.cs new file mode 100644 index 0000000..afc77d5 --- /dev/null +++ b/PrivaPub/Services/PersonaRemoval.cs @@ -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 + { + /// Deletes one persona, and every group it owns: false when there is no such persona, or it is gone. + Task Remove(string avatarId, CancellationToken token); + } + + public interface IGroupRemoval + { + /// Deletes one group: false when there is no such group, or it is gone. + Task 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 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 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().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token); + await Empty(avatar.ID, now, token); + await DB.Default.DeleteAsync(m => m.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(l => l.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(f => f.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(t => t.AvatarId == avatar.ID); + // what it did with the server's announcements, kept for it alone + await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); + // (the reader of feeds stops following what nobody reads any more at its next round) + await DB.Default.DeleteAsync(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(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().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().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()) + .Modify(p => p.Revisions, new List()) + .ExecuteAsync(token); + await DB.Default.DeleteAsync(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 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().MatchID(group.ID).Modify(g => g.DeletionAt, DateTime.UtcNow).ExecuteAsync(token); + await DB.Default.DeleteAsync(f => f.TargetActorURI == actor.Uri); + await DB.Default.DeleteAsync(f => f.LocalActorId == group.ID && f.LocalActorKind == LocalActorKind.Group); + return true; + } + } +} diff --git a/PrivaPub/Services/PostsService.cs b/PrivaPub/Services/PostsService.cs index 89c96ef..ed665b4 100644 --- a/PrivaPub/Services/PostsService.cs +++ b/PrivaPub/Services/PostsService.cs @@ -125,7 +125,7 @@ namespace PrivaPub.Services } 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; } catch (Exception ex) @@ -188,7 +188,7 @@ namespace PrivaPub.Services .Sort(p => p.CreationDate, Order.Descending) .Limit(PageSize) .ExecuteAsync(token); - result.Data = dms.Select(ToView).ToList(); + result.Data = dms.Where(p => !VisibilityPolicy.Barred(p)).Select(ToView).ToList(); return result; } catch (Exception ex) @@ -263,7 +263,8 @@ namespace PrivaPub.Services return default; if (!await _dbEntities.RootToAvatars.Match(ra => ra.RootId == rootUserId && ra.AvatarId == avatarId).ExecuteAnyAsync(token)) 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() diff --git a/PrivaPub/Services/RootRemoval.cs b/PrivaPub/Services/RootRemoval.cs index 4e44453..aa9b9ca 100644 --- a/PrivaPub/Services/RootRemoval.cs +++ b/PrivaPub/Services/RootRemoval.cs @@ -1,18 +1,7 @@ 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.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 { @@ -21,25 +10,21 @@ namespace PrivaPub.Services Task 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 - // owns, is announced deleted with a Delete of the actor to everyone who follows it, every member and everyone it - // follows; their posts are emptied; their names stay reserved, and their documents answer 410 from then on. The root's - // sessions end first, so nothing acts as it while it goes. + // Deleting a root deletes everything public it had (owner decision 2026-10-04): each of its personas goes as a persona + // is deleted (PersonaRemoval: each group it owns, a Delete of the actor to everyone who follows it and everyone it + // follows, its posts emptied, its name kept reserved and its documents answering 410). The root's sessions end first, so + // nothing acts as it while it goes, and it keeps only a deleted-{id} name. public class RootRemoval : IRootRemoval { readonly DbEntities _dbEntities; - readonly ILocalActorService _localActors; - readonly IDeliveryService _delivery; 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; - _localActors = localActors; - _delivery = delivery; _sessions = sessions; + _personas = personas; } public async Task Remove(string rootId, CancellationToken token) @@ -51,34 +36,8 @@ namespace PrivaPub.Services var now = DateTime.UtcNow; 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 avatar in avatars) - { - 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().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().MatchID(avatar.ID).Modify(a => a.DeletionAt, now).ExecuteAsync(token); - await Empty(avatar.ID, now, token); - await DB.Default.DeleteAsync(m => m.AvatarId == avatar.ID); - await DB.Default.DeleteAsync(l => l.AvatarId == avatar.ID); - await DB.Default.DeleteAsync(f => f.AvatarId == avatar.ID); - await DB.Default.DeleteAsync(t => t.AvatarId == avatar.ID); - // what it did with the server's announcements, kept for it alone - await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); - await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); - // (the reader of feeds stops following what nobody reads any more at its next round) - await DB.Default.DeleteAsync(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(s => s.AvatarId == avatar.ID); - await _media.Trash(m => m.OwnerAvatarId == avatar.ID, "root removed", token); - } + foreach (var avatarId in avatarIds) + await _personas.Remove(avatarId, token); await DB.Default.Update().MatchID(root.ID) .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) .ExecuteAsync(token); await DB.Default.DeleteAsync(r => r.RootUserId == root.ID); + await Domain.Moderation.Restrictions.Reload(CancellationToken.None); return true; } - - // Delete{Actor}: to its followers' (shared) inboxes, and to the inboxes of the other accounts named - async Task Announce(LocalActor actor, IEnumerable 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().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().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()) - .Modify(p => p.Revisions, new List()) - .ExecuteAsync(token); - await DB.Default.DeleteAsync(e => postIds.Contains(e.PostId) || postIds.Contains(e.ReblogOfPostId)); - } } } diff --git a/PrivaPub/Services/RootUsersService.cs b/PrivaPub/Services/RootUsersService.cs index f06b524..60c059a 100644 --- a/PrivaPub/Services/RootUsersService.cs +++ b/PrivaPub/Services/RootUsersService.cs @@ -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 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 BanUserAsync(UsersIds usersIds) { var result = new WebResult(); diff --git a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs index b28e33d..a64862e 100644 --- a/PrivaPub/Web/Pages/OAuth/OAuthPages.cs +++ b/PrivaPub/Web/Pages/OAuth/OAuthPages.cs @@ -112,7 +112,7 @@ namespace PrivaPub.Web.Pages.OAuth var owns = !string.IsNullOrEmpty(avatarId) && await _dbEntities.RootToAvatars.Match(r => r.RootId == rootId && r.AvatarId == avatarId).ExecuteAnyAsync(token); 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); 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 avatars = new List(); 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 = avatars; Parameters = request.GetParameters() diff --git a/PrivaPub/Web/Pages/Pages.cs b/PrivaPub/Web/Pages/Pages.cs index e70c69a..b4c2657 100644 --- a/PrivaPub/Web/Pages/Pages.cs +++ b/PrivaPub/Web/Pages/Pages.cs @@ -61,7 +61,7 @@ namespace PrivaPub.Web.Pages public async Task OnGetAsync(string user, CancellationToken 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(); if (WantsActivityJson()) return Redirect(Actor.Uri); @@ -84,7 +84,7 @@ namespace PrivaPub.Web.Pages continue; if (!authors.TryGetValue(post.GroupUserId, out var author)) 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)); } Posts = views; @@ -118,6 +118,7 @@ namespace PrivaPub.Web.Pages var posts = await _dbEntities.Posts .Match(p => p.Tags.Contains(Tag) && !p.IsFederatedCopy && p.ReblogOfPostId == null && p.Visibility == PostVisibility.Public) .Match(VisibilityPolicy.IsPublic) + .Match(VisibilityPolicy.IsListed) .Sort(p => p.ID, Order.Descending) .Limit(PageSize) .ExecuteAsync(token); @@ -127,7 +128,7 @@ namespace PrivaPub.Web.Pages { if (!authors.TryGetValue(post.GroupUserId, out var author)) 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)); } Posts = views; @@ -153,7 +154,7 @@ namespace PrivaPub.Web.Pages public async Task OnGetAsync(string user, string id, CancellationToken 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(); var post = await _dbEntities.Posts .Match(p => p.ID == id && p.GroupUserId == Actor.Id && !p.IsFederatedCopy && p.ReblogOfPostId == null) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 2aa45af..efa8f29 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 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. + - 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 @@ -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.** | | 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). | -| 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. | | 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. | diff --git a/tools/pasture/scenarios/moderation.sh b/tools/pasture/scenarios/moderation.sh new file mode 100644 index 0000000..77ea304 --- /dev/null +++ b/tools/pasture/scenarios/moderation.sh @@ -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 : 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 : 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 : 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 diff --git a/tools/pasture/scenarios/restore.sh b/tools/pasture/scenarios/restore.sh index cbffd43..2d1e068 100644 --- a/tools/pasture/scenarios/restore.sh +++ b/tools/pasture/scenarios/restore.sh @@ -1,8 +1,9 @@ # 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 -# blocks bob_restore, and carol_restore is made. The backup is restored: PrivaPub stops, restores it as it starts again, -# and nothing protective is undone: the deleted post stays gone, the post made since answers as deleted, mastouser still -# follows her, the block holds and carol's name stays taken. Every session ends, so the scenario signs in again, and at +# blocks bob_restore, carol_restore is made, and the staff suspend dave_restore, silence mrestore on Mastodon and take +# down a post of alice's. The backup is restored: PrivaPub stops, restores it as it starts again, and nothing protective +# 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 mastodon peer. M=https://mastodon.test:6443 @@ -10,6 +11,7 @@ mcurl() { curl -sk --resolve mastodon.test:6443:127.0.0.1 "$@"; } . "$here/peers/mastodon.sh" 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"; } +staff() { local method=$1 path=$2; shift 2; curl -s -X "$method" -H "$RH" -H 'Content-Type: application/json' "$P$path" "$@"; } echo "restore" 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'])") 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'])") +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 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'])") @@ -57,6 +67,13 @@ curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/accounts/$bob_id/block" carol="carol_restore_$run" 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\"}" +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" [ "$(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" [ "$(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" +[ "$(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) echo "$status" | grep -q "$carol" && ok "the report names carol among the personas made since" || ko "the report: $status" diff --git a/tools/pasture/sweep.sh b/tools/pasture/sweep.sh index f83d1b9..79418f1 100755 --- a/tools/pasture/sweep.sh +++ b/tools/pasture/sweep.sh @@ -7,8 +7,9 @@ # run.sh as they are. Each batch's output is kept in out/sweep-.log. # 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 -# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings, last: it -# changes them all and puts them back) +# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings: it changes +# 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 # C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale # D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla @@ -28,7 +29,7 @@ batch_peers() { } batch_scenarios() { 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" ;; esac }