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