From 70d1ffe9a2bc0fa6eee97ecfc442ccd9e8d912f1 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 17 Oct 2026 04:02:47 +0200 Subject: [PATCH] R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 110 +++- FEDERATION.md | 17 +- .../Admin/DomainBlockForm.cs | 5 + .../Admin/ViewAdminAction.cs | 26 + .../Admin/ViewAnnouncement.cs | 47 ++ PrivaPub.ClientModels/Admin/ViewReport.cs | 2 + .../Admin/ViewServerSettings.cs | 167 ++++++ .../Controllers/AnnouncementsController.cs | 98 ++++ .../Controllers/InstanceController.cs | 165 +++++- .../Controllers/ProvenanceController.cs | 2 +- .../Mastodon/Controllers/ReportsController.cs | 4 +- .../Mastodon/Controllers/SearchController.cs | 15 - .../Controllers/StreamingController.cs | 10 +- .../ClientToServer/BackupController.cs | 13 +- .../ClientToServer/DomainBlockController.cs | 18 +- .../ClientToServer/InstanceAdminController.cs | 481 ++++++++++++++++++ .../ClientToServer/ModeratorController.cs | 82 ++- PrivaPub/Domain/Content/ContentRenderer.cs | 5 + PrivaPub/Domain/Instance/InstanceSettings.cs | 146 ++++++ PrivaPub/Domain/Media/MediaAudit.cs | 9 +- PrivaPub/Domain/Media/MediaProxy.cs | 7 +- PrivaPub/Domain/Media/MediaService.cs | 54 ++ PrivaPub/Domain/Moderation/ModerationLog.cs | 59 +++ PrivaPub/Domain/Privacy/Counted.cs | 25 + .../Domain/Relationships/ReportService.cs | 23 +- PrivaPub/Domain/Social/Announcements.cs | 162 ++++++ PrivaPub/Domain/Timelines/Streams.cs | 15 +- .../Federation/Actors/LocalActorService.cs | 15 +- .../Controllers/WellKnownController.cs | 10 +- PrivaPub/Federation/Relays/Relays.cs | 50 +- .../Infrastructure/Backup/ProtectiveMerge.cs | 18 +- PrivaPub/Infrastructure/Cli/AdminCommands.cs | 30 +- PrivaPub/Infrastructure/Data/Indexes.cs | 11 + PrivaPub/Models/AdminAction.cs | 64 +++ PrivaPub/Models/Federation/DomainBlock.cs | 4 + PrivaPub/Models/Media/MediaAttachment.cs | 1 + PrivaPub/Models/ServerSettings.cs | 65 +++ PrivaPub/Models/Social/Announcement.cs | 36 ++ PrivaPub/Models/Social/Relationships.cs | 1 + PrivaPub/Program.cs | 9 + PrivaPub/Services/Mailer.cs | 9 +- PrivaPub/Services/RootRemoval.cs | 3 + PrivaPub/Web/Pages/OAuth/Login.cshtml | 3 +- PrivaPub/Web/Pages/Shared/_Layout.cshtml | 3 +- tools/pasture/scenarios/instance.sh | 226 ++++++++ tools/pasture/sweep.sh | 5 +- 46 files changed, 2223 insertions(+), 107 deletions(-) create mode 100644 PrivaPub.ClientModels/Admin/ViewAdminAction.cs create mode 100644 PrivaPub.ClientModels/Admin/ViewAnnouncement.cs create mode 100644 PrivaPub.ClientModels/Admin/ViewServerSettings.cs create mode 100644 PrivaPub/Api/Mastodon/Controllers/AnnouncementsController.cs create mode 100644 PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs create mode 100644 PrivaPub/Domain/Instance/InstanceSettings.cs create mode 100644 PrivaPub/Domain/Moderation/ModerationLog.cs create mode 100644 PrivaPub/Domain/Social/Announcements.cs create mode 100644 PrivaPub/Models/AdminAction.cs create mode 100644 PrivaPub/Models/ServerSettings.cs create mode 100644 PrivaPub/Models/Social/Announcement.cs create mode 100644 tools/pasture/scenarios/instance.sh diff --git a/CLAUDE.md b/CLAUDE.md index 5ebe2d3..b5316dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,8 +82,9 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Program.cs host, Mongo init (GUIDs Standard), pipeline, /build.json Middleware/SocialPubConfigurations.cs every DI registration (auth, federation, services, swagger, CORS) Controllers/ClientToServer/ /clientapi/*: RootUser (signup, setup, login, invitations, recovery), PrivateAvatar, - Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports), - Admin (logins), DomainBlock, Statistics, Backup + Group, Post (posts + DMs), Social, Timeline, Data, PersonaArchive, Moderator (reports, + the staff's log), Admin (logins), DomainBlock, Statistics, Backup, InstanceAdmin (the + server's settings, thumbnail, announcements, relays) Infrastructure/ Http/ FederationHttp + SafeHttpHandlerFactory + IpRangeGuard: the only way out Jobs/ JobQueue (leases), JobWorker, Backoff, HostCircuitBreaker @@ -116,8 +117,11 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Crawler/ Crawler, PeerHosts, RobotsTxt (the opt-in crawler behind `/stargazing`) Relays/ Relays (the instance actor's relay subscriptions) Domain/ - Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags) - Social/ FollowService (local in-process, remote Follow/Accept), Notifications + Content/ ContentRenderer (Markdown or plain text → HTML with h-card mentions and hashtags; + Document: the server's own texts) + Instance/ InstanceSettings (the server's own settings in memory), MediaLimitsFromSettings + Moderation/ ModerationLog (the staff's log, AdminAction) + Social/ FollowService (local in-process, remote Follow/Accept), Notifications, Announcements Timelines/ Fanout (TimelineEntry rows, Mastodon's home rules), TimelineService Privacy/ VisibilityPolicy (IsPublic expression, CanSee) Relationships/ RelationshipService (blocks, mutes, account domain blocks; Hidden), ReportService @@ -137,7 +141,8 @@ PrivaPub/ ASP.NET Core Web API, net10.0 Web/Pages/ Razor: /@{user}, /@{user}/{id} (public posts only, strict CSP, noindex); OAuth/: /oauth/login (root password), /oauth/authorize (choose persona, consent) Services/ RootUsersService, GroupUsersService, PostsService, AppConfigurationService, … - Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration + Models/ Mongo entities: User/, Group/, Post/, Federation/, Jobs/, AppConfiguration, ServerSetup, + ServerSettings, AdminAction StaticServices/ DbEntities (Find accessors), AuthTokenManager (JWT), PasswordHasher Data/InitDb.cs first-run seeding (languages) PrivaPub.ClientModels/ DTOs + validation resources shared with clients @@ -189,7 +194,80 @@ ASPNETCORE_ENVIRONMENT=Production ./PrivaPub admin reset-totp # a logi ``` The deploy runs them as `build-runner`, which owns the published files, reads `appsettings.Production.json` through -group www-data and reaches the private mongod; `sudo -u www-data` works too. +group www-data and reaches the private mongod; `sudo -u www-data` works too. Every one of them that changes a login or a +persona (promote, demote, create-root, move-persona, delete-root, reset-totp) and `restore` is kept in the staff's log as +the command line's (`cli`). + +## The server's own settings (completion program R5) + +- **One document** (`Models/ServerSettings.cs`, fixed id `settings`), read through the `IInstanceSettings` singleton + (`Domain/Instance/InstanceSettings.cs`): `Current` is never null and never changed in place; `Update(change, byRootId)` + changes the stored document (or the defaults), saves it and swaps it in; it is read at start (after migrations), after + each save and every five minutes (`InstanceSettingsRefresher`), so an edit made elsewhere takes. **The defaults are what + PrivaPub served before, word for word** (title "PrivaPub", `ServerSettings.DefaultDescription`, the privacy policy of + 2026-10-04 as Markdown that renders to the old paragraph, `/media/missing-header.png`), so nothing changes until an + administrator saves. Registrations stay configuration-only (`Registrations:Mode`, owner decision; the deploy checks it). +- **Where each fact comes from now:** + - the title: both instance APIs' `title`, NodeInfo `metadata.nodeName`, the instance actor's `name`, + `ProvenanceController.DescribeSelf`, the web pages' footer (`_Layout`), the `/oauth/login` heading and the emails' + From name (`Mailer`); + - the short description: v1 `short_description`/`description`, v2 `description`, NodeInfo `nodeDescription`, and the + instance actor's `summary` once it is not the default (until then the actor's own sentence); + - the description (Markdown): `/api/v1/instance/extended_description` (dated when it last changed, today until then); + - contact email and contact persona (`ContactAccountId`, a local persona the administrator chooses, publicly tied to + running the server; shown while it exists and its login may act): v1 `email`/`contact_account`, v2 `contact`; + - the thumbnail: v1 `thumbnail`, v2 `thumbnail.url`; + - the rules (`ServerRule`: a GUID id, text, hint, position; soft-deleted, so old reports keep their meaning): both + instance APIs' `rules` and `/api/v1/instance/rules`, those not deleted, in order; + - the privacy policy and the terms of service (Markdown): `/api/v1/instance/privacy_policy` and Mastodon 4.4's + `/api/v1/instance/terms_of_service[/{yyyy-MM-dd}]` (one version kept; 404 while there are none). + + The server's texts are rendered by `ContentRenderer.Document` (Markdig with raw HTML off, then `ContentSanitizer`; + nothing is looked up). +- **Administrator routes** (`InstanceAdminController`, `[Authorize(Policy = Policies.IsAdmin)]` and checked again against + the database): `GET`/`POST /clientapi/admin/settings` (`ViewServerSettings` / `ServerSettingsForm`: a rule named by its + id keeps it, a new one gets one, one left out is deleted; a description's or policy's date moves only when its text + does), `POST`/`DELETE /clientapi/admin/settings/thumbnail` (multipart `file`), `/clientapi/admin/announcements/ + list|insert|update|delete`, `/clientapi/admin/relays`, `/relays/insert`, `/relays/delete?address=`. +- **The thumbnail** goes through `IMediaService.ServerImage` (checked before it is decoded, libvips, `keep=none`, fitted + within 1200×630) into a `MediaAttachment` nobody owns, held by `ServerUse = "thumbnail"`: the janitor's never-posted + sweep leaves such rows alone, `admin media audit` keeps the one the settings name (and trashes any other), no quota + counts them, and a replaced or removed thumbnail is trashed. +- **Media limits** (`ServerSettings.Media`: quota, image and video sizes, duration): `MediaLimitsFromSettings` + (`IPostConfigureOptions`) applies them over `Media:*` within hard caps (images 40 MB, video 99 MB, which + nginx's 100m and `MediaController`'s request limit allow, four hours), and `MediaLimitsChangeSource` makes every + `IOptionsMonitor` reader (uploads, the quota, the instance API, `/clientapi/user/storage`) see a save at + once. +- **Rules in reports:** `POST /api/v1/reports` takes `rule_ids[]` (kept when the rule exists, deleted ones too, since the + dialog may have listed them; a report naming rules without a category is a `violation`, and only a violation names + rules, as on Mastodon); `Report.RuleIds`, the entity's `rule_ids`, and the moderators' `ViewReport.RuleIds` and `Rules` + (their texts). Rules never federate. +- **Announcements** (`Domain/Social/Announcements.cs`, `Api/Mastodon/Controllers/AnnouncementsController.cs`): written in + Markdown by an administrator, shown while published and not ended; `GET /api/v1/announcements` (anonymous: without + `read` and `me`), `POST …/{id}/dismiss`, `PUT`/`DELETE …/{id}/reactions/{name}` (one unicode emoji, normalised as a + post's reactions are, eight different ones at most; anything else 422). Dismissals (`AnnouncementRead`) and reactions + (`AnnouncementReaction`) are kept per persona, never federated, and go with a deleted persona (`RootRemoval`). Every + persona's `user` stream hears `announcement`, `announcement.reaction` and `announcement.delete` (`Streams.ToUsers`, + mapped per persona in `StreamingController.Payload`). +- **Public domain blocks:** `DomainBlock.Published` and `Obfuscate` (off by default: publishing a block tells the blocked + server, and everyone). `/api/v1/instance/domain_blocks` lists only published ones, as Mastodon does (`domain`, partly + starred when obfuscated, `digest` the SHA-256 of the domain, `severity`, `comment` the public comment). +- **Relays** are `Federation:Relays` ∪ `ServerSettings.Relays` (`IRelays.Named`). One `Reconcile` runs at a time; the + page's insert and delete start one at once in the background (resolving a relay is a fetch) and answer 202. A + configured relay can't be removed from the page (409). A subscription no longer named gets no `Undo` while the same + relay actor is still named under another address. +- **Activity:** `/api/v1/instance/activity` answers `[]` unless `PublishActivity` (off by default); then twelve weeks, + this one first, counted by `Counted.Weekly` (local posts as `LocalPosts` counts them, by their ids; logins made by + `RootUser.CreatedAt`; `logins` "0", since sign-ins are not kept), worked out at most hourly. Personas are never counted + by their creation (their dates are spread on purpose). +- **The staff's log** (`Models/AdminAction.cs`, `Domain/Moderation/ModerationLog.cs`): who (a root id, or `cli`), the action, + the target's kind, id and label, the report, a note; never an IP address, and never a root next to its own personas + (an act on a persona the acting root owns is kept without who acted; a root's entry names none of its personas). It + 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 + /clientapi/moderator/log?before=&limit=` (`ViewAdminActionsPage`, newest first, by id): administrators read all of it + with login names; a moderator who isn't one reads what isn't about a login, named only when it acted itself or it was + the command line. Writing never fails the act it records. ## Federation invariants @@ -363,8 +441,10 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. - `MediaJanitor` deletes them a day later (`TrashGrace`). What a login's media take (every persona's, trashed ones no longer) is `Counted.MediaOfRoot`, shown to the login at - `/clientapi/user/storage`; `Media:QuotaBytesPerRoot` (0, none, by default) refuses an upload over it with 422; the - administrator's statistics overview has the totals, the proxy cache and the trash. + `/clientapi/user/storage`; `Media:QuotaBytesPerRoot` (0, none, by default, or the administrator's override in the + server's settings) refuses an upload over it with 422; the administrator's statistics overview has the totals, the + proxy cache and the trash. The server's own pictures (`ServerUse`, its thumbnail) belong to nobody and are held while + the settings name them. Nothing is deleted because it looks unused. `PrivaPub admin media audit [--fix]` compares disk and database: with `--fix` (as www-data) it gives pictures shown from before their rows a row, and trashes media of deleted posts or @@ -572,7 +652,9 @@ group www-data and reaches the private mongod; `sudo -u www-data` works too. 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; 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; + 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 + are P's: the live settings outlive a restore; 5. every session ends (a new `SessionStamp` for each root, persona tokens deleted), circuits close, and a `RestoreRecord` (never in a backup) tells what happened (`admin restore --status`). @@ -793,6 +875,16 @@ tools/pasture/run.sh down # removes e announced by the other), that alice's public post goes to the relays (and through both to Mastodon: Activity-Relay's forward on its FEP-8b32 proof, after the scenario makes Mastodon read alice's keys anew) and nothing less public, and has Mastodon leave again, so the town sees no relayed posts. 16 checks. +- **The server's own settings (`scenarios/instance.sh`, needs relay and aoderelay; last in batch A):** promotes + pastureroot (`admin promote`, as `stats_check` does) and edits everything through `/clientapi/admin/settings`, then reads + it back from both instance APIs, NodeInfo, the instance actor (exempt from SecureMode), `/instance/rules`, + `extended_description`, `privacy_policy`, `terms_of_service` (and by date) and the OAuth page; a published (obfuscated) + and an unpublished domain block; a report with `rule_ids` reaching the moderators with the rules' texts; an announcement + listed, dismissed by one persona, reacted to and streamed on `user`; an image limit reaching the instance API and a 1 KB + quota refusing an upload, then reset; the thumbnail; a relay added through the settings (`https://relay.test/inbox`, + beside the configured actor), accepted and removed without ending the configured subscription; the activity once + published; the staff's log listing it all without an address. It saves the settings it found at the start back at the + end (the description's and the policy's dates then say today). - **Mailpit (1.31.4, `peers/mailpit.sh`, as `mailpit.test`):** the pasture's mail server. `appsettings.Pasture.json` sends PrivaPub's mail to `pasture-mailpit:1025` (plain SMTP, any login accepted) and its recovery links to `https://decepub.test`. `scenarios/accounts.sh` reads what arrived through Mailpit's API: an address set with the diff --git a/FEDERATION.md b/FEDERATION.md index 41be554..fac0dfa 100644 --- a/FEDERATION.md +++ b/FEDERATION.md @@ -139,7 +139,9 @@ The names are the project's own and are stable; resolve actors through WebFinger personas made on the same day do not share a date. `indexable` is `false` unless the persona turns it on, and `discoverable` is `true` unless it turns that off. - The instance actor is `/peasants/privapub` (type `Application`). It signs every fetch PrivaPub makes, so no avatar's key - is used to read another server's content. It also answers at the server's root (`/`) for a request that asks for + is used to read another server's content. Its `name` is the server's name and its `summary` the server's short + description, as the administrator sets them (the server's settings; until then "PrivaPub" and a sentence saying what it + does); NodeInfo's `metadata.nodeName` and `nodeDescription` come from the same settings. It also answers at the server's root (`/`) for a request that asks for ActivityPub, as Lemmy's site actor does: PieFed sends a community's announces to the inbox of the Application at a peer's root, and to `/inbox` when there is none. - The reporter is `/peasants/privapub_reports` (type `Service`, "Reports from "), one for the whole server with @@ -254,7 +256,9 @@ replies to a persona's posts passed on to its followers. A deleted post answers - **Pinned posts** are the actor's `featured` collection (`/trophies`); `featuredTags` is `/tattoos`. A pin or an unpin is told to the post's audience as `Add` or `Remove` on `featured`, as Mastodon tells it. - **Blocks are sent.** A blocked remote account receives `Block` from the blocking account (and `Reject{Follow}` if it - followed); an unblock sends `Undo{Block}`. + followed); an unblock sends `Undo{Block}`. A server's block of a whole server sends that server nothing; the + administrator may list it at `/api/v1/instance/domain_blocks` (off for each block by default, its name partly hidden + when asked, with its SHA-256 digest), as Mastodon lists its blocks. - **Reports** are sent as `Flag`, never by the reporting account and never naming it. - To the reported account's server: from the instance actor, with the account and the posts as `object` and the persona's words in `content`. @@ -364,10 +368,11 @@ days and shared by every account on the server. Images are served to clients onl ## Relays -PrivaPub reads from the relays `Federation:Relays` names (by their actor's address, or their inbox's), none by default. -Its instance actor follows `Public` at each, as Mastodon subscribes (`Federation/Relays/Relays.cs`, a minute after start -and every six hours: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets -the `Undo`). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it +PrivaPub reads from the relays `Federation:Relays` names, and those its administrator adds in the server's settings (by +their actor's address, or their inbox's), none by default. Its instance actor follows `Public` at each, as Mastodon +subscribes (`Federation/Relays/Relays.cs`, a minute after start, every six hours and at once when the administrator adds +or removes one: an unanswered or refused subscription is asked again a day later, and a relay no longer named gets the +`Undo`, unless the same relay is still named under another address). What an accepted relay passes on comes to the federated timeline, never to anyone's home: a public post it forwards as its author sent it (Activity-Relay), read again from its origin like any forwarded post, and a post it announces (aode-relay), kept as its author's and never as the relay's boost. Nothing else is taken from a relay. A persona's public post outside any group, its edit and its deletion also go to the relays that accepted us (owner decision diff --git a/PrivaPub.ClientModels/Admin/DomainBlockForm.cs b/PrivaPub.ClientModels/Admin/DomainBlockForm.cs index d59c8fe..a6fc488 100644 --- a/PrivaPub.ClientModels/Admin/DomainBlockForm.cs +++ b/PrivaPub.ClientModels/Admin/DomainBlockForm.cs @@ -12,6 +12,9 @@ namespace PrivaPub.ClientModels.Admin public bool Suspend { get; set; } = true; public bool RejectMedia { get; set; } + // listed at /api/v1/instance/domain_blocks (off by default: it tells the blocked server, and everyone, of the block) + public bool Published { get; set; } = false; + public bool Obfuscate { get; set; } = false;//listed with part of its name hidden [StringLength(1000, ErrorMessageResourceName = "MaxLengthString", ErrorMessageResourceType = typeof(ErrorsResource))] public string PublicComment { get; set; } @@ -28,6 +31,8 @@ namespace PrivaPub.ClientModels.Admin public bool RejectMedia { get; set; } public string PublicComment { get; set; } public string PrivateComment { get; set; } + public bool Published { get; set; } = false; + public bool Obfuscate { get; set; } = false; public DateTime CreatedAt { get; set; } } } diff --git a/PrivaPub.ClientModels/Admin/ViewAdminAction.cs b/PrivaPub.ClientModels/Admin/ViewAdminAction.cs new file mode 100644 index 0000000..d54238a --- /dev/null +++ b/PrivaPub.ClientModels/Admin/ViewAdminAction.cs @@ -0,0 +1,26 @@ +namespace PrivaPub.ClientModels.Admin +{ + // the staff's log, newest first (GET /clientapi/moderator/log?before=&limit=): Next is the `before` of the next page, + // null on the last + public class ViewAdminActionsPage + { + public List Items { get; set; } = []; + public string Next { get; set; } + } + + public class ViewAdminAction + { + public string Id { get; set; } + public DateTime At { get; set; } + // who acted: a login name (to administrators; to a moderator only its own), "cli" for the command line, null when not + // told (an act on a persona of the acting login keeps nobody's name) + public string By { get; set; } + public bool ByYou { get; set; } = false; + public string Action { get; set; }//e.g. domain_block.insert, settings.update, report.resolve, root.promote + public string TargetKind { get; set; }//root, persona, account, domain, report, settings, rule, announcement, emoji, relay, backup + public string TargetId { get; set; } + public string TargetLabel { get; set; } + public string ReportId { get; set; } + public string Note { get; set; } + } +} diff --git a/PrivaPub.ClientModels/Admin/ViewAnnouncement.cs b/PrivaPub.ClientModels/Admin/ViewAnnouncement.cs new file mode 100644 index 0000000..b5bc701 --- /dev/null +++ b/PrivaPub.ClientModels/Admin/ViewAnnouncement.cs @@ -0,0 +1,47 @@ +using PrivaPub.ClientModels.Resources; + +using System.ComponentModel.DataAnnotations; + +namespace PrivaPub.ClientModels.Admin +{ + // an announcement as the administrator's page shows it (/clientapi/admin/announcements/list): its Markdown and what it + // renders to, whether it is published and shown now, how many personas dismissed it and its reactions + public class ViewAnnouncement + { + public string Id { get; set; } + public string Text { get; set; } + public string Html { get; set; } + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; } + public bool AllDay { get; set; } = false; + public bool IsPublished { get; set; } = false; + public bool IsShown { get; set; } = false;//published and not ended + public DateTime? PublishedAt { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime UpdatedAt { get; set; } + public long Reads { get; set; } = 0; + public List Reactions { get; set; } = []; + } + + public class ViewAnnouncementReaction + { + public string Name { get; set; } + public long Count { get; set; } = 0; + } + + // insert (no Id) and update (its Id) + public class AnnouncementForm + { + [StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Id { get; set; } + + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(5000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Text { get; set; }//Markdown + + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; }//once past, it is no longer shown + public bool AllDay { get; set; } = false; + public bool IsPublished { get; set; } = false; + } +} diff --git a/PrivaPub.ClientModels/Admin/ViewReport.cs b/PrivaPub.ClientModels/Admin/ViewReport.cs index 488dcf2..2e38025 100644 --- a/PrivaPub.ClientModels/Admin/ViewReport.cs +++ b/PrivaPub.ClientModels/Admin/ViewReport.cs @@ -11,6 +11,8 @@ namespace PrivaPub.ClientModels.Admin public List PostIds { get; set; } = new(); public List ObjectURIs { get; set; } = new(); public string Category { get; set; } + public List RuleIds { get; set; } = [];//the server's rules it says were broken + public List Rules { get; set; } = [];//their texts, in the same order (a deleted rule's too) public string Comment { get; set; } public bool Forwarded { get; set; } public bool IsResolved { get; set; } diff --git a/PrivaPub.ClientModels/Admin/ViewServerSettings.cs b/PrivaPub.ClientModels/Admin/ViewServerSettings.cs new file mode 100644 index 0000000..984d8af --- /dev/null +++ b/PrivaPub.ClientModels/Admin/ViewServerSettings.cs @@ -0,0 +1,167 @@ +using PrivaPub.ClientModels.Resources; + +using System.ComponentModel.DataAnnotations; + +namespace PrivaPub.ClientModels.Admin +{ + // The server's own settings as the administrator's page shows them (GET /clientapi/admin/settings): what the instance + // API, NodeInfo, the instance actor, the web pages and the emails say of the server, its rules, its relays, its media + // limits next to the configured ones, and whether its weekly activity is published. Registrations are read-only: they + // stay in the configuration (Registrations:Mode). + public class ViewServerSettings + { + public string Title { get; set; } + public string ShortDescription { get; set; } + public string Description { get; set; }//Markdown + public DateTime? DescriptionUpdatedAt { get; set; } + public string ContactEmail { get; set; } + public string ContactAccountId { get; set; } + public ViewContactAccount ContactAccount { get; set; }//null when none is set, or it is gone + public string ThumbnailUrl { get; set; }//what the instance API shows (the default picture when none was uploaded) + public bool ThumbnailIsDefault { get; set; } = true; + public string PrivacyPolicy { get; set; }//Markdown + public DateTime? PrivacyPolicyUpdatedAt { get; set; } + public string TermsOfService { get; set; }//Markdown; null: none + public DateTime? TermsEffectiveAt { get; set; } + public List Rules { get; set; } = [];//deleted ones too (IsDeleted), as old reports name them + public List Relays { get; set; } = []; + public ViewMediaLimits Media { get; set; } = new(); + public string RegistrationMode { get; set; }//"Invitations" or "Open", from the configuration + public bool PublishActivity { get; set; } = false; + public DateTime? UpdatedAt { get; set; }//null: never saved, every value is the default + public string UpdatedBy { get; set; }//the administrator's login, or "cli" + } + + public class ViewContactAccount + { + public string Id { get; set; } + public string UserName { get; set; } + public string Name { get; set; } + public string Handle { get; set; }//user@host + } + + public class ViewServerRule + { + public string Id { get; set; } + public string Text { get; set; } + public string Hint { get; set; } + public int Position { get; set; } = 0; + public bool IsDeleted { get; set; } = false; + public DateTime? DeletedAt { get; set; } + } + + // the administrator's overrides (null: the configured value holds), what the configuration says, and the hard caps + public class ViewMediaLimits + { + public long? QuotaBytesPerRoot { get; set; } + public long? MaxImageBytes { get; set; } + public long? MaxVideoBytes { get; set; } + public int? MaxSeconds { get; set; } + public long ConfiguredQuotaBytesPerRoot { get; set; } = 0;//0: no quota + public long ConfiguredMaxImageBytes { get; set; } = 0; + public long ConfiguredMaxVideoBytes { get; set; } = 0; + public int ConfiguredMaxSeconds { get; set; } = 0; + public long EffectiveQuotaBytesPerRoot { get; set; } = 0; + public long EffectiveMaxImageBytes { get; set; } = 0; + public long EffectiveMaxVideoBytes { get; set; } = 0; + public int EffectiveMaxSeconds { get; set; } = 0; + public long HardMaxImageBytes { get; set; } = ServerMediaLimitsForm.HardMaxImageBytes; + public long HardMaxVideoBytes { get; set; } = ServerMediaLimitsForm.HardMaxVideoBytes; + public int HardMaxSeconds { get; set; } = ServerMediaLimitsForm.HardMaxSeconds; + } + + // POST /clientapi/admin/settings: everything the page edits at once (relays and the thumbnail have their own routes). + // A rule keeps its id when the form names it; one without an id is new; one left out is deleted (kept, marked). + public class ServerSettingsForm + { + public const int MaxRules = 50; + public const int MaxText = 20000; + + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "Title", ResourceType = typeof(FieldsNameResource))] + public string Title { get; set; } + + [StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string ShortDescription { get; set; } + + [StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "Description", ResourceType = typeof(FieldsNameResource))] + public string Description { get; set; } + + [EmailAddress(ErrorMessageResourceName = "InvalidEmail", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(254, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource)), + Display(Name = "Email", ResourceType = typeof(FieldsNameResource))] + public string ContactEmail { get; set; } + + [StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string ContactAccountId { get; set; }//a local persona's account id (its Mastodon id), or none + + [StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string PrivacyPolicy { get; set; } + + [StringLength(MaxText, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string TermsOfService { get; set; }//empty: none + + public DateTime? TermsEffectiveAt { get; set; }//a date; today when terms are given without one + + [MaxLength(MaxRules, ErrorMessageResourceName = "MaxLengthArray", ErrorMessageResourceType = typeof(ErrorsResource))] + public List Rules { get; set; } = []; + + public ServerMediaLimitsForm Media { get; set; } = new(); + + public bool PublishActivity { get; set; } = false; + } + + public class ServerRuleForm + { + [StringLength(64, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Id { get; set; }//an existing rule's id, or none for a new rule + + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(500, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Text { get; set; } + + [StringLength(1000, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Hint { get; set; } + } + + // null keeps the configured value + public class ServerMediaLimitsForm + { + public const long HardMaxImageBytes = 40L * 1024 * 1024; + public const long HardMaxVideoBytes = 99L * 1024 * 1024; + public const int HardMaxSeconds = 4 * 3600; + + [Range(0L, long.MaxValue, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public long? QuotaBytesPerRoot { get; set; }//0: no quota + + [Range(1L, HardMaxImageBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public long? MaxImageBytes { get; set; } + + [Range(1L, HardMaxVideoBytes, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public long? MaxVideoBytes { get; set; } + + [Range(1, HardMaxSeconds, ErrorMessageResourceName = "Range", ErrorMessageResourceType = typeof(ErrorsResource))] + public int? MaxSeconds { get; set; } + } + + // a relay the instance actor subscribes to: named by the configuration (Federation:Relays, removed only there) or by + // the settings (added and removed from the page) + public class ViewRelay + { + public string Address { get; set; } + public string Source { get; set; }//"configuration" or "settings" + public string State { get; set; }//"pending", "accepted", "rejected", or "none" before its first request + public string ActorURI { get; set; } + public DateTime? RequestedAt { get; set; } + public DateTime? AnsweredAt { get; set; } + } + + public class RelayForm + { + [Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)), + StringLength(2048, ErrorMessageResourceName = "StringLength", ErrorMessageResourceType = typeof(ErrorsResource))] + public string Address { get; set; }//the relay's actor (https://relay.example/actor) or its inbox + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/AnnouncementsController.cs b/PrivaPub/Api/Mastodon/Controllers/AnnouncementsController.cs new file mode 100644 index 0000000..229d77c --- /dev/null +++ b/PrivaPub/Api/Mastodon/Controllers/AnnouncementsController.cs @@ -0,0 +1,98 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +using MongoDB.Entities; + +using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Domain.Social; +using PrivaPub.Models.Social; + +namespace PrivaPub.Api.Mastodon.Controllers +{ + // Mastodon's announcements: the administrators' (Domain/Social/Announcements.cs), each persona's dismissals and + // reactions its own. Anonymous readers get the list without `read` or `me`. + public class AnnouncementsController : MastodonController + { + readonly IAnnouncements _announcements; + + public AnnouncementsController(IAnnouncements announcements) + { + _announcements = announcements; + } + + [HttpGet("/api/v1/announcements"), Scope("read", requiresUser: false), AllowAnonymous] + public async Task List(CancellationToken token) => + Json(await Map(await _announcements.Shown(token), MyId, token)); + + [HttpPost("/api/v1/announcements/{id}/dismiss"), Scope("write:accounts")] + public async Task Dismiss(string id, CancellationToken token) => + await _announcements.Dismiss(id, MyId, token) ? Json(new { }) : NotFoundError(); + + [HttpPut("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")] + public Task React(string id, string name, CancellationToken token) => Reaction(id, name, add: true, token); + + [HttpDelete("/api/v1/announcements/{id}/reactions/{name}"), Scope("write:favourites")] + public Task Unreact(string id, string name, CancellationToken token) => Reaction(id, name, add: false, token); + + async Task Reaction(string id, string name, bool add, CancellationToken token) + { + var (status, error) = await _announcements.React(id, MyId, name, add, token); + return error == default ? Json(new { }) : Error(status, error); + } + + /// Mastodon's Announcement entities, as the viewer (a persona, or nobody) sees them. + public static async Task>> Map(IReadOnlyList announcements, string viewerId, CancellationToken token) + { + var ids = announcements.Select(a => a.ID).ToList(); + if (ids.Count == 0) + return new List>(); + var reactions = await DB.Default.Find().Match(r => ids.Contains(r.AnnouncementId)).ExecuteAsync(token); + var read = viewerId == default + ? new HashSet() + : (await DB.Default.Find().Match(r => r.AvatarId == viewerId && ids.Contains(r.AnnouncementId)).ExecuteAsync(token)) + .Select(r => r.AnnouncementId).ToHashSet(); + return announcements.Select(a => + { + var entity = new Dictionary + { + ["id"] = a.ID, + ["content"] = a.Html ?? string.Empty, + ["starts_at"] = a.StartsAt == default ? default : MastodonJson.Time(a.StartsAt.Value), + ["ends_at"] = a.EndsAt == default ? default : MastodonJson.Time(a.EndsAt.Value), + ["all_day"] = a.AllDay, + ["published_at"] = MastodonJson.Time(a.PublishedAt ?? a.CreatedAt), + ["updated_at"] = MastodonJson.Time(a.UpdatedAt), + ["mentions"] = Array.Empty(), + ["statuses"] = Array.Empty(), + ["tags"] = Array.Empty(), + ["emojis"] = Array.Empty(), + ["reactions"] = reactions.Where(r => r.AnnouncementId == a.ID) + .GroupBy(r => r.Name) + .OrderBy(g => g.Min(r => r.CreatedAt)) + .Select(g => Reaction(g.Key, g.Count(), viewerId == default ? default : g.Any(r => r.AvatarId == viewerId))) + .ToList() + }; + if (viewerId != default) + entity["read"] = read.Contains(a.ID); + return entity; + }).ToList(); + } + + // a unicode emoji's reaction: no url (custom emoji come later); `me` only for a persona + static Dictionary Reaction(string name, int count, bool? me) + { + var reaction = new Dictionary { ["name"] = name, ["count"] = count }; + if (me is { } mine) + reaction["me"] = mine; + return reaction; + } + + /// The `announcement.reaction` event's payload: the emoji, how many chose it now, and on which. + public static async Task> ReactionEvent(string announcementId, string name, CancellationToken token) => new() + { + ["name"] = name, + ["count"] = await DB.Default.CountAsync(r => r.AnnouncementId == announcementId && r.Name == name, token), + ["announcement_id"] = announcementId + }; + } +} diff --git a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs index 135e796..b0424f2 100644 --- a/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/InstanceController.cs @@ -5,31 +5,71 @@ using Microsoft.Extensions.Options; using MongoDB.Entities; using PrivaPub.Api.Mastodon.Infrastructure; +using PrivaPub.Api.Mastodon.Mappers; +using PrivaPub.Domain.Content; +using PrivaPub.Domain.Instance; using PrivaPub.Domain.Privacy; using PrivaPub.Federation.Actors; using PrivaPub.Infrastructure; +using PrivaPub.Models; +using PrivaPub.Models.Federation; using PrivaPub.Models.User; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; + using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Api.Mastodon.Controllers { + // The instance API: what the server says of itself comes from its settings (IInstanceSettings, edited on the + // administrator's page; until then exactly what it said before), its numbers from Counted, its limits from where they + // are enforced. public class InstanceController : MastodonController { public const string Version = "4.2.0 (compatible; PrivaPub)"; - const string Description = "A small ActivityPub server where one private login keeps several unlinkable public personas."; + const int ActivityWeeks = 12; + static readonly TimeSpan ActivityLifetime = TimeSpan.FromHours(1); + static volatile CachedActivity _activity; + + sealed record CachedActivity(DateTime At, object Weeks); readonly ILocalActorService _localActors; readonly IOptionsMonitor _registrations; + readonly IInstanceSettings _settings; + readonly MastodonMapper _mapper; - public InstanceController(ILocalActorService localActors, IOptionsMonitor registrations) + public InstanceController(ILocalActorService localActors, IOptionsMonitor registrations, IInstanceSettings settings, + MastodonMapper mapper) { _localActors = localActors; _registrations = registrations; + _settings = settings; + _mapper = mapper; } bool Open => _registrations.CurrentValue.IsOpen; + ServerSettings Settings => _settings.Current; + + string Thumbnail => Settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}"; + + static object Rules(ServerSettings settings) => + settings.ListedRules().Select(r => new { id = r.Id, text = r.Text ?? string.Empty, hint = r.Hint ?? string.Empty }).ToList(); + + // the persona the administrator named as the server's contact, while it exists and its login may act + async Task ContactAccount(ServerSettings settings, CancellationToken token) + { + var id = settings.ContactAccountId; + if (string.IsNullOrEmpty(id)) + return default; + var avatar = await DB.Default.Find().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); + if (avatar == default || (await Counted.BarredPersonas(token)).Contains(id)) + return default; + return await _mapper.Local(_localActors.FromAvatar(avatar), false, token); + } + // the languages this server is set up for (AppConfiguration:SupportedLanguages), "en" when none are named public static IReadOnlyList LanguageCodes(IServiceProvider services) { @@ -75,27 +115,28 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/instance"), AllowAnonymous] public async Task V1(CancellationToken token) { + var settings = Settings; var users = await Counted.Users(token); var statuses = await Counted.LocalPosts(token); var domains = await KnownServers(token); return Json(new { uri = Domain, - title = "PrivaPub", - short_description = Description, - description = Description, - email = string.Empty, + title = settings.Title, + short_description = settings.ShortDescription ?? string.Empty, + description = settings.ShortDescription ?? string.Empty, + email = settings.ContactEmail ?? string.Empty, version = Version, urls = new { streaming_api = Streaming }, stats = new { user_count = users, status_count = statuses, domain_count = domains }, - thumbnail = $"{_localActors.BaseAddress}/media/missing-header.png", + thumbnail = Thumbnail, languages = LanguageCodes(HttpContext.RequestServices), registrations = Open, approval_required = false, invites_enabled = true,//a group invitation always creates an account configuration = new { accounts = new { max_featured_tags = 0 }, statuses = Statuses, media_attachments = MediaAttachments, polls = Polls }, - contact_account = default(object), - rules = Array.Empty() + contact_account = await ContactAccount(settings, token), + rules = Rules(settings) }); } @@ -109,13 +150,13 @@ namespace PrivaPub.Api.Mastodon.Controllers public async Task V2(CancellationToken token) => Json(new { domain = Domain, - title = "PrivaPub", + title = Settings.Title, version = Version, api_versions = new { mastodon = 7 }, source_url = "https://git.thepra.dev/thepra/SocialPub", - description = Description, + description = Settings.ShortDescription ?? string.Empty, usage = new { users = new { active_month = await Counted.ActiveUsers(30, token) } }, - thumbnail = new { url = $"{_localActors.BaseAddress}/media/missing-header.png" }, + thumbnail = new { url = Thumbnail }, languages = LanguageCodes(HttpContext.RequestServices), configuration = new { @@ -127,20 +168,110 @@ namespace PrivaPub.Api.Mastodon.Controllers translation = new { enabled = false } }, registrations = new { enabled = Open, approval_required = false, message = Open ? default : RegistrationOptions.ClosedMessage }, - contact = new { email = string.Empty, account = default(object) }, - rules = Array.Empty() + contact = new { email = Settings.ContactEmail ?? string.Empty, account = await ContactAccount(Settings, token) }, + rules = Rules(Settings) }); [HttpGet("/api/v1/instance/peers"), AllowAnonymous] public IActionResult Peers() => Json(Array.Empty()); + // Mastodon's weekly activity, only when the administrator publishes it (off by default): twelve weeks, this one first, + // counted as Counted counts (local posts, logins made); sign-ins are not kept, so `logins` is 0. Worked out at most + // once an hour. [HttpGet("/api/v1/instance/activity"), AllowAnonymous] - public IActionResult Activity() => Json(Array.Empty()); + public async Task Activity(CancellationToken token) + { + if (!Settings.PublishActivity) + return Json(Array.Empty()); + if (_activity is { } cached && DateTime.UtcNow - cached.At < ActivityLifetime) + return Json(cached.Weeks); + var weeks = (await Counted.Weekly(ActivityWeeks, token)).Select(w => new + { + week = new DateTimeOffset(w.Week, TimeSpan.Zero).ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + statuses = w.Posts.ToString(CultureInfo.InvariantCulture), + logins = "0", + registrations = w.Registrations.ToString(CultureInfo.InvariantCulture) + }).ToList(); + _activity = new CachedActivity(DateTime.UtcNow, weeks); + return Json(weeks); + } [HttpGet("/api/v1/instance/rules"), AllowAnonymous] - public IActionResult Rules() => Json(Array.Empty()); + public IActionResult InstanceRules() => Json(Rules(Settings)); [HttpGet("/api/v1/instance/extended_description"), AllowAnonymous] - public IActionResult ExtendedDescription() => Json(new { updated_at = MastodonJson.Day(DateTime.UtcNow), content = $"

{Description}

" }); + public IActionResult ExtendedDescription() + { + var settings = Settings; + return Json(new + { + updated_at = settings.DescriptionUpdatedAt is { } updated ? MastodonJson.Time(updated) : MastodonJson.Day(DateTime.UtcNow), + content = ContentRenderer.Document(settings.Description) + }); + } + + [HttpGet("/api/v1/instance/privacy_policy"), AllowAnonymous] + public IActionResult PrivacyPolicy() + { + var settings = Settings; + return Json(new + { + updated_at = MastodonJson.Time(settings.PrivacyPolicyUpdatedAt ?? ServerSettings.DefaultPrivacyPolicyUpdatedAt), + content = ContentRenderer.Document(settings.PrivacyPolicy) + }); + } + + // Mastodon 4.4's terms of service: the one version the server keeps, 404 while it has none + [HttpGet("/api/v1/instance/terms_of_service"), AllowAnonymous] + public IActionResult TermsOfService() => Terms(default); + + [HttpGet("/api/v1/instance/terms_of_service/{date}"), AllowAnonymous] + public IActionResult TermsOfServiceOn(string date) => + DateTime.TryParseExact(date, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var on) + ? Terms(on.Date) + : NotFoundError(); + + IActionResult Terms(DateTime? on) + { + var settings = Settings; + if (string.IsNullOrWhiteSpace(settings.TermsOfService)) + return NotFoundError(); + var effective = (settings.TermsEffectiveAt ?? settings.UpdatedAt ?? DateTime.UtcNow).Date; + if (on != default && on.Value != effective) + return NotFoundError(); + return Json(new + { + effective_date = effective.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + effective = effective <= DateTime.UtcNow.Date, + content = ContentRenderer.Document(settings.TermsOfService), + succeeded_by = default(string) + }); + } + + // the server's blocks the administrator chose to publish (off for each by default: publishing one tells the blocked + // server, and everyone else, that it is blocked), its name partly hidden when asked, as Mastodon hides it + [HttpGet("/api/v1/instance/domain_blocks"), AllowAnonymous] + public async Task DomainBlocks(CancellationToken token) => + Json((await DB.Default.Find().Match(b => b.Published).Sort(b => b.Domain, Order.Ascending).ExecuteAsync(token)) + .Select(b => new + { + domain = b.Obfuscate ? Obfuscated(b.Domain) : b.Domain, + digest = Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(b.Domain ?? string.Empty))), + severity = b.Severity switch { DomainBlockSeverity.Suspend => "suspend", DomainBlockSeverity.Silence => "silence", _ => "noop" }, + comment = b.PublicComment + })); + + // Mastodon's DomainBlock#public_domain: a quarter of the name kept at each end, every other character but the dots a * + public static string Obfuscated(string domain) + { + if (string.IsNullOrEmpty(domain)) + return domain; + var visible = domain.Length / 4; + var hidden = domain.ToCharArray(); + for (var i = 0; i < hidden.Length; i++) + if (i > visible && i < domain.Length - visible && hidden[i] != '.') + hidden[i] = '*'; + return new string(hidden); + } } } diff --git a/PrivaPub/Api/Mastodon/Controllers/ProvenanceController.cs b/PrivaPub/Api/Mastodon/Controllers/ProvenanceController.cs index cda5dd5..e59ddeb 100644 --- a/PrivaPub/Api/Mastodon/Controllers/ProvenanceController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/ProvenanceController.cs @@ -127,7 +127,7 @@ namespace PrivaPub.Api.Mastodon.Controllers Host = host, Software = "privapub", Version = BuildInfo.Ref, - NodeName = "PrivaPub", + NodeName = HttpContext.RequestServices.GetRequiredService().Current.Title, Protocols = new() { "activitypub" }, OpenRegistrations = _registrations.CurrentValue.IsOpen, DescribedAt = MastodonJson.Time(DateTime.UtcNow), diff --git a/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs b/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs index e6abd4c..dc59c51 100644 --- a/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/ReportsController.cs @@ -21,7 +21,7 @@ namespace PrivaPub.Api.Mastodon.Controllers public async Task Create(CancellationToken token) { var report = await _reports.File(Me, Params.Get("account_id"), Params.List("status_ids"), Params.Get("comment"), Params.Get("category"), - Params.Bool("forward") == true, token); + Params.List("rule_ids"), Params.Bool("forward") == true, token); if (report == default) return NotFoundError(); return Json(new @@ -34,7 +34,7 @@ namespace PrivaPub.Api.Mastodon.Controllers forwarded = report.Forwarded, created_at = MastodonJson.Time(report.CreatedAt), status_ids = report.PostIds, - rule_ids = Array.Empty(), + rule_ids = report.RuleIds, target_account = await _mapper.Account(report.TargetAccountId, token) }); } diff --git a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs index 2ab86a1..2e6c0a4 100644 --- a/PrivaPub/Api/Mastodon/Controllers/SearchController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/SearchController.cs @@ -143,9 +143,6 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult CustomEmojis() => Json(Array.Empty()); - [HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult Announcements() => Json(Array.Empty()); - [HttpGet("/api/v1/suggestions"), Scope("read")] public IActionResult SuggestionsV1() => Json(Array.Empty()); @@ -185,17 +182,5 @@ namespace PrivaPub.Api.Mastodon.Controllers [HttpGet("/api/v1/instance/translation_languages"), Microsoft.AspNetCore.Authorization.AllowAnonymous] public IActionResult TranslationLanguages() => Json(new { }); - - [HttpGet("/api/v1/instance/domain_blocks"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult InstanceDomainBlocks() => Json(Array.Empty()); - - [HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous] - public IActionResult PrivacyPolicy() => Json(new - { - updated_at = "2026-10-04T00:00:00.000Z", - content = "

One private login owns several public personas, and nobody but this server's admin can tell they belong " - + "together. What a persona posts is shared as its visibility says; a located post never leaves this server. " - + "Uploads lose their metadata. Statistics name servers, never people: see /stargazing.

" - }); } } diff --git a/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs b/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs index ded56d5..a43a2b3 100644 --- a/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs +++ b/PrivaPub/Api/Mastodon/Controllers/StreamingController.cs @@ -59,8 +59,16 @@ namespace PrivaPub.Api.Mastodon.Controllers // is not the persona's to see async Task Payload(Streams.Event e, CancellationToken token) { - if (e.Name == "delete") + if (e.Name is "delete" or "announcement.delete") return e.Id; + if (e.Name == "announcement") + { + var shown = await HttpContext.RequestServices.GetRequiredService().FindShown(e.Id, token); + return shown == default ? default + : JsonSerializer.Serialize((await AnnouncementsController.Map(new[] { shown }, MyId, token))[0], MastodonJson.Options); + } + if (e.Name == "announcement.reaction") + return JsonSerializer.Serialize(await AnnouncementsController.ReactionEvent(e.Id, e.Data, token), MastodonJson.Options); if (e.Name == "notification") { var notification = await _dbEntities.Notifications.Match(n => n.ID == e.Id && n.AvatarId == MyId).ExecuteFirstAsync(token); diff --git a/PrivaPub/Controllers/ClientToServer/BackupController.cs b/PrivaPub/Controllers/ClientToServer/BackupController.cs index 7ebe633..721389a 100644 --- a/PrivaPub/Controllers/ClientToServer/BackupController.cs +++ b/PrivaPub/Controllers/ClientToServer/BackupController.cs @@ -8,6 +8,7 @@ using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Admin; +using PrivaPub.Domain.Moderation; using PrivaPub.Extensions; using PrivaPub.Infrastructure.Backup; using PrivaPub.Models.User; @@ -24,7 +25,7 @@ namespace PrivaPub.Controllers.ClientToServer Route("clientapi/admin/backups"), Authorize(Policy = Policies.IsAdmin)] public class BackupController(Backups backups, TransferStore transfers, IPasswordHasher hasher, IStringLocalizer localizer, - ILogger logger) : ControllerBase + ILogger logger, IModerationLog log) : ControllerBase { [HttpGet, Route("")] public async Task List(CancellationToken token) @@ -64,6 +65,7 @@ namespace PrivaPub.Controllers.ClientToServer return Forbid(); if (await MaintenanceLock.Current(token) is { } held) return Conflict(new WebResult().Invalidate(localizer["A {0} is running.", held.What])); + await log.Write(admin.ID, Models.AdminActions.BackupCreate, Models.AdminTargets.Backup, default, "manual", token: token); _ = Task.Run(async () => { try @@ -85,11 +87,15 @@ namespace PrivaPub.Controllers.ClientToServer [HttpDelete, Route("{id}")] public async Task Delete(string id, CancellationToken token) { - if (await Administrator(token) == default) + var admin = await Administrator(token); + if (admin == default) return Forbid(); if (RestoreMarker.Read(backups.Root) is { } waiting && (waiting.Backup == id || waiting.PreRestore == id)) return Conflict(new WebResult().Invalidate(localizer["A restore waits for this backup."])); - return backups.Delete(id) ? Ok() : NotFound(); + if (!backups.Delete(id)) + return NotFound(); + await log.Write(admin.ID, Models.AdminActions.BackupDelete, Models.AdminTargets.Backup, id, id, token: token); + return Ok(); } // a link to download it with, for the password @@ -211,6 +217,7 @@ namespace PrivaPub.Controllers.ClientToServer if (refused != default) return UnprocessableEntity(new WebResult().Invalidate(refused)); logger.LogWarning("{Admin} asked to restore {Backup}", admin.UserName, id); + await log.Write(admin.ID, Models.AdminActions.BackupRestore, Models.AdminTargets.Backup, id, id, token: token); return Accepted(); } diff --git a/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs index ac4b5ac..6821109 100644 --- a/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs +++ b/PrivaPub/Controllers/ClientToServer/DomainBlockController.cs @@ -6,7 +6,10 @@ using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Admin; +using PrivaPub.Domain.Moderation; +using PrivaPub.Extensions; using PrivaPub.Federation.Moderation; +using PrivaPub.Models; using PrivaPub.Models.Federation; using PrivaPub.Resources; @@ -20,12 +23,14 @@ namespace PrivaPub.Controllers.ClientToServer readonly IDomainBlocks _domainBlocks; readonly IStringLocalizer _localizer; readonly Domain.Media.IMediaProxy _proxy; + readonly IModerationLog _log; - public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer localizer, Domain.Media.IMediaProxy proxy) + public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer localizer, Domain.Media.IMediaProxy proxy, IModerationLog log) { _domainBlocks = domainBlocks; _localizer = localizer; _proxy = proxy; + _log = log; } [HttpGet, Route("/clientapi/admin/domainblocks/list")] @@ -46,6 +51,8 @@ namespace PrivaPub.Controllers.ClientToServer .Modify(b => b.RejectMedia, form.RejectMedia) .Modify(b => b.PublicComment, form.PublicComment) .Modify(b => b.PrivateComment, form.PrivateComment) + .Modify(b => b.Published, form.Published) + .Modify(b => b.Obfuscate, form.Obfuscate) .Modify(b => b.SetOnInsert(x => x.CreatedAt, DateTime.UtcNow)) .Option(o => o.IsUpsert = true) .ExecuteAsync(token); @@ -53,6 +60,9 @@ namespace PrivaPub.Controllers.ClientToServer // what the media proxy holds of it goes with the block if (block.Severity == DomainBlockSeverity.Suspend || block.RejectMedia) _proxy.Purge(domain); + await _log.Write(User.GetUserId(), AdminActions.DomainBlockInsert, AdminTargets.Domain, domain, domain, + note: string.Join(", ", new[] { block.Severity.ToString().ToLowerInvariant(), block.RejectMedia ? "media rejected" : default, + block.Published ? (block.Obfuscate ? "published, obfuscated" : "published") : default }.Where(n => n != default)), token: token); return Ok(ToView(block)); } @@ -60,8 +70,10 @@ namespace PrivaPub.Controllers.ClientToServer public async Task Delete([FromQuery] string domain, CancellationToken token) { domain = DomainBlocks.Normalise(domain); - await DB.Default.DeleteAsync(b => b.Domain == domain); + var deleted = await DB.Default.DeleteAsync(b => b.Domain == domain); await _domainBlocks.Reload(token); + if (deleted.DeletedCount > 0) + await _log.Write(User.GetUserId(), AdminActions.DomainBlockDelete, AdminTargets.Domain, domain, domain, token: token); return Ok(); } @@ -73,6 +85,8 @@ namespace PrivaPub.Controllers.ClientToServer RejectMedia = block.RejectMedia, PublicComment = block.PublicComment, PrivateComment = block.PrivateComment, + Published = block.Published, + Obfuscate = block.Obfuscate, CreatedAt = block.CreatedAt }; } diff --git a/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs b/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs new file mode 100644 index 0000000..9cb8e90 --- /dev/null +++ b/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs @@ -0,0 +1,481 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; + +using MongoDB.Entities; + +using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Admin; +using PrivaPub.Domain.Instance; +using PrivaPub.Domain.Media; +using PrivaPub.Domain.Moderation; +using PrivaPub.Domain.Privacy; +using PrivaPub.Domain.Social; +using PrivaPub.Extensions; +using PrivaPub.Federation.Actors; +using PrivaPub.Federation.Relays; +using PrivaPub.Infrastructure; +using PrivaPub.Models; +using PrivaPub.Models.Federation; +using PrivaPub.Models.Social; +using PrivaPub.Models.User; +using PrivaPub.Resources; + +namespace PrivaPub.Controllers.ClientToServer +{ + // The server's own settings from the administrator's page (completion program R5): what it says of itself, its rules, + // its thumbnail, its media limits, whether its activity is published, its announcements and the relays it subscribes to. + // Only an administrator, checked again against the database. Every change is kept in the staff's log. + [ApiController, + Route("clientapi/admin"), + Authorize(Policy = Policies.IsAdmin)] + public class InstanceAdminController : ControllerBase + { + const int MaxSettingsRelays = 20; + const string Thumbnail = "thumbnail"; + + readonly IInstanceSettings _settings; + readonly IModerationLog _log; + readonly IStringLocalizer _localizer; + readonly ILocalActorService _localActors; + readonly ILogger _logger; + + public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer localizer, ILocalActorService localActors, + ILogger logger) + { + _settings = settings; + _log = log; + _localizer = localizer; + _localActors = localActors; + _logger = logger; + } + + // the token says administrator; the database has the last word + async Task Administrator(CancellationToken token) + { + var root = await DB.Default.Find().MatchID(User.GetUserId()).ExecuteFirstAsync(token); + return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default; + } + + IActionResult Refused(int status, string message) => StatusCode(status, new WebResult().Invalidate(_localizer[message], status)); + + #region settings + + [HttpGet, Route("/clientapi/admin/settings")] + public async Task Settings(CancellationToken token) + { + if (await Administrator(token) == default) + return Forbid(); + return Ok(await View(_settings.Current, token)); + } + + [HttpPost, Route("/clientapi/admin/settings")] + public async Task SaveSettings(ServerSettingsForm form, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + var contact = Clean(form.ContactAccountId); + if (contact != default && !await IsContactable(contact, token)) + return Refused(StatusCodes.Status422UnprocessableEntity, "The contact account must be a persona of this server."); + var rules = form.Rules ?? new(); + if (rules.Count > ServerSettingsForm.MaxRules || rules.Any(r => string.IsNullOrWhiteSpace(r?.Text))) + return Refused(StatusCodes.Status422UnprocessableEntity, "Every rule needs its text, and there can be 50 at most."); + + var changed = new List(); + var ruleActs = new List<(string Action, ServerRule Rule)>(); + var now = DateTime.UtcNow; + var saved = await _settings.Update(settings => + { + void Set(string name, string value, Func get, Action set) + { + if (get() == value) + return; + set(value); + changed.Add(name); + } + Set("title", form.Title.Trim(), () => settings.Title, v => settings.Title = v); + Set("short description", Clean(form.ShortDescription), () => settings.ShortDescription, v => settings.ShortDescription = v); + Set("description", Clean(form.Description), () => settings.Description, v => + { + settings.Description = v; + settings.DescriptionUpdatedAt = now; + }); + Set("contact email", Clean(form.ContactEmail), () => settings.ContactEmail, v => settings.ContactEmail = v); + Set("contact account", contact, () => settings.ContactAccountId, v => settings.ContactAccountId = v); + Set("privacy policy", Clean(form.PrivacyPolicy), () => settings.PrivacyPolicy, v => + { + settings.PrivacyPolicy = v; + settings.PrivacyPolicyUpdatedAt = now; + }); + var terms = Clean(form.TermsOfService); + Set("terms of service", terms, () => settings.TermsOfService, v => settings.TermsOfService = v); + var effective = terms == default ? (DateTime?)null + : form.TermsEffectiveAt is { } on ? DateTime.SpecifyKind(on.Date, DateTimeKind.Utc) + : changed.Contains("terms of service") || settings.TermsEffectiveAt == default ? now.Date + : settings.TermsEffectiveAt; + if (effective != settings.TermsEffectiveAt) + { + settings.TermsEffectiveAt = effective; + if (!changed.Contains("terms of service")) + changed.Add("terms' date"); + } + + // rules: the form's order; a named rule keeps its id, a new one gets one, one left out is deleted but kept + var existing = settings.Rules.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.First()); + var listed = new HashSet(); + var position = 0; + foreach (var asked in rules) + { + var text = asked.Text.Trim(); + var hint = Clean(asked.Hint); + if (asked.Id != default && existing.TryGetValue(asked.Id, out var rule) && listed.Add(rule.Id)) + { + if (rule.Text != text || rule.Hint != hint || rule.DeletedAt != default) + ruleActs.Add((AdminActions.RuleUpdate, rule)); + else if (rule.Position != position) + changed.Add("rules' order"); + rule.Text = text; + rule.Hint = hint; + rule.DeletedAt = default; + rule.Position = position++; + continue; + } + var added = new ServerRule { Text = text, Hint = hint, Position = position++ }; + settings.Rules.Add(added); + listed.Add(added.Id); + ruleActs.Add((AdminActions.RuleInsert, added)); + } + foreach (var gone in settings.Rules.Where(r => r.DeletedAt == default && !listed.Contains(r.Id))) + { + gone.DeletedAt = now; + ruleActs.Add((AdminActions.RuleDelete, gone)); + } + + var media = form.Media ?? new(); + var limits = settings.Media ?? new(); + if (limits.QuotaBytesPerRoot != media.QuotaBytesPerRoot || limits.MaxImageBytes != media.MaxImageBytes + || limits.MaxVideoBytes != media.MaxVideoBytes || limits.MaxSeconds != media.MaxSeconds) + { + settings.Media = new ServerMediaLimits + { + QuotaBytesPerRoot = media.QuotaBytesPerRoot is { } quota ? Math.Max(0, quota) : (long?)null, + MaxImageBytes = media.MaxImageBytes is { } image ? Math.Clamp(image, 1, MediaLimitsFromSettings.MaxImageBytes) : (long?)null, + MaxVideoBytes = media.MaxVideoBytes is { } video ? Math.Clamp(video, 1, MediaLimitsFromSettings.MaxVideoBytes) : (long?)null, + MaxSeconds = media.MaxSeconds is { } seconds ? Math.Clamp(seconds, 1, MediaLimitsFromSettings.MaxSeconds) : (int?)null + }; + changed.Add("media limits"); + } + if (settings.PublishActivity != form.PublishActivity) + { + settings.PublishActivity = form.PublishActivity; + changed.Add(form.PublishActivity ? "activity published" : "activity unpublished"); + } + }, admin.ID, token); + + if (changed.Count > 0) + await _log.Write(admin.ID, AdminActions.SettingsUpdate, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: string.Join(", ", changed), token: token); + foreach (var (action, rule) in ruleActs) + await _log.Write(admin.ID, action, AdminTargets.Rule, rule.Id, rule.Text, token: token); + return Ok(await View(saved, token)); + } + + // the server's thumbnail (the instance API's), from a picture checked and re-encoded like a profile picture + [HttpPost, Route("/clientapi/admin/settings/thumbnail"), RequestSizeLimit(MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024), + RequestFormLimits(MultipartBodyLengthLimit = MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024)] + public async Task SaveThumbnail([FromServices] IMediaService media, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + var file = Request.HasFormContentType ? (await Request.ReadFormAsync(token)).Files["file"] : default; + var outcome = await media.ServerImage(Thumbnail, file, token); + if (!outcome.Ok) + return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status)); + var previous = default(string); + var saved = await _settings.Update(settings => + { + previous = settings.ThumbnailMediaId; + settings.ThumbnailMediaId = outcome.Attachment.ID; + settings.ThumbnailUrl = media.Url(outcome.Attachment.FilePath); + }, admin.ID, token); + if (previous != default) + await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail replaced", token); + await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "replaced", token: token); + return Ok(await View(saved, token)); + } + + [HttpDelete, Route("/clientapi/admin/settings/thumbnail")] + public async Task DeleteThumbnail([FromServices] IMediaService media, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + var previous = default(string); + var saved = await _settings.Update(settings => + { + previous = settings.ThumbnailMediaId; + settings.ThumbnailMediaId = default; + settings.ThumbnailUrl = default; + }, admin.ID, token); + if (previous != default) + { + await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail removed", token); + await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "removed", token: token); + } + return Ok(await View(saved, token)); + } + + // a local persona whose login may act: the contact is shown to everyone as the one who runs the server + static async Task IsContactable(string avatarId, CancellationToken token) => + await DB.Default.Find().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token) + && !(await Counted.BarredPersonas(token)).Contains(avatarId); + + static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim(); + + async Task View(ServerSettings settings, CancellationToken token) + { + var services = HttpContext.RequestServices; + var configured = services.GetRequiredService().GetSection("Media").Get() ?? new MediaOptions(); + var effective = services.GetRequiredService>().CurrentValue; + var contact = settings.ContactAccountId == default ? default + : await DB.Default.Find().Match(a => a.ID == settings.ContactAccountId && !a.DeletionAt.HasValue).ExecuteFirstAsync(token); + var updatedBy = settings.UpdatedBy == default || settings.UpdatedBy == AdminAction.Cli ? settings.UpdatedBy + : (await DB.Default.Find().MatchID(settings.UpdatedBy).ExecuteFirstAsync(token))?.UserName; + var limits = settings.Media ?? new(); + return new ViewServerSettings + { + Title = settings.Title, + ShortDescription = settings.ShortDescription, + Description = settings.Description, + DescriptionUpdatedAt = settings.DescriptionUpdatedAt, + ContactEmail = settings.ContactEmail, + ContactAccountId = settings.ContactAccountId, + ContactAccount = contact == default ? default : new ViewContactAccount + { + Id = contact.ID, + UserName = contact.UserName, + Name = contact.Name, + Handle = _localActors.FromAvatar(contact).Handle + }, + ThumbnailUrl = settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}", + ThumbnailIsDefault = settings.ThumbnailUrl == default, + PrivacyPolicy = settings.PrivacyPolicy, + PrivacyPolicyUpdatedAt = settings.PrivacyPolicyUpdatedAt, + TermsOfService = settings.TermsOfService, + TermsEffectiveAt = settings.TermsEffectiveAt, + Rules = settings.Rules.OrderBy(r => r.DeletedAt != default).ThenBy(r => r.Position).Select(r => new ViewServerRule + { + Id = r.Id, + Text = r.Text, + Hint = r.Hint, + Position = r.Position, + IsDeleted = r.DeletedAt != default, + DeletedAt = r.DeletedAt + }).ToList(), + Relays = await RelayViews(settings, token), + Media = new ViewMediaLimits + { + QuotaBytesPerRoot = limits.QuotaBytesPerRoot, + MaxImageBytes = limits.MaxImageBytes, + MaxVideoBytes = limits.MaxVideoBytes, + MaxSeconds = limits.MaxSeconds, + ConfiguredQuotaBytesPerRoot = configured.QuotaBytesPerRoot, + ConfiguredMaxImageBytes = configured.MaxImageBytes, + ConfiguredMaxVideoBytes = configured.MaxVideoBytes, + ConfiguredMaxSeconds = configured.MaxSeconds, + EffectiveQuotaBytesPerRoot = effective.QuotaBytesPerRoot, + EffectiveMaxImageBytes = effective.MaxImageBytes, + EffectiveMaxVideoBytes = effective.MaxVideoBytes, + EffectiveMaxSeconds = effective.MaxSeconds + }, + RegistrationMode = services.GetRequiredService>().CurrentValue.Mode.ToString(), + PublishActivity = settings.PublishActivity, + UpdatedAt = settings.UpdatedAt, + UpdatedBy = updatedBy + }; + } + + #endregion + + #region announcements + + [HttpGet, Route("/clientapi/admin/announcements/list")] + public async Task Announcements([FromServices] IAnnouncements announcements, CancellationToken token) + { + if (await Administrator(token) == default) + return Forbid(); + var views = new List(); + foreach (var announcement in await announcements.All(token)) + views.Add(await View(announcement, token)); + return Ok(views); + } + + [HttpPost, Route("/clientapi/admin/announcements/insert")] + public async Task InsertAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + if (Invalid(form) is { } refused) + return refused; + var made = await announcements.Insert(Draft(form), token); + await _log.Write(admin.ID, AdminActions.AnnouncementInsert, AdminTargets.Announcement, made.ID, made.Text, + note: made.IsPublished ? "published" : "draft", token: token); + return Ok(await View(made, token)); + } + + [HttpPost, Route("/clientapi/admin/announcements/update")] + public async Task UpdateAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + if (Invalid(form) is { } refused) + return refused; + var updated = await announcements.Update(form.Id, Draft(form), token); + if (updated == default) + return NotFound(); + await _log.Write(admin.ID, AdminActions.AnnouncementUpdate, AdminTargets.Announcement, updated.ID, updated.Text, + note: updated.IsPublished ? "published" : "draft", token: token); + return Ok(await View(updated, token)); + } + + [HttpPost, Route("/clientapi/admin/announcements/delete")] + public async Task DeleteAnnouncement([FromServices] IAnnouncements announcements, [FromQuery] string id, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + var deleted = await announcements.Delete(id, token); + if (deleted == default) + return NotFound(); + await _log.Write(admin.ID, AdminActions.AnnouncementDelete, AdminTargets.Announcement, deleted.ID, deleted.Text, token: token); + return Ok(); + } + + IActionResult Invalid(AnnouncementForm form) => + string.IsNullOrWhiteSpace(form.Text) + ? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement needs its text.") + : form.StartsAt != default && form.EndsAt != default && form.EndsAt < form.StartsAt + ? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement can't end before it starts.") + : default; + + static AnnouncementDraft Draft(AnnouncementForm form) => new(form.Text, form.StartsAt, form.EndsAt, form.AllDay, form.IsPublished); + + static async Task View(Announcement announcement, CancellationToken token) + { + var reactions = await DB.Default.Find().Match(r => r.AnnouncementId == announcement.ID).ExecuteAsync(token); + return new ViewAnnouncement + { + Id = announcement.ID, + Text = announcement.Text, + Html = announcement.Html, + StartsAt = announcement.StartsAt, + EndsAt = announcement.EndsAt, + AllDay = announcement.AllDay, + IsPublished = announcement.IsPublished, + IsShown = announcement.IsPublished && (announcement.EndsAt == default || announcement.EndsAt > DateTime.UtcNow), + PublishedAt = announcement.PublishedAt, + CreatedAt = announcement.CreatedAt, + UpdatedAt = announcement.UpdatedAt, + Reads = await DB.Default.CountAsync(r => r.AnnouncementId == announcement.ID, token), + Reactions = reactions.GroupBy(r => r.Name).Select(g => new ViewAnnouncementReaction { Name = g.Key, Count = g.Count() }).ToList() + }; + } + + #endregion + + #region relays + + [HttpGet, Route("/clientapi/admin/relays")] + public async Task Relays(CancellationToken token) + { + if (await Administrator(token) == default) + return Forbid(); + return Ok(await RelayViews(_settings.Current, token)); + } + + // a relay named by the settings: subscribed to at once (in the background: resolving it is a fetch), the page polls + [HttpPost, Route("/clientapi/admin/relays/insert")] + public async Task InsertRelay([FromServices] IRelays relays, RelayForm form, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + var address = form.Address?.Trim(); + if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo) + || !string.IsNullOrEmpty(uri.Fragment)) + return Refused(StatusCodes.Status422UnprocessableEntity, "A relay is named by the https address of its actor or its inbox."); + if (relays.Configured.Contains(address)) + return Refused(StatusCodes.Status409Conflict, "The configuration names this relay already."); + if (_settings.Current.Relays.Contains(address)) + return Ok(await RelayViews(_settings.Current, token)); + if (_settings.Current.Relays.Count >= MaxSettingsRelays) + return Refused(StatusCodes.Status422UnprocessableEntity, "The settings name 20 relays at most."); + var saved = await _settings.Update(settings => + { + if (!settings.Relays.Contains(address)) + settings.Relays.Add(address); + }, admin.ID, token); + await _log.Write(admin.ID, AdminActions.RelayInsert, AdminTargets.Relay, address, address, token: token); + Reconcile(relays); + return Accepted(await RelayViews(saved, token)); + } + + // a relay the settings named: unsubscribed at once (its Undo is queued); the configuration's are removed only there + [HttpPost, Route("/clientapi/admin/relays/delete")] + public async Task DeleteRelay([FromServices] IRelays relays, [FromQuery] string address, CancellationToken token) + { + var admin = await Administrator(token); + if (admin == default) + return Forbid(); + address = address?.Trim(); + if (relays.Configured.Contains(address)) + return Refused(StatusCodes.Status409Conflict, "This relay is named by the configuration (Federation:Relays): remove it there."); + if (string.IsNullOrEmpty(address) || !_settings.Current.Relays.Contains(address)) + return NotFound(); + var saved = await _settings.Update(settings => settings.Relays.Remove(address), admin.ID, token); + await _log.Write(admin.ID, AdminActions.RelayDelete, AdminTargets.Relay, address, address, token: token); + Reconcile(relays); + return Accepted(await RelayViews(saved, token)); + } + + void Reconcile(IRelays relays) => _ = Task.Run(async () => + { + try + { + await relays.Reconcile(CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Relay subscriptions could not be brought up to date"); + } + }, CancellationToken.None); + + async Task> RelayViews(ServerSettings settings, CancellationToken token) + { + var configured = HttpContext.RequestServices.GetRequiredService().Configured; + var subscriptions = (await DB.Default.Find().ExecuteAsync(token)).GroupBy(s => s.Configured).ToDictionary(g => g.Key, g => g.First()); + ViewRelay View(string address, string source) + { + var subscription = subscriptions.GetValueOrDefault(address); + return new ViewRelay + { + Address = address, + Source = source, + State = subscription == default ? "none" : subscription.State.ToString().ToLowerInvariant(), + ActorURI = subscription?.ActorURI, + RequestedAt = subscription?.RequestedAt, + AnsweredAt = subscription?.AnsweredAt + }; + } + return configured.Select(a => View(a, "configuration")) + .Concat((settings.Relays ?? new()).Where(a => !configured.Contains(a)).Select(a => View(a, "settings"))) + .ToList(); + } + + #endregion + } +} diff --git a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs index 8410227..2c61c32 100644 --- a/PrivaPub/Controllers/ClientToServer/ModeratorController.cs +++ b/PrivaPub/Controllers/ClientToServer/ModeratorController.cs @@ -5,8 +5,12 @@ using MongoDB.Entities; using PrivaPub.ClientModels; using PrivaPub.ClientModels.Admin; +using PrivaPub.Domain.Instance; +using PrivaPub.Domain.Moderation; using PrivaPub.Extensions; +using PrivaPub.Models; using PrivaPub.Models.Social; +using PrivaPub.Models.User; namespace PrivaPub.Controllers.ClientToServer { @@ -15,9 +19,22 @@ namespace PrivaPub.Controllers.ClientToServer Authorize(Policy = Policies.IsModerator)] public class ModeratorController : ControllerBase { + const int MaxLogPage = 100; + + readonly IInstanceSettings _settings; + readonly IModerationLog _log; + + public ModeratorController(IInstanceSettings settings, IModerationLog log) + { + _settings = settings; + _log = log; + } + [HttpGet, Route("/clientapi/moderator/reports")] - public async Task Reports([FromQuery] bool resolved, CancellationToken token) => - Ok((await DB.Default.Find().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token)) + public async Task Reports([FromQuery] bool resolved, CancellationToken token) + { + var rules = _settings.Current.Rules.ToDictionary(r => r.Id, r => r.Text ?? string.Empty); + return Ok((await DB.Default.Find().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token)) .Select(r => new ViewReport { ID = r.ID, @@ -27,6 +44,8 @@ namespace PrivaPub.Controllers.ClientToServer PostIds = r.PostIds, ObjectURIs = r.ObjectURIs, Category = r.Category, + RuleIds = r.RuleIds ?? new(), + Rules = (r.RuleIds ?? new()).Select(id => rules.GetValueOrDefault(id, string.Empty)).ToList(), Comment = r.Comment, Forwarded = r.Forwarded, IsResolved = r.IsResolved, @@ -35,16 +54,71 @@ namespace PrivaPub.Controllers.ClientToServer RemoteResolvedAt = r.RemoteResolvedAt, RemoteResolvedBy = r.RemoteResolvedBy })); + } [HttpPost, Route("/clientapi/moderator/reports/{id}/resolve")] public async Task Resolve(string id, CancellationToken token) { + var by = User.GetUserId(); var result = await DB.Default.Update().MatchID(id) .Modify(r => r.IsResolved, true) .Modify(r => r.ResolvedAt, DateTime.UtcNow) - .Modify(r => r.ResolvedBy, User.GetUserId()) + .Modify(r => r.ResolvedBy, by) .ExecuteAsync(token); - return result.MatchedCount == 0 ? NotFound() : Ok(); + if (result.MatchedCount == 0) + return NotFound(); + var report = await DB.Default.Find().MatchID(id).ExecuteFirstAsync(token); + await _log.Write(by, AdminActions.ReportResolve, AdminTargets.Report, id, report?.Category, reportId: id, token: token); + return Ok(); + } + + // The staff's log, newest first, a page at a time (before: the last id of the page before). Administrators read all + // of it; a moderator who isn't one reads what isn't about a login, and who acted only when it was the command line or + // themselves. + [HttpGet, Route("/clientapi/moderator/log")] + public async Task Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default) + { + var me = await DB.Default.Find().MatchID(User.GetUserId()).ExecuteFirstAsync(token); + if (me is not { IsBanned: false, DeletedAt: null } || !me.Policies.Contains(Policies.IsModerator)) + return Forbid(); + var admin = me.Policies.Contains(Policies.IsAdmin); + limit = Math.Clamp(limit, 1, MaxLogPage); + var query = DB.Default.Find(); + if (!string.IsNullOrEmpty(before)) + { + if (!MongoDB.Bson.ObjectId.TryParse(before, out _)) + return BadRequest(); + query.Match(f => f.Lt(a => a.ID, before)); + } + if (!admin) + query.Match(a => a.TargetKind != AdminTargets.Root); + var actions = await query.Sort(a => a.ID, Order.Descending).Limit(limit).ExecuteAsync(token); + var actorIds = actions.Select(a => a.ByRootId).Where(id => id != default && id != AdminAction.Cli).Distinct().ToList(); + var names = admin && actorIds.Count > 0 + ? (await DB.Default.Find().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token)) + .ToDictionary(u => u.ID, u => u.UserName) + : new Dictionary(); + return Ok(new ViewAdminActionsPage + { + Items = actions.Select(a => new ViewAdminAction + { + Id = a.ID, + At = a.At, + By = a.ByRootId == AdminAction.Cli ? AdminAction.Cli + : a.ByRootId == default ? default + : admin ? names.GetValueOrDefault(a.ByRootId, a.ByRootId) + : a.ByRootId == me.ID ? me.UserName + : default, + ByYou = a.ByRootId == me.ID, + Action = a.Action, + TargetKind = a.TargetKind, + TargetId = a.TargetId, + TargetLabel = a.TargetLabel, + ReportId = a.ReportId, + Note = a.Note + }).ToList(), + Next = actions.Count == limit ? actions[^1].ID : default + }); } } } diff --git a/PrivaPub/Domain/Content/ContentRenderer.cs b/PrivaPub/Domain/Content/ContentRenderer.cs index ed5cd21..33fe474 100644 --- a/PrivaPub/Domain/Content/ContentRenderer.cs +++ b/PrivaPub/Domain/Content/ContentRenderer.cs @@ -43,6 +43,11 @@ namespace PrivaPub.Domain.Content public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}"; + /// A text of the server's own (its description, privacy policy, terms, an announcement) from Markdown to + /// sanitized HTML: the posts' pipeline (raw HTML off), without mentions or hashtags, so nothing is looked up. + public static string Document(string markdown) => + string.IsNullOrWhiteSpace(markdown) ? string.Empty : ContentSanitizer.Html(Markdig.Markdown.ToHtml(markdown, Pipeline)); + public Task Markdown(string text, CancellationToken token) => Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token); diff --git a/PrivaPub/Domain/Instance/InstanceSettings.cs b/PrivaPub/Domain/Instance/InstanceSettings.cs new file mode 100644 index 0000000..55c0860 --- /dev/null +++ b/PrivaPub/Domain/Instance/InstanceSettings.cs @@ -0,0 +1,146 @@ +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Primitives; + +using MongoDB.Bson.Serialization; +using MongoDB.Entities; + +using PrivaPub.Domain.Media; +using PrivaPub.Models; + +namespace PrivaPub.Domain.Instance +{ + public interface IInstanceSettings + { + /// The settings as they are now, never null: the defaults (today's literals) until an administrator saves. + /// Never change it: Update does. + ServerSettings Current { get; } + /// Reads the document again; tells whoever listens (ChangeToken) when it changed. + Task Reload(CancellationToken token); + /// Changes the settings as stored now (or the defaults when none are), saves them and makes them current. + Task Update(Action change, string byRootId, CancellationToken token); + /// Fires once the settings change (a save here, or a reload that found another version). + IChangeToken ChangeToken(); + } + + // The server's own settings (Models/ServerSettings.cs) in memory: read at start, saved from the administrator's page, and + // read again every five minutes (InstanceSettingsRefresher), so an edit made elsewhere takes too. What reads them reads + // Current, which is never null and never changed in place. + public class InstanceSettings : IInstanceSettings + { + readonly ILogger _logger; + readonly SemaphoreSlim _saving = new(1, 1); + volatile ServerSettings _current = ServerSettings.Defaults(); + volatile bool _stored;//whether Current came from the document (or is the defaults) + CancellationTokenSource _changed = new(); + + public InstanceSettings(ILogger logger) + { + _logger = logger; + } + + public ServerSettings Current => _current; + + public IChangeToken ChangeToken() => new CancellationChangeToken(Volatile.Read(ref _changed).Token); + + public async Task Reload(CancellationToken token) + { + var stored = await Find(token); + if ((stored != default) == _stored && stored?.UpdatedAt == _current.UpdatedAt) + return; + Swap(stored ?? ServerSettings.Defaults(), stored != default); + } + + public async Task Update(Action change, string byRootId, CancellationToken token) + { + await _saving.WaitAsync(token); + try + { + var settings = await Find(token) ?? ServerSettings.Defaults(); + change(settings); + settings.ID = ServerSettings.Name; + settings.UpdatedAt = DateTime.UtcNow; + settings.UpdatedBy = byRootId; + await DB.Default.SaveAsync(settings, token); + // what is kept in memory is a copy nobody else holds + Swap(BsonSerializer.Deserialize(MongoDB.Bson.BsonExtensionMethods.ToBson(settings)), stored: true); + return settings; + } + finally + { + _saving.Release(); + } + } + + static Task Find(CancellationToken token) => + DB.Default.Find().Match(s => s.ID == ServerSettings.Name).ExecuteFirstAsync(token); + + void Swap(ServerSettings settings, bool stored) + { + _current = settings; + _stored = stored; + var previous = Interlocked.Exchange(ref _changed, new CancellationTokenSource()); + try + { + previous.Cancel(); + } + catch (AggregateException ex) + { + _logger.LogWarning(ex, "A listener to the server's settings failed"); + } + //(not disposed: a reader may still be registering on its token) + } + } + + // reads the settings again every five minutes, so an edit made elsewhere (another process, the database) takes + public sealed class InstanceSettingsRefresher(IInstanceSettings settings, ILogger logger) : BackgroundService + { + static readonly TimeSpan Every = TimeSpan.FromMinutes(5); + + protected override async Task ExecuteAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(Every); + while (await timer.WaitForNextTickAsync(token)) + { + try + { + await settings.Reload(token); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "The server's settings could not be read again"); + } + } + } + } + + // Media:* as the administrator changed it (ServerSettings.Media), within hard caps: images up to 40 MB, video up to + // 99 MB (nginx takes 100 MB, as does MediaController's request limit), a quota of 0 or more, audio and video up to four + // hours. Every IOptionsMonitor reader sees it, since the settings' change token is one of its sources. + public sealed class MediaLimitsFromSettings(IInstanceSettings settings) : IPostConfigureOptions + { + public const long MaxImageBytes = 40L * 1024 * 1024; + public const long MaxVideoBytes = 99L * 1024 * 1024; + public const int MaxSeconds = 4 * 3600; + + public void PostConfigure(string name, MediaOptions options) + { + if (name != Options.DefaultName || settings.Current.Media is not { } media) + return; + if (media.QuotaBytesPerRoot is { } quota) + options.QuotaBytesPerRoot = Math.Max(0, quota); + if (media.MaxImageBytes is { } image) + options.MaxImageBytes = Math.Clamp(image, 1, MaxImageBytes); + if (media.MaxVideoBytes is { } video) + options.MaxVideoBytes = Math.Clamp(video, 1, MaxVideoBytes); + if (media.MaxSeconds is { } seconds) + options.MaxSeconds = Math.Clamp(seconds, 1, MaxSeconds); + } + } + + public sealed class MediaLimitsChangeSource(IInstanceSettings settings) : IOptionsChangeTokenSource + { + public string Name => Options.DefaultName; + + public IChangeToken GetChangeToken() => settings.ChangeToken(); + } +} diff --git a/PrivaPub/Domain/Media/MediaAudit.cs b/PrivaPub/Domain/Media/MediaAudit.cs index 63456f3..e60e0bc 100644 --- a/PrivaPub/Domain/Media/MediaAudit.cs +++ b/PrivaPub/Domain/Media/MediaAudit.cs @@ -9,7 +9,8 @@ namespace PrivaPub.Domain.Media { // What the media directory holds against what the database says holds it, and, with fix, the two made to agree: // - a persona's current avatar or header with no row (they had none before) becomes a row of its own; - // - media of a deleted post or of a deleted persona go to the trash; + // - media of a deleted post or of a deleted persona go to the trash, and a picture of the server's own (ServerUse) that + // its settings no longer name; // - rows whose files are gone go to the trash; // - files nothing holds (deleted posts' media and replaced pictures, from before media were trashed) get a trashed row // each. @@ -59,7 +60,11 @@ namespace PrivaPub.Domain.Media var ownerIds = rows.Select(r => r.OwnerAvatarId).Where(id => id != null).Distinct().ToList(); var liveOwners = (await DB.Default.Find().Match(a => ownerIds.Contains(a.ID) && !a.DeletionAt.HasValue).Project(a => a.Include(x => x.ID)).ExecuteAsync(token)) .Select(a => a.ID).ToHashSet(); - var ofDeleted = rows.Where(r => (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList(); + // (the server's own pictures belong to nobody: they are held while the settings name them) + var settings = await DB.Default.Find().Match(x => x.ID == Models.ServerSettings.Name).ExecuteFirstAsync(token); + var serverHeld = new HashSet { settings?.ThumbnailMediaId }; + var ofDeleted = rows.Where(r => r.ServerUse != null ? !serverHeld.Contains(r.ID) + : (r.PostId != null && !livePosts.Contains(r.PostId)) || (r.OwnerAvatarId != null && !liveOwners.Contains(r.OwnerAvatarId))).ToList(); var missing = rows.Except(ofDeleted).Where(r => !string.IsNullOrEmpty(r.FilePath) && !files.Contains(r.FilePath)).ToList(); if (fix) foreach (var row in ofDeleted.Concat(missing)) diff --git a/PrivaPub/Domain/Media/MediaProxy.cs b/PrivaPub/Domain/Media/MediaProxy.cs index b911065..64ce5ea 100644 --- a/PrivaPub/Domain/Media/MediaProxy.cs +++ b/PrivaPub/Domain/Media/MediaProxy.cs @@ -359,7 +359,8 @@ namespace PrivaPub.Domain.Media } // one pass: - // - uploads never posted for a day (and not waiting for a scheduled post, nor a profile picture) go to the trash; + // - uploads never posted for a day (and not waiting for a scheduled post, nor a profile picture, nor the server's own + // picture) go to the trash; // - trashed files still served (a crash between the mark and the move) are moved out; // - trashed files past their grace are deleted, with their rows, unless a backup or a restore is running (one may be // reading the trash); @@ -367,8 +368,8 @@ namespace PrivaPub.Domain.Media public async Task Sweep(CancellationToken token) { var cutoff = DateTime.UtcNow - UnattachedLifetime; - var unattached = await _media.Trash(m => m.PostId == null && m.ScheduledStatusId == null && m.ProfileOfAvatarId == null && m.CreatedAt < cutoff, - "never posted", token); + var unattached = await _media.Trash(m => m.PostId == null && m.ScheduledStatusId == null && m.ProfileOfAvatarId == null && m.ServerUse == null + && m.CreatedAt < cutoff, "never posted", token); var trashed = await DB.Default.Find().Match(m => m.TrashedAt != null).Limit(2000).ExecuteAsync(token); var purgeBefore = DateTime.UtcNow - TrashGrace; diff --git a/PrivaPub/Domain/Media/MediaService.cs b/PrivaPub/Domain/Media/MediaService.cs index f3be238..50ee026 100644 --- a/PrivaPub/Domain/Media/MediaService.cs +++ b/PrivaPub/Domain/Media/MediaService.cs @@ -46,6 +46,9 @@ namespace PrivaPub.Domain.Media Task ProcessPending(MediaAttachment pending, CancellationToken token); /// A persona's new avatar or header ("avatar" or "header"), cropped to its size, as a row of its own. Task ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token); + /// A picture of the server's own (its thumbnail, "thumbnail"), fitted within 1200×630, as a row nobody owns, + /// held by its . + Task ServerImage(string use, IFormFile file, CancellationToken token); /// Trashes the media matches (and that isn't trashed yet): each row is marked in one /// conditional update, so a row attached meanwhile is left alone, and its files stop being served at once. Task Trash(System.Linq.Expressions.Expression> which, string reason, CancellationToken token); @@ -245,6 +248,57 @@ namespace PrivaPub.Domain.Media return new MediaOutcome(attachment); } + public const int ServerImageWidth = 1200; + public const int ServerImageHeight = 630; + + // as safe as a profile picture: checked before it is decoded, decoded by the loaders allowed alone, written without its + // metadata; fitted (not cropped) within the size link previews use + public async Task ServerImage(string use, IFormFile file, CancellationToken token) + { + var contentType = file?.ContentType?.Split(';')[0].Trim().ToLowerInvariant(); + if (file == default || file.Length == 0) + return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File can't be blank"); + if (!ImageTypes.Contains(contentType)) + return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File type is not supported"); + if (file.Length > _options.CurrentValue.MaxImageBytes) + return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: File is too big"); + var input = await Read(file, token); + if (Refusal(input, _options.CurrentValue) is { } refused) + return refused; + byte[] bytes; + int outWidth, outHeight; + await _processing.WaitAsync(token); + try + { + using var image = Image.ThumbnailBuffer(input, ServerImageWidth, height: ServerImageHeight, size: Enums.Size.Down); + using var flat = Flatten(image); + bytes = flat.WriteToBuffer(".jpg[Q=85,keep=none]"); + (outWidth, outHeight) = (flat.Width, flat.Height); + } + catch (VipsException ex) + { + _logger.LogInformation("Refused a server picture: {Error}", ex.Message); + return MediaOutcome.Fail(StatusCodes.Status422UnprocessableEntity, "Validation failed: The file is not a readable image"); + } + finally + { + _processing.Release(); + } + var attachment = new MediaAttachment + { + Kind = "image", + ServerUse = use, + ContentType = "image/jpeg", + FilePath = await Save(bytes, "jpg", token), + Size = bytes.Length, + Width = outWidth, + Height = outHeight, + AttachedAt = DateTime.UtcNow + }; + await DB.Default.SaveAsync(attachment, token); + return new MediaOutcome(attachment); + } + // a login over its quota (Media:QuotaBytesPerRoot; none when 0) uploads nothing more async Task OverQuota(string avatarId, long incoming, CancellationToken token) { diff --git a/PrivaPub/Domain/Moderation/ModerationLog.cs b/PrivaPub/Domain/Moderation/ModerationLog.cs new file mode 100644 index 0000000..9d4e18c --- /dev/null +++ b/PrivaPub/Domain/Moderation/ModerationLog.cs @@ -0,0 +1,59 @@ +using MongoDB.Entities; + +using PrivaPub.Models; +using PrivaPub.Models.User; + +namespace PrivaPub.Domain.Moderation +{ + public interface IModerationLog + { + /// Keeps one act of the staff (Models/AdminAction.cs). Never throws: an act that was done stays done when + /// its entry can't be written, and the failure is logged. + Task Write(string byRootId, string action, string targetKind, string targetId, string targetLabel, string reportId = default, string note = default, + CancellationToken token = default); + } + + // The staff's log (completion program R5): administrators read all of it, moderators what isn't about a login + // (ModeratorController's /clientapi/moderator/log). An entry never holds an IP address, and never pairs a root with its + // own personas: an act on a persona that the acting root owns is kept without who acted. + public class ModerationLog : IModerationLog + { + const int MaxLabel = 200; + const int MaxNote = 1000; + + readonly ILogger _logger; + + public ModerationLog(ILogger logger) + { + _logger = logger; + } + + public async Task Write(string byRootId, string action, string targetKind, string targetId, string targetLabel, string reportId = default, + string note = default, CancellationToken token = default) + { + try + { + if (targetKind == AdminTargets.Persona && byRootId != default && byRootId != AdminAction.Cli && targetId != default + && await DB.Default.Find().Match(r => r.RootId == byRootId && r.AvatarId == targetId).ExecuteAnyAsync(token)) + byRootId = default; + await DB.Default.SaveAsync(new AdminAction + { + ByRootId = byRootId, + Action = action, + TargetKind = targetKind, + TargetId = targetId, + TargetLabel = Cut(targetLabel, MaxLabel), + ReportId = reportId, + Note = Cut(note, MaxNote) + }, CancellationToken.None);//an act that was done is logged even when its request went away + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The staff's log could not keep a {Action}", action); + } + } + + static string Cut(string text, int max) => + string.IsNullOrEmpty(text) ? text : text.Length <= max ? text : text[..(max - 1)] + "…"; + } +} diff --git a/PrivaPub/Domain/Privacy/Counted.cs b/PrivaPub/Domain/Privacy/Counted.cs index 6056798..7aeba57 100644 --- a/PrivaPub/Domain/Privacy/Counted.cs +++ b/PrivaPub/Domain/Privacy/Counted.cs @@ -88,5 +88,30 @@ namespace PrivaPub.Domain.Privacy var barred = await BarredPersonas(token); return await DB.Default.CountAsync(f => f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token); } + + // Mastodon's instance activity, week by week (from Monday 00:00 UTC), this week first: the local posts made in it, + // counted as LocalPosts counts them (by their ids, which carry when they were made, so the _id index serves it), and + // the logins made in it (RootUser.CreatedAt). Never personas by their creation: their dates are spread on purpose. + public static async Task> Weekly(int weeks, CancellationToken token) + { + var barred = await BarredPersonas(token); + var today = DateTime.UtcNow.Date; + var monday = DateTime.SpecifyKind(today.AddDays(-(((int)today.DayOfWeek + 6) % 7)), DateTimeKind.Utc); + var counted = new List<(DateTime, long, long)>(); + for (var i = 0; i < weeks; i++) + { + var start = monday.AddDays(-7 * i); + var end = start.AddDays(7); + var (low, high) = (IdAt(start), IdAt(end)); + var posts = await DB.Default.CountAsync(f => f.Gte(p => p.ID, low) & f.Lt(p => p.ID, high) + & f.Where(p => !p.IsFederatedCopy && !barred.Contains(p.GroupUserId)) & f.Where(Post), token); + var registrations = await DB.Default.CountAsync(u => u.CreatedAt >= start && u.CreatedAt < end, token); + counted.Add((start, posts, registrations)); + } + return counted; + } + + // the smallest ObjectId of a moment + static string IdAt(DateTime at) => ((uint)new DateTimeOffset(at, TimeSpan.Zero).ToUnixTimeSeconds()).ToString("x8") + "0000000000000000"; } } diff --git a/PrivaPub/Domain/Relationships/ReportService.cs b/PrivaPub/Domain/Relationships/ReportService.cs index 38a62de..697bdc8 100644 --- a/PrivaPub/Domain/Relationships/ReportService.cs +++ b/PrivaPub/Domain/Relationships/ReportService.cs @@ -14,8 +14,8 @@ namespace PrivaPub.Domain.Relationships { public interface IReportService { - Task File(LocalActor reporter, string targetAccountId, IReadOnlyList postIds, string comment, string category, bool forward, - CancellationToken token); + Task File(LocalActor reporter, string targetAccountId, IReadOnlyList postIds, string comment, string category, + IReadOnlyList ruleIds, bool forward, CancellationToken token); } public class ReportService : IReportService @@ -23,17 +23,29 @@ namespace PrivaPub.Domain.Relationships readonly DbEntities _dbEntities; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; + readonly Instance.IInstanceSettings _settings; - public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery) + public ReportService(DbEntities dbEntities, ILocalActorService localActors, IDeliveryService delivery, Instance.IInstanceSettings settings) { _dbEntities = dbEntities; _localActors = localActors; _delivery = delivery; + _settings = settings; } public async Task File(LocalActor reporter, string targetAccountId, IReadOnlyList postIds, string comment, string category, - bool forward, CancellationToken token) + IReadOnlyList ruleIds, bool forward, CancellationToken token) { + // the server's rules it names, deleted ones too (they may have been listed when the reader opened the dialog); a + // report naming rules is a violation unless it says what else it is, and only a violation names rules, as on Mastodon + var known = _settings.Current.Rules.Select(r => r.Id).ToHashSet(StringComparer.Ordinal); + var rules = (ruleIds ?? Array.Empty()).Where(known.Contains).Distinct().ToList(); + if (rules.Count > 0 && string.IsNullOrEmpty(category)) + category = "violation"; + category = category is "spam" or "legal" or "violation" ? category : "other"; + if (category != "violation") + rules.Clear(); + var localTarget = await _dbEntities.Avatars.MatchID(targetAccountId).ExecuteFirstAsync(token); var remoteTarget = localTarget == default ? await _dbEntities.ForeignAvatars.MatchID(targetAccountId).ExecuteFirstAsync(token) : default; if (localTarget == default && remoteTarget == default) @@ -51,7 +63,8 @@ namespace PrivaPub.Domain.Relationships PostIds = posts.Select(p => p.ID).ToList(), ObjectURIs = posts.Select(p => p.ObjectURI).ToList(), Comment = comment?.Trim(), - Category = category is "spam" or "legal" or "violation" ? category : "other" + Category = category, + RuleIds = rules }; await DB.Default.SaveAsync(report, token); if (!forward || remoteTarget == default) diff --git a/PrivaPub/Domain/Social/Announcements.cs b/PrivaPub/Domain/Social/Announcements.cs new file mode 100644 index 0000000..499731f --- /dev/null +++ b/PrivaPub/Domain/Social/Announcements.cs @@ -0,0 +1,162 @@ +using MongoDB.Driver; +using MongoDB.Entities; + +using PrivaPub.Domain.Content; +using PrivaPub.Domain.Timelines; +using PrivaPub.Models.Social; + +using System.Linq.Expressions; + +namespace PrivaPub.Domain.Social +{ + public sealed record AnnouncementDraft(string Text, DateTime? StartsAt, DateTime? EndsAt, bool AllDay, bool IsPublished); + + public interface IAnnouncements + { + Task> Shown(CancellationToken token); + Task FindShown(string id, CancellationToken token); + Task Dismiss(string id, string avatarId, CancellationToken token); + /// 200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji. + Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token); + Task> All(CancellationToken token); + Task Insert(AnnouncementDraft draft, CancellationToken token); + Task Update(string id, AnnouncementDraft draft, CancellationToken token); + Task Delete(string id, CancellationToken token); + } + + // The administrators' announcements (completion program R5): written on the administrator's page, shown to every persona + // while published and not ended, each persona's dismissal and reactions kept apart and never federated. Every change a + // client shows is told to every persona's home stream, as Mastodon tells it: `announcement` (made, edited or published), + // `announcement.reaction` and `announcement.delete` (deleted or no longer shown). + public class Announcements : IAnnouncements + { + public const int MaxReactionNames = 8;//as Mastodon: eight different emoji on one announcement + const int MaxNameLength = 32; + + public static Expression> IsShown() + { + var now = DateTime.UtcNow; + return a => a.IsPublished && (a.EndsAt == null || a.EndsAt > now); + } + + static bool Showing(Announcement a) => a != default && a.IsPublished && (a.EndsAt == null || a.EndsAt > DateTime.UtcNow); + + // as Mastodon orders them: by when they are about, else when they were published + public async Task> Shown(CancellationToken token) => + (await DB.Default.Find().Match(IsShown()).ExecuteAsync(token)) + .OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList(); + + public Task FindShown(string id, CancellationToken token) => + string.IsNullOrEmpty(id) ? Task.FromResult(default) + : DB.Default.Find().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token); + + public async Task Dismiss(string id, string avatarId, CancellationToken token) + { + if (await FindShown(id, token) == default) + return false; + try + { + await DB.Default.Update() + .Match(r => r.AnnouncementId == id && r.AvatarId == avatarId) + .Modify(b => b.SetOnInsert(r => r.ReadAt, DateTime.UtcNow)) + .Option(o => o.IsUpsert = true) + .ExecuteAsync(token); + } + catch (MongoException ex) when (ex is MongoCommandException { Code: 11000 } or MongoWriteException { WriteError.Category: ServerErrorCategory.DuplicateKey }) + { + //dismissed at the same moment by another request + } + return true; + } + + public async Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token) + { + if (await FindShown(id, token) == default) + return (StatusCodes.Status404NotFound, "Record not found"); + // one unicode emoji (custom emoji come with R7): the same normalisation as a post's reactions + var picked = Reactions.Normalise(name, new List(), requireKnownCustom: true); + if (picked == default || picked.Url != default || picked.Name.Length > MaxNameLength) + return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Name is not a recognized emoji"); + name = picked.Name; + if (!add) + { + var removed = await DB.Default.DeleteAsync(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name); + if (removed.DeletedCount > 0) + Streams.ToUsers(new Streams.Event("announcement.reaction", id, name)); + return (StatusCodes.Status200OK, default); + } + if (await DB.Default.Find().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token)) + return (StatusCodes.Status200OK, default); + var names = await DB.Default.Fluent().Match(r => r.AnnouncementId == id).Group(r => r.Name, g => g.Key).ToListAsync(token); + if (!names.Contains(name) && names.Count >= MaxReactionNames) + return (StatusCodes.Status422UnprocessableEntity, "Validation failed: Maximum number of reactions reached"); + try + { + await DB.Default.SaveAsync(new AnnouncementReaction { AnnouncementId = id, AvatarId = avatarId, Name = name }, token); + } + catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) + { + return (StatusCodes.Status200OK, default);//the same reaction at the same moment + } + Streams.ToUsers(new Streams.Event("announcement.reaction", id, name)); + return (StatusCodes.Status200OK, default); + } + + public async Task> All(CancellationToken token) => + await DB.Default.Find().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token); + + public async Task Insert(AnnouncementDraft draft, CancellationToken token) + { + var announcement = new Announcement(); + Apply(announcement, draft); + await DB.Default.SaveAsync(announcement, token); + if (Showing(announcement)) + Streams.ToUsers(new Streams.Event("announcement", announcement.ID)); + return announcement; + } + + public async Task Update(string id, AnnouncementDraft draft, CancellationToken token) + { + var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find().Match(a => a.ID == id).ExecuteFirstAsync(token); + if (announcement == default) + return default; + var wasShown = Showing(announcement); + Apply(announcement, draft); + announcement.UpdatedAt = DateTime.UtcNow; + await DB.Default.SaveAsync(announcement, token); + if (Showing(announcement)) + Streams.ToUsers(new Streams.Event("announcement", announcement.ID)); + else if (wasShown) + Streams.ToUsers(new Streams.Event("announcement.delete", announcement.ID)); + return announcement; + } + + public async Task Delete(string id, CancellationToken token) + { + var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find().Match(a => a.ID == id).ExecuteFirstAsync(token); + if (announcement == default) + return default; + await DB.Default.DeleteAsync(a => a.ID == id); + await DB.Default.DeleteAsync(r => r.AnnouncementId == id); + await DB.Default.DeleteAsync(r => r.AnnouncementId == id); + if (Showing(announcement)) + Streams.ToUsers(new Streams.Event("announcement.delete", id)); + return announcement; + } + + static void Apply(Announcement announcement, AnnouncementDraft draft) + { + announcement.Text = draft.Text?.Trim(); + announcement.Html = ContentRenderer.Document(announcement.Text); + announcement.StartsAt = Utc(draft.StartsAt); + announcement.EndsAt = Utc(draft.EndsAt); + announcement.AllDay = draft.AllDay; + if (draft.IsPublished && !announcement.IsPublished) + announcement.PublishedAt = DateTime.UtcNow; + announcement.IsPublished = draft.IsPublished; + } + + static DateTime? Utc(DateTime? value) => value == default ? (DateTime?)null : value.Value.Kind == DateTimeKind.Local ? value.Value.ToUniversalTime() + : DateTime.SpecifyKind(value.Value, DateTimeKind.Utc); + } +} diff --git a/PrivaPub/Domain/Timelines/Streams.cs b/PrivaPub/Domain/Timelines/Streams.cs index bdef98d..1199b4c 100644 --- a/PrivaPub/Domain/Timelines/Streams.cs +++ b/PrivaPub/Domain/Timelines/Streams.cs @@ -22,8 +22,9 @@ namespace PrivaPub.Domain.Timelines public static readonly string[] Names = { User, UserNotification, Public, PublicLocal, PublicRemote, Hashtag, HashtagLocal, List, Direct }; - // what happened: Mastodon's event name, and the id of what it is about - public sealed record Event(string Name, string Id); + // what happened: Mastodon's event name, the id of what it is about and, for some, what else the event says (an + // announcement reaction's emoji) + public sealed record Event(string Name, string Id, string Data = default); // one stream a connection listens to: its name and, for a hashtag or a list, which one public sealed record Key(string Name, string Parameter = default) @@ -102,6 +103,16 @@ namespace PrivaPub.Domain.Timelines listener.Tell(key, e); } + // to every persona's home stream (user), as Mastodon tells its announcements: each connection maps it for its own + // persona when it sends it + public static void ToUsers(Event e) + { + var user = new Key(User); + foreach (var listener in Listeners.Keys) + if (listener.Hears(user)) + listener.Tell(user, e); + } + public static bool Anyone => !Listeners.IsEmpty; } } diff --git a/PrivaPub/Federation/Actors/LocalActorService.cs b/PrivaPub/Federation/Actors/LocalActorService.cs index 7e2a963..774a3f5 100644 --- a/PrivaPub/Federation/Actors/LocalActorService.cs +++ b/PrivaPub/Federation/Actors/LocalActorService.cs @@ -104,16 +104,21 @@ namespace PrivaPub.Federation.Actors "abuse", "postmaster", "webmaster", "hostmaster", "security", "noreply", "no_reply", "null", "undefined" }; + // what the instance actor says of itself until the administrator describes the server (ServerSettings) + const string InstanceSummary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for."; + readonly DbEntities _dbEntities; readonly IOptionsMonitor _appConfiguration; + readonly Domain.Instance.IInstanceSettings _settings; InstanceActor _instanceActor; ReporterActor _reporterActor; ReaderActor _readerActor; - public LocalActorService(DbEntities dbEntities, IOptionsMonitor appConfiguration) + public LocalActorService(DbEntities dbEntities, IOptionsMonitor appConfiguration, Domain.Instance.IInstanceSettings settings) { _dbEntities = dbEntities; _appConfiguration = appConfiguration; + _settings = settings; } public string BaseAddress => _appConfiguration.CurrentValue.BackendBaseAddress?.TrimEnd('/'); @@ -195,14 +200,18 @@ namespace PrivaPub.Federation.Actors public async Task GetInstanceActor(CancellationToken token) { var instance = _instanceActor ??= await LoadInstanceActor(token); + // the server's name, and its short description once the administrator wrote one of their own + var settings = _settings.Current; return new LocalActor { Id = instance.ID, Kind = LocalActorKind.Application, UserName = InstanceUserName, - Name = "PrivaPub", - Summary = "The instance actor of this PrivaPub server; it signs the requests no other actor speaks for.", + Name = settings.Title, + Summary = string.IsNullOrWhiteSpace(settings.ShortDescription) || settings.ShortDescription == ServerSettings.DefaultDescription + ? InstanceSummary + : settings.ShortDescription, PrivateKeyPem = instance.PrivateKey, PublicKeyPem = instance.PublicKey, Discoverable = false, diff --git a/PrivaPub/Federation/Controllers/WellKnownController.cs b/PrivaPub/Federation/Controllers/WellKnownController.cs index 8aba452..4cb410f 100644 --- a/PrivaPub/Federation/Controllers/WellKnownController.cs +++ b/PrivaPub/Federation/Controllers/WellKnownController.cs @@ -23,10 +23,13 @@ namespace PrivaPub.Federation.Controllers readonly ILocalActorService _localActors; readonly DbEntities _dbEntities; readonly IOptionsMonitor _registrations; + readonly Domain.Instance.IInstanceSettings _settings; - public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor registrations) + public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor registrations, + Domain.Instance.IInstanceSettings settings) { _registrations = registrations; + _settings = settings; _localActors = localActors; _dbEntities = dbEntities; } @@ -140,8 +143,9 @@ namespace PrivaPub.Federation.Controllers }, ["metadata"] = new JsonObject { - ["nodeName"] = "PrivaPub", - ["nodeDescription"] = "A small ActivityPub server where one private login keeps several unlinkable public personas.", + // the server's name and short description, as its administrator set them (IInstanceSettings) + ["nodeName"] = _settings.Current.Title, + ["nodeDescription"] = _settings.Current.ShortDescription ?? string.Empty, ["federation"] = new JsonObject { ["document"] = "https://git.thepra.dev/thepra/SocialPub/src/branch/master/FEDERATION.md" } } }; diff --git a/PrivaPub/Federation/Relays/Relays.cs b/PrivaPub/Federation/Relays/Relays.cs index 5ef83de..ba6f3dc 100644 --- a/PrivaPub/Federation/Relays/Relays.cs +++ b/PrivaPub/Federation/Relays/Relays.cs @@ -18,6 +18,10 @@ namespace PrivaPub.Federation.Relays { public interface IRelays { + /// The relays Federation:Relays names, as it names them. + IReadOnlyList Configured { get; } + /// The relays named by the configuration or by the server's settings, each once. + IReadOnlyList Named { get; } Task Reconcile(CancellationToken token); Task Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token); Task Passes(string actorUri, CancellationToken token); @@ -25,9 +29,10 @@ namespace PrivaPub.Federation.Relays } // Relays (Activity-Relay, aode-relay, pub-relay): the instance actor follows Public at each relay the configuration - // names, as Mastodon subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read - // again from their origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays - // that accepted us (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does. + // (Federation:Relays) or the server's settings (ServerSettings.Relays, from the administrator's page) name, as Mastodon + // subscribes, and takes what they pass on: public posts, forwarded as their authors sent them (read again from their + // origin, as any forwarded post) or announced by the relay. A persona's public posts go to the relays that accepted us + // (owner decision 2026-10-06), as Mastodon sends them; nothing else of a persona's does. public class Relays : IRelays { static readonly TimeSpan AskAgain = TimeSpan.FromDays(1); @@ -36,31 +41,58 @@ namespace PrivaPub.Federation.Relays readonly IRemoteActorService _remoteActors; readonly ILocalActorService _localActors; readonly IDeliveryService _delivery; + readonly Domain.Instance.IInstanceSettings _settings; + readonly SemaphoreSlim _reconciling = new(1, 1);//the hourly round and the administrator's page, one at a time volatile HashSet _accepted; - public Relays(IOptions options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery) + public Relays(IOptions options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery, + Domain.Instance.IInstanceSettings settings) { _options = options; _remoteActors = remoteActors; _localActors = localActors; _delivery = delivery; + _settings = settings; } - // subscribes to the relays the configuration names (again a day after an unanswered or refused request), and - // unsubscribes from those it no longer names + public IReadOnlyList Configured => Clean(_options.Value.Relays); + + public IReadOnlyList Named => Clean(Configured.Concat(_settings.Current.Relays ?? new())); + + static List Clean(IEnumerable addresses) => + (addresses ?? Array.Empty()).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct(StringComparer.Ordinal).ToList(); + + // subscribes to the relays the configuration and the settings name (again a day after an unanswered or refused + // request), and unsubscribes from those they no longer name: with an Undo, unless the same relay is still named + // under another address (an Undo would end that subscription too) public async Task Reconcile(CancellationToken token) { - var configured = (_options.Value.Relays ?? new()).Where(r => !string.IsNullOrWhiteSpace(r)).Select(r => r.Trim()).Distinct().ToList(); + await _reconciling.WaitAsync(token); + try + { + await ReconcileNow(token); + } + finally + { + _reconciling.Release(); + } + } + + async Task ReconcileNow(CancellationToken token) + { + var configured = Named; var known = await DB.Default.Find().ExecuteAsync(token); if (configured.Count == 0 && known.Count == 0) return; var instance = await _localActors.GetInstanceActor(token); + var kept = known.Where(k => configured.Contains(k.Configured)).Select(k => k.ActorURI).Where(a => a != default).ToHashSet(StringComparer.Ordinal); foreach (var gone in known.Where(k => !configured.Contains(k.Configured))) { - if (gone.State != RelayState.Rejected && !string.IsNullOrEmpty(gone.InboxURL)) + if (gone.State != RelayState.Rejected && !string.IsNullOrEmpty(gone.InboxURL) && !kept.Contains(gone.ActorURI)) await _delivery.Enqueue(instance, new[] { gone.InboxURL }, Undo(instance, gone.FollowActivityURI), token); await DB.Default.DeleteAsync(gone.ID); } + _accepted = default; foreach (var address in configured) { var subscription = known.FirstOrDefault(k => k.Configured == address); @@ -143,7 +175,7 @@ namespace PrivaPub.Federation.Relays }; } - // subscribes to the configured relays a minute after start, then every six hours + // subscribes to the relays named (configuration and settings) a minute after start, then every six hours public sealed class RelaySubscriber(IServiceScopeFactory scopes, ILogger logger) : BackgroundService { static readonly TimeSpan Every = TimeSpan.FromHours(6); diff --git a/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs b/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs index 2ff2395..7abba57 100644 --- a/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs +++ b/PrivaPub/Infrastructure/Backup/ProtectiveMerge.cs @@ -13,7 +13,10 @@ namespace PrivaPub.Infrastructure.Backup // deletion left it), with what a deletion takes away; P's moderation of a persona (silenced, suspended, banned) stays; // - a root keeps P's password, e-mail, ban and policies; // - roots, personas and groups made since become tombstones: deleted, their names kept; local posts made since answer - // 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them); + // 410; media made since go to the trash, as do media trashed since (and they leave the posts restored with them), + // but the server's own pictures the live settings hold; + // - the staff's log is the union (a restore never loses an entry, its own request included), and the server's + // settings are P's: the live settings outlive a restore; // - every session ends, and every server's circuit is closed again. // Run on the imported database, it reads P's files only, so it can run again after a failed attempt. public static class ProtectiveMerge @@ -29,7 +32,8 @@ namespace PrivaPub.Infrastructure.Backup ("DeletedObject", ["ObjectURI"]), ("Report", ["_id"]), ("PersonaFilter", ["_id"]), - ("openiddict.applications", ["client_id"]) + ("openiddict.applications", ["client_id"]), + ("AdminAction", ["_id"]) ]; const string Since = "restore: made after the backup"; @@ -39,8 +43,8 @@ namespace PrivaPub.Infrastructure.Backup IEnumerable P(string collection) => ServerRestore.Read(Path.Combine(previous, collection + ".jsonl.gz")); var now = DateTime.UtcNow; - // who follows whom: P's - foreach (var collection in new[] { "Follower", "Following" }) + // who follows whom: P's; and the server's settings as they were live + foreach (var collection in new[] { "Follower", "Following", "ServerSettings" }) { await database.DropCollectionAsync(collection, token); await ServerRestore.Insert(database.GetCollection(collection), P(collection), token); @@ -206,7 +210,9 @@ namespace PrivaPub.Infrastructure.Backup { if (!restored.TryGetValue(row["_id"], out var mine)) { - if (!IsSet(row, "TrashedAt")) + // made since, and to the trash, but a picture of the server's own (its thumbnail): the live settings, + // which a restore keeps, still show it + if (!IsSet(row, "TrashedAt") && !IsSet(row, "ServerUse")) { row["TrashedAt"] = now; row["TrashReason"] = Since; @@ -223,7 +229,7 @@ namespace PrivaPub.Infrastructure.Backup report.MediaTrashed++; } await ServerRestore.Insert(media, made, token); - report.MediaTrashed += made.Count(m => m["TrashReason"] == Since); + report.MediaTrashed += made.Count(m => m.GetValue("TrashReason", BsonNull.Value) == Since); } var deleted = await (await posts.FindAsync(new BsonDocument("DeletedAt", new BsonDocument("$ne", BsonNull.Value)), diff --git a/PrivaPub/Infrastructure/Cli/AdminCommands.cs b/PrivaPub/Infrastructure/Cli/AdminCommands.cs index f9edb71..cd5216f 100644 --- a/PrivaPub/Infrastructure/Cli/AdminCommands.cs +++ b/PrivaPub/Infrastructure/Cli/AdminCommands.cs @@ -4,6 +4,7 @@ using OpenIddict.Abstractions; using PrivaPub.ClientModels; using PrivaPub.ClientModels.User; +using PrivaPub.Domain.Moderation; using PrivaPub.Infrastructure.Backup; using PrivaPub.Models; using PrivaPub.Models.User; @@ -46,7 +47,7 @@ namespace PrivaPub.Infrastructure.Cli switch (args) { case [("promote" or "demote") and var verb, var userName]: - return await Promote(verb == "promote", userName, output); + return await Promote(services, verb == "promote", userName, output); case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"): return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output); case ["move-persona", var persona, var root]: @@ -54,7 +55,7 @@ namespace PrivaPub.Infrastructure.Cli case ["delete-root", var root]: return await DeleteRoot(services, root, output); case ["reset-totp", var root]: - return await ResetTotp(root, output); + return await ResetTotp(services, root, output); case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"): return await AuditMedia(services, flags.Contains("--fix"), output); case ["backup", "verify", var id]: @@ -139,6 +140,7 @@ namespace PrivaPub.Infrastructure.Cli Console.Error.WriteLine(refused); return 1; } + await Log(services, AdminActions.BackupRestore, AdminTargets.Backup, id, id); output.WriteLine($"{id} will be restored: the running service stops within seconds and restores it when it starts again"); output.WriteLine("(systemd starts it again; follow it with journalctl -u privapub, then PrivaPub admin restore --status)"); return 0; @@ -187,7 +189,12 @@ namespace PrivaPub.Infrastructure.Cli return 0; } - static async Task Promote(bool promote, string userName, TextWriter output) + // every act below is kept in the staff's log as the command line's ("cli"): a login's by its name alone, a persona's + // without the login it went to + static Task Log(IServiceProvider services, string action, string kind, string id, string label, string note = default) => + services.GetRequiredService().Write(AdminAction.Cli, action, kind, id, label, note: note); + + static async Task Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false) { userName = userName.ToLowerInvariant(); var user = await DB.Default.Find().Match(u => u.UserName == userName).ExecuteFirstAsync(); @@ -201,6 +208,8 @@ namespace PrivaPub.Infrastructure.Cli await DB.Default.SaveAsync(user); if (promote) await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed + if (!created) + await Log(services, promote ? AdminActions.RootPromote : AdminActions.RootDemote, AdminTargets.Root, user.ID, userName); output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}"); return 0; @@ -223,7 +232,9 @@ namespace PrivaPub.Infrastructure.Cli Console.Error.WriteLine(created.ErrorMessage); return 1; } - return admin ? await Promote(true, login, output) : await Report(login, output); + var made = await DB.Default.Find().Match(u => u.UserName == login.ToLowerInvariant()).ExecuteFirstAsync(); + await Log(services, AdminActions.RootCreate, AdminTargets.Root, made?.ID, login.ToLowerInvariant(), admin ? "administrator" : default); + return admin ? await Promote(services, true, login, output, created: true) : await Report(login, output); } static Task Report(string login, TextWriter output) @@ -266,22 +277,24 @@ namespace PrivaPub.Infrastructure.Cli await DB.Default.Update().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync(); await services.GetRequiredService().RevokeBySubjectAsync(persona.ID); await services.GetRequiredService().RevokeBySubjectAsync(persona.ID); + await Log(services, AdminActions.PersonaMove, AdminTargets.Persona, persona.ID, "@" + personaName, "moved to another login"); output.WriteLine($"@{personaName}: moved to {login}"); return 0; } // The way back for a login that lost its authenticator and its recovery codes: its two-factor sign-in goes, its // password stays. - static async Task ResetTotp(string login, TextWriter output) + static async Task ResetTotp(IServiceProvider services, string login, TextWriter output) { login = login.ToLowerInvariant(); - var updated = await DB.Default.Update().Match(u => u.UserName == login && u.DeletedAt == null) - .Modify(u => u.Totp, null).ExecuteAsync(); - if (updated.MatchedCount == 0) + var root = await DB.Default.Find().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync(); + var updated = root == default ? default : await DB.Default.Update().MatchID(root.ID).Modify(u => u.Totp, null).ExecuteAsync(); + if (updated == default || updated.MatchedCount == 0) { Console.Error.WriteLine($"no root user '{login}'"); return 1; } + await Log(services, AdminActions.RootResetTotp, AdminTargets.Root, root.ID, login); output.WriteLine($"{login}: two-factor sign-in is off"); return 0; } @@ -305,6 +318,7 @@ namespace PrivaPub.Infrastructure.Cli Console.Error.WriteLine($"'{login}' could not be deleted"); return 1; } + await Log(services, AdminActions.RootDelete, AdminTargets.Root, root.ID, login); output.WriteLine($"{login}: deleted{(personas.Count > 0 ? ", with " + string.Join(", ", personas) : string.Empty)}"); return 0; } diff --git a/PrivaPub/Infrastructure/Data/Indexes.cs b/PrivaPub/Infrastructure/Data/Indexes.cs index 31685fc..5f685e2 100644 --- a/PrivaPub/Infrastructure/Data/Indexes.cs +++ b/PrivaPub/Infrastructure/Data/Indexes.cs @@ -48,6 +48,17 @@ namespace PrivaPub.Infrastructure.Data await Plain(token, m => m.ScheduledStatusId); await Plain(token, m => m.ProfileOfAvatarId); await Plain(token, m => m.TrashedAt); + await Plain(token, m => m.ServerUse);//the server's own pictures (its thumbnail) + + // the administrators' announcements: each persona's dismissal and reactions, once each (new collections, R5) + await DB.Default.Index().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending) + .Option(o => o.Unique = true).CreateAsync(token); + await Plain(token, r => r.AvatarId); + await DB.Default.Index().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending) + .Key(r => r.Name, KeyType.Ascending).Option(o => o.Unique = true).CreateAsync(token); + await Plain(token, r => r.AvatarId); + // the staff's log, read newest first + await DB.Default.Index().Key(a => a.At, KeyType.Descending).CreateAsync(token); await Unique(a => a.ActorURI, Builders.Filter.Gt(a => a.ActorURI, ""), token); await Plain(token, a => a.PublicKeyId); diff --git a/PrivaPub/Models/AdminAction.cs b/PrivaPub/Models/AdminAction.cs new file mode 100644 index 0000000..3cabd8a --- /dev/null +++ b/PrivaPub/Models/AdminAction.cs @@ -0,0 +1,64 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models +{ + // One act of the server's staff, kept for the moderators' log (Domain/Moderation/ModerationLog.cs, completion program + // R5): who (an administrator's or moderator's root id, or "cli" for the command line), what, and on what. Never an IP + // address, and never a root next to its own personas: an entry about a persona drops the acting root when the persona + // is that root's, and an entry about a root names none of its personas. A backup holds the log and a restore keeps + // every entry made since (ProtectiveMerge). + public class AdminAction : Entity + { + public const string Cli = "cli"; + + public DateTime At { get; set; } = DateTime.UtcNow; + public string ByRootId { get; set; } + public string Action { get; set; }//AdminActions.* + public string TargetKind { get; set; }//AdminTargets.* + public string TargetId { get; set; } + public string TargetLabel { get; set; } + public string ReportId { get; set; } + public string Note { get; set; } + } + + public static class AdminActions + { + public const string DomainBlockInsert = "domain_block.insert"; + public const string DomainBlockDelete = "domain_block.delete"; + public const string BackupCreate = "backup.create"; + public const string BackupDelete = "backup.delete"; + public const string BackupRestore = "backup.restore"; + public const string SettingsUpdate = "settings.update"; + public const string SettingsThumbnail = "settings.thumbnail"; + public const string RuleInsert = "rule.insert"; + public const string RuleUpdate = "rule.update"; + public const string RuleDelete = "rule.delete"; + public const string AnnouncementInsert = "announcement.insert"; + public const string AnnouncementUpdate = "announcement.update"; + public const string AnnouncementDelete = "announcement.delete"; + public const string RelayInsert = "relay.insert"; + public const string RelayDelete = "relay.delete"; + public const string ReportResolve = "report.resolve"; + public const string RootPromote = "root.promote"; + public const string RootDemote = "root.demote"; + public const string RootCreate = "root.create"; + public const string RootDelete = "root.delete"; + public const string RootResetTotp = "root.reset_totp"; + public const string PersonaMove = "persona.move"; + } + + public static class AdminTargets + { + public const string Root = "root"; + public const string Persona = "persona"; + public const string Account = "account"; + public const string Domain = "domain"; + public const string Report = "report"; + public const string Settings = "settings"; + public const string Rule = "rule"; + public const string Announcement = "announcement"; + public const string Emoji = "emoji"; + public const string Relay = "relay"; + public const string Backup = "backup"; + } +} diff --git a/PrivaPub/Models/Federation/DomainBlock.cs b/PrivaPub/Models/Federation/DomainBlock.cs index ac86449..733b4e8 100644 --- a/PrivaPub/Models/Federation/DomainBlock.cs +++ b/PrivaPub/Models/Federation/DomainBlock.cs @@ -9,6 +9,10 @@ namespace PrivaPub.Models.Federation public bool RejectMedia { get; set; } public string PublicComment { get; set; } public string PrivateComment { get; set; } + // listed at /api/v1/instance/domain_blocks, which tells the blocked server too (off by default); Obfuscate hides part + // of its name there, as Mastodon does + public bool Published { get; set; } + public bool Obfuscate { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/PrivaPub/Models/Media/MediaAttachment.cs b/PrivaPub/Models/Media/MediaAttachment.cs index b5e9298..10b0f8b 100644 --- a/PrivaPub/Models/Media/MediaAttachment.cs +++ b/PrivaPub/Models/Media/MediaAttachment.cs @@ -24,6 +24,7 @@ namespace PrivaPub.Models.Media public DateTime? AttachedAt { get; set; } public string ScheduledStatusId { get; set; }//kept for a scheduled post until it is published or dropped public string ProfileOfAvatarId { get; set; }//a persona's avatar or header (Kind "avatar" or "header"), never a post's + public string ServerUse { get; set; }//the server's own picture ("thumbnail", ServerSettings.ThumbnailMediaId): held while it is set // set once nothing holds it any more (its post deleted, edited out, replaced, its root removed, never posted): its // files move out of what /media/files serves at once, and the janitor deletes them after a grace public DateTime? TrashedAt { get; set; } diff --git a/PrivaPub/Models/ServerSettings.cs b/PrivaPub/Models/ServerSettings.cs new file mode 100644 index 0000000..28a7d77 --- /dev/null +++ b/PrivaPub/Models/ServerSettings.cs @@ -0,0 +1,65 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models +{ + // The server's own settings (completion program R5), edited from the administrator's page: one document with the fixed + // id `settings`, read through IInstanceSettings (Domain/Instance/InstanceSettings.cs). While none exists, Defaults() is + // what PrivaPub served and sent before the settings existed, word for word, so nothing changes until an administrator + // saves. A backup holds it, and a restore keeps the live one (ProtectiveMerge). + public class ServerSettings : Entity + { + public const string Name = "settings"; + public const string DefaultTitle = "PrivaPub"; + public const string DefaultDescription = "A small ActivityPub server where one private login keeps several unlinkable public personas."; + public const string DefaultThumbnailPath = "/media/missing-header.png"; + public static readonly DateTime DefaultPrivacyPolicyUpdatedAt = new(2026, 10, 4, 0, 0, 0, DateTimeKind.Utc); + // the privacy policy PrivaPub served before it could be edited, as Markdown: it renders to the same paragraph + public const string DefaultPrivacyPolicy = "One private login owns several public personas, and nobody but this server's admin can tell they belong " + + "together. What a persona posts is shared as its visibility says; a located post never leaves this server. " + + "Uploads lose their metadata. Statistics name servers, never people: see /stargazing."; + + public string Title { get; set; } = DefaultTitle; + public string ShortDescription { get; set; } = DefaultDescription; + public string Description { get; set; } = DefaultDescription;//Markdown + public DateTime? DescriptionUpdatedAt { get; set; }//null: the description was never edited (it then answers today) + public string ContactEmail { get; set; } + public string ContactAccountId { get; set; }//a local persona the administrator chose: publicly tied to running the server + public string ThumbnailMediaId { get; set; }//its MediaAttachment (ServerUse "thumbnail") + public string ThumbnailUrl { get; set; }//null: /media/missing-header.png + public string PrivacyPolicy { get; set; } = DefaultPrivacyPolicy;//Markdown + public DateTime? PrivacyPolicyUpdatedAt { get; set; } = DefaultPrivacyPolicyUpdatedAt; + public string TermsOfService { get; set; }//Markdown; null: the server has none (the API answers 404) + public DateTime? TermsEffectiveAt { get; set; } + public List Rules { get; set; } = new();//soft-deleted ones stay, so old reports keep their meaning + public List Relays { get; set; } = new();//besides Federation:Relays + public ServerMediaLimits Media { get; set; } = new(); + public List StaffPicks { get; set; } = new();//account ids, for later + public bool PublishActivity { get; set; }//GET /api/v1/instance/activity; off by default + public DateTime? UpdatedAt { get; set; }//null: never saved + public string UpdatedBy { get; set; }//the administrator's root id, or "cli" + + public static ServerSettings Defaults() => new() { ID = Name }; + + /// The rules shown to everyone: those not deleted, in their order. + public IEnumerable ListedRules() => Rules.Where(r => r.DeletedAt == null).OrderBy(r => r.Position); + } + + public class ServerRule + { + public string Id { get; set; } = Guid.NewGuid().ToString("N"); + public string Text { get; set; } + public string Hint { get; set; } + public int Position { get; set; } + public DateTime? DeletedAt { get; set; } + } + + // what the administrator changed of Media:* (MediaOptions); null keeps the configured value. Applied by + // Domain/Instance/MediaLimitsFromSettings within hard caps. + public class ServerMediaLimits + { + public long? QuotaBytesPerRoot { get; set; } + public long? MaxImageBytes { get; set; } + public long? MaxVideoBytes { get; set; } + public int? MaxSeconds { get; set; } + } +} diff --git a/PrivaPub/Models/Social/Announcement.cs b/PrivaPub/Models/Social/Announcement.cs new file mode 100644 index 0000000..d96a337 --- /dev/null +++ b/PrivaPub/Models/Social/Announcement.cs @@ -0,0 +1,36 @@ +using MongoDB.Entities; + +namespace PrivaPub.Models.Social +{ + // An announcement of the server's administrators (Mastodon's /api/v1/announcements, completion program R5): written in + // Markdown on the administrator's page, shown while published and not ended. Never federated. + public class Announcement : Entity + { + public string Text { get; set; }//Markdown + public string Html { get; set; }//rendered and sanitized (ContentRenderer.Document) + public DateTime? StartsAt { get; set; }//what it is about, if it has a time (as Mastodon's) + public DateTime? EndsAt { get; set; }//once past, it is no longer shown + public bool AllDay { get; set; } + public bool IsPublished { get; set; } + public DateTime? PublishedAt { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; + } + + // a persona dismissed it (Mastodon's read), kept per persona and never told anyone + public class AnnouncementRead : Entity + { + public string AnnouncementId { get; set; } + public string AvatarId { get; set; } + public DateTime ReadAt { get; set; } = DateTime.UtcNow; + } + + // a persona's reaction to it (one unicode emoji), counted for everyone, the persona's own known only to it + public class AnnouncementReaction : Entity + { + public string AnnouncementId { get; set; } + public string AvatarId { get; set; } + public string Name { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + } +} diff --git a/PrivaPub/Models/Social/Relationships.cs b/PrivaPub/Models/Social/Relationships.cs index 5e24e49..a94302b 100644 --- a/PrivaPub/Models/Social/Relationships.cs +++ b/PrivaPub/Models/Social/Relationships.cs @@ -72,6 +72,7 @@ namespace PrivaPub.Models.Social public List PostIds { get; set; } = new(); public List ObjectURIs { get; set; } = new(); public string Category { get; set; } = "other"; + public List RuleIds { get; set; } = new();//the server's rules it says were broken (ServerSettings.Rules, deleted ones too) public string Comment { get; set; } public bool Forwarded { get; set; } public bool IsResolved { get; set; } diff --git a/PrivaPub/Program.cs b/PrivaPub/Program.cs index 1a6d81a..b16778f 100644 --- a/PrivaPub/Program.cs +++ b/PrivaPub/Program.cs @@ -59,6 +59,14 @@ try .AddScoped() .AddSingleton() .Configure(builder.Configuration.GetSection("Media")) + // the server's own settings (R5): read at start, saved from the administrator's page, read again every five + // minutes; the media limits it changes reach every IOptionsMonitor reader + .AddSingleton() + .AddHostedService() + .AddSingleton, PrivaPub.Domain.Instance.MediaLimitsFromSettings>() + .AddSingleton, PrivaPub.Domain.Instance.MediaLimitsChangeSource>() + .AddSingleton() + .AddSingleton() .AddSingleton() .AddSingleton() .AddHostedService() @@ -125,6 +133,7 @@ try { await DB.Default.MigrateAsync(); await Indexes.Create(); + await app.Services.GetRequiredService().Reload(CancellationToken.None); } catch (Exception ex) { diff --git a/PrivaPub/Services/Mailer.cs b/PrivaPub/Services/Mailer.cs index 2d34a40..4949a07 100644 --- a/PrivaPub/Services/Mailer.cs +++ b/PrivaPub/Services/Mailer.cs @@ -19,14 +19,19 @@ namespace PrivaPub.Services public class Mailer : IMailer { readonly IOptionsMonitor _app; + readonly Domain.Instance.IInstanceSettings _settings; - public Mailer(IOptionsMonitor app) => _app = app; + public Mailer(IOptionsMonitor app, Domain.Instance.IInstanceSettings settings) + { + _app = app; + _settings = settings; + } public async Task Send(string to, string subject, string body, CancellationToken token) { var email = _app.CurrentValue.EmailConfiguration; var message = new MimeMessage(); - message.From.Add(new MailboxAddress("PrivaPub", email.SmtpUsername)); + message.From.Add(new MailboxAddress(_settings.Current.Title, email.SmtpUsername));//the server's name, as its administrator set it message.To.Add(MailboxAddress.Parse(to)); message.Subject = subject; message.Body = new TextPart("plain") { Text = body }; diff --git a/PrivaPub/Services/RootRemoval.cs b/PrivaPub/Services/RootRemoval.cs index b7113c7..4e44453 100644 --- a/PrivaPub/Services/RootRemoval.cs +++ b/PrivaPub/Services/RootRemoval.cs @@ -69,6 +69,9 @@ namespace PrivaPub.Services await DB.Default.DeleteAsync(l => l.AvatarId == avatar.ID); await DB.Default.DeleteAsync(f => f.AvatarId == avatar.ID); await DB.Default.DeleteAsync(t => t.AvatarId == avatar.ID); + // what it did with the server's announcements, kept for it alone + await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); + await DB.Default.DeleteAsync(r => r.AvatarId == avatar.ID); // (the reader of feeds stops following what nobody reads any more at its next round) await DB.Default.DeleteAsync(s => s.AvatarId == avatar.ID); // nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and diff --git a/PrivaPub/Web/Pages/OAuth/Login.cshtml b/PrivaPub/Web/Pages/OAuth/Login.cshtml index 149bdf4..2356b9b 100644 --- a/PrivaPub/Web/Pages/OAuth/Login.cshtml +++ b/PrivaPub/Web/Pages/OAuth/Login.cshtml @@ -1,9 +1,10 @@ @page "/oauth/login" @model PrivaPub.Web.Pages.OAuth.LoginModel +@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings @{ ViewData["Title"] = "Sign in"; } -

Sign in to PrivaPub

+

Sign in to @InstanceSettings.Current.Title

Sign in with your private login. You will then choose which persona the application acts as; the application never sees the login.

diff --git a/PrivaPub/Web/Pages/Shared/_Layout.cshtml b/PrivaPub/Web/Pages/Shared/_Layout.cshtml index 9560efe..f836aa5 100644 --- a/PrivaPub/Web/Pages/Shared/_Layout.cshtml +++ b/PrivaPub/Web/Pages/Shared/_Layout.cshtml @@ -1,3 +1,4 @@ +@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings @@ -32,7 +33,7 @@
@RenderBody() -
PrivaPub
+
@InstanceSettings.Current.Title
diff --git a/tools/pasture/scenarios/instance.sh b/tools/pasture/scenarios/instance.sh new file mode 100644 index 0000000..0540f35 --- /dev/null +++ b/tools/pasture/scenarios/instance.sh @@ -0,0 +1,226 @@ +# The server's own settings (completion program R5), edited as the administrator through /clientapi/admin: what the +# instance API (v1 and v2), NodeInfo, the instance actor, the OAuth sign-in page and the instance's rules, description, +# privacy policy and terms say; published domain blocks (and an unpublished one that stays out); a report naming rules +# reaching the moderators with their texts; an announcement listed, dismissed by one persona, reacted to and streamed; +# media limits from the settings (a 1 KB quota refusing an upload, then reset); the thumbnail; a relay added through the +# settings and removed (the configured ones can't be); the weekly activity once published; the staff's log listing all +# of it, without an address. It puts every setting back as it found it at the end, so other scenarios see the usual +# instance. Needs the relay and aoderelay peers (batch A); mastodon is not used. +. "$here/peers/relay.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" "$@"; } +# settings_form : the settings form made from a settings view, then changed +settings_form() { python3 -c " +import json,sys +v=json.loads(sys.argv[1]) +f={k: v.get(k) for k in ['title','shortDescription','description','contactEmail','contactAccountId','privacyPolicy','termsOfService','termsEffectiveAt','publishActivity']} +f['rules']=[{'id': r['id'], 'text': r['text'], 'hint': r.get('hint')} for r in v.get('rules', []) if not r.get('isDeleted')] +m=v.get('media') or {} +f['media']={k: m.get(k) for k in ['quotaBytesPerRoot','maxImageBytes','maxVideoBytes','maxSeconds']} +$2 +print(json.dumps(f))" "$1"; } +relay_state() { podman exec pasture-mongo mongosh --quiet PrivaPub --eval 'var s=db.RelaySubscription.findOne({Configured:"'"$1"'"}); print(s ? s.State : "none")'; } +# noise_png : a PNG of random pixels, which compresses to more than a kilobyte +noise_png() { python3 -c " +import os,struct,zlib +w=h=$2 +raw=b''.join(b'\x00'+os.urandom(3*w) for _ in range(h)) +png=b'\x89PNG\r\n\x1a\n'+b''.join(struct.pack('>I',len(c))+t+c+struct.pack('>I',zlib.crc32(t+c)&0xffffffff) for t,c in [(b'IHDR',struct.pack('>IIBBBBB',w,h,8,2,0,0,0)),(b'IDAT',zlib.compress(raw)),(b'IEND',b'')]) +open('$1','wb').write(png)"; } + +echo "instance" +run=$(date +%s) +privapub_root >/dev/null # the pasture's root, made if this runs first +podman exec -w /app pasture-privapub /app/PrivaPub admin promote "$ROOT_USER" >/dev/null 2>&1 || true +ADMIN=$(curl -s -X POST "$P/clientapi/user/login" -H 'Content-Type: application/json' -d "{\"userName\":\"$ROOT_USER\",\"password\":\"$ROOT_PASS\"}" | j "print(d['token'])") +[ -n "$ADMIN" ] && ok "the pasture's root signs in as the administrator" || { ko "no administrator's login"; return 1; } +AT=$(privapub_token alice_instance); BT=$(privapub_token bob_instance) +AH="Authorization: Bearer $AT"; BH="Authorization: Bearer $BT" +alice_id=$(curl -s -H "$AH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") +bob_id=$(curl -s -H "$BH" "$P/api/v1/accounts/verify_credentials" | j "print(d['id'])") +[ -n "$alice_id" ] && [ -n "$bob_id" ] && ok "two personas for the scenario" || { ko "no personas for the scenario"; return 1; } +before=$(api "$ADMIN" GET /clientapi/admin/settings) +[ -n "$(echo "$before" | j "print(d['title'])")" ] && ok "the administrator reads the settings" || { ko "the settings could not be read"; return 1; } +[ "$(code_of "$AT" GET /clientapi/admin/settings)" != 200 ] && ok "a persona's token reads no settings" || ko "a persona's token read the settings" + +echo " the server described" +title="Pasture Commons $run" +form=$(settings_form "$before" " +f.update(title='$title', shortDescription='A test field for $run.', description='Welcome to **the pasture** $run.', + contactEmail='staff@privapub.test', contactAccountId='$alice_id', privacyPolicy='We keep *little* about you ($run).', + termsOfService='Be kind ($run).', termsEffectiveAt='2026-10-01T00:00:00Z') +f['rules']=[{'text': 'No spam $run', 'hint': 'Ads included'}, {'text': 'Be kind $run'}]") +saved=$(api "$ADMIN" POST /clientapi/admin/settings -d "$form") +r1=$(echo "$saved" | j "print([r['id'] for r in d['rules'] if r['text'] == 'No spam $run'][0])") +r2=$(echo "$saved" | j "print([r['id'] for r in d['rules'] if r['text'] == 'Be kind $run'][0])") +[ -n "$r1" ] && [ -n "$r2" ] && [ "$(echo "$saved" | j "print(d['title'], d['contactAccount']['userName'])")" = "$title alice_instance" ] \ + && ok "the settings are saved, the rules given their ids" || ko "the settings were not saved: $(echo "$saved" | head -c 300)" +v1=$(curl -s "$P/api/v1/instance") +[ "$(echo "$v1" | j "print(d['title'] == '$title' and d['short_description'] == 'A test field for $run.' and d['email'] == 'staff@privapub.test' and d['contact_account']['id'] == '$alice_id' and [r['text'] for r in d['rules']] == ['No spam $run', 'Be kind $run'])")" = True ] \ + && ok "/api/v1/instance says so" || ko "/api/v1/instance: $(echo "$v1" | j "print(d['title'], d['email'], d['rules'])")" +v2=$(curl -s "$P/api/v2/instance") +[ "$(echo "$v2" | j "print(d['title'] == '$title' and d['description'] == 'A test field for $run.' and d['contact']['email'] == 'staff@privapub.test' and d['contact']['account']['username'] == 'alice_instance' and [r['id'] for r in d['rules']] == ['$r1', '$r2'] and d['rules'][0]['hint'] == 'Ads included')")" = True ] \ + && ok "/api/v2/instance says so" || ko "/api/v2/instance: $(echo "$v2" | j "print(d['title'], d['contact'], d['rules'])")" +[ "$(curl -s "$P/nodeinfo/2.1" | j "print(d['metadata']['nodeName'] == '$title' and d['metadata']['nodeDescription'] == 'A test field for $run.')")" = True ] \ + && ok "NodeInfo names the server and describes it" || ko "NodeInfo: $(curl -s "$P/nodeinfo/2.1" | j "print(d['metadata'])")" +actor=$(pfetch -H 'Accept: application/activity+json' https://privapub.test/peasants/privapub) +[ "$(echo "$actor" | j "print(d['name'] == '$title' and 'A test field for $run.' in d['summary'])")" = True ] \ + && ok "the instance actor carries the name and the short description" || ko "the instance actor: $(echo "$actor" | j "print(d['name'], d['summary'])")" +[ "$(curl -s "$P/api/v1/instance/rules" | j "print([(r['id'], r['text']) for r in d] == [('$r1', 'No spam $run'), ('$r2', 'Be kind $run')])")" = True ] \ + && ok "/instance/rules lists them in order" || ko "/instance/rules: $(curl -s "$P/api/v1/instance/rules")" +ext=$(curl -s "$P/api/v1/instance/extended_description") +[ "$(echo "$ext" | j "print('the pasture' in d['content'] and 'little' in d['content'] and d['updated_at'][:10] == '$(date -u +%F)')")" = True ] \ + && ok "the privacy policy is rendered and dated" || ko "the privacy policy: $(curl -s "$P/api/v1/instance/privacy_policy")" +tos=$(curl -s "$P/api/v1/instance/terms_of_service") +[ "$(echo "$tos" | j "print(d['effective_date'] == '2026-10-01' and d['effective'] is True and 'Be kind ($run)' in d['content'] and d['succeeded_by'] is None)")" = True ] \ + && ok "the terms of service answer in Mastodon 4.4's shape" || ko "the terms of service: $tos" +[ "$(curl -s -o /dev/null -w '%{http_code}' "$P/api/v1/instance/terms_of_service/2026-10-01")" = 200 ] \ + && [ "$(curl -s -o /dev/null -w '%{http_code}' "$P/api/v1/instance/terms_of_service/2020-01-01")" = 404 ] \ + && ok "the terms answer for their date and no other" || ko "the terms by date answer wrongly" +curl -s "$P/oauth/login" | grep -q "Sign in to $title" && ok "the OAuth sign-in page names the server" || ko "the OAuth sign-in page does not name the server" +[ "$(code_of "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$saved" "f['contactAccountId']='0123456789abcdef01234567'")")" = 422 ] \ + && ok "a contact that is no persona here is refused" || ko "a contact that is no persona was taken" +[ "$(code_of "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$saved" "f['title']='x'*65")")" = 400 ] \ + && ok "a title over 64 characters is refused" || ko "a title over 64 characters was taken" +edited=$(api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$saved" "f['rules']=[{'id': '$r1', 'text': 'No spam at all $run'}, {'text': 'Third $run'}]")") +[ "$(echo "$edited" | j "print([(r['id'] == '$r1', r['text'], r['isDeleted']) for r in d['rules']] == [(True, 'No spam at all $run', False), (False, 'Third $run', False), (False, 'Be kind $run', True)])")" = True ] \ + && ok "an edited rule keeps its id, a new one gets one, one left out is kept deleted" || ko "the rules after an edit: $(echo "$edited" | j "print(d['rules'])")" +[ "$(curl -s "$P/api/v1/instance/rules" | j "print('$r2' not in [r['id'] for r in d] and len(d) == 2)")" = True ] \ + && ok "a deleted rule is no longer listed" || ko "a deleted rule is still listed" + +echo " public domain blocks" +shown="blocked-$run.example"; quiet="quiet-$run.example" +api "$ADMIN" POST /clientapi/admin/domainblocks/insert -d "{\"domain\":\"$shown\",\"suspend\":true,\"publicComment\":\"spam $run\",\"published\":true,\"obfuscate\":true}" >/dev/null +api "$ADMIN" POST /clientapi/admin/domainblocks/insert -d "{\"domain\":\"$quiet\",\"suspend\":false,\"publicComment\":\"quiet $run\"}" >/dev/null +obfuscated=$(python3 -c " +d='$shown'; v=len(d)//4 +print(''.join('*' if v < i < len(d)-v and c != '.' else c for i,c in enumerate(d)))") +digest=$(printf '%s' "$shown" | sha256sum | cut -d' ' -f1) +blocks=$(curl -s "$P/api/v1/instance/domain_blocks") +[ "$(echo "$blocks" | j "print([b for b in d if b['digest'] == '$digest'] == [{'domain': '$obfuscated', 'digest': '$digest', 'severity': 'suspend', 'comment': 'spam $run'}])")" = True ] \ + && ok "a published block is listed, its name obfuscated, with its digest" || ko "the published block: $blocks" +[ "$(echo "$blocks" | j "print(not any('quiet $run' == b['comment'] for b in d))")" = True ] && ok "an unpublished block stays out" || ko "an unpublished block is listed" +api "$ADMIN" POST "/clientapi/admin/domainblocks/delete?domain=$shown" >/dev/null +api "$ADMIN" POST "/clientapi/admin/domainblocks/delete?domain=$quiet" >/dev/null +[ "$(curl -s "$P/api/v1/instance/domain_blocks" | j "print(any(b['digest'] == '$digest' for b in d))")" = False ] && ok "and leaves the list when deleted" || ko "a deleted block is still listed" + +echo " rules in reports" +report=$(curl -s -X POST -H "$AH" "$P/api/v1/reports" -d "account_id=$bob_id" -d "rule_ids[]=$r1" -d "rule_ids[]=$r2" -d "rule_ids[]=no-such-rule" --data-urlencode "comment=rules $run") +report_id=$(echo "$report" | j "print(d['id'])") +[ "$(echo "$report" | j "print(sorted(d['rule_ids']) == sorted(['$r1', '$r2']) and d['category'] == 'violation')")" = True ] \ + && ok "a report keeps the rules that exist (a deleted one too) and becomes a violation" || ko "the report: $report" +[ "$(api "$ADMIN" GET "/clientapi/moderator/reports?resolved=false" | j "print([sorted(r['rules']) for r in d if r['id'] == '$report_id'] == [sorted(['No spam at all $run', 'Be kind $run'])])")" = True ] \ + && ok "the moderators read the rules' texts" || ko "the moderators do not see the rules: $(api "$ADMIN" GET "/clientapi/moderator/reports?resolved=false" | head -c 400)" +[ "$(code_of "$ADMIN" POST "/clientapi/moderator/reports/$report_id/resolve")" = 200 ] && ok "and resolve it" || ko "the report could not be resolved" + +echo " announcements" +curl -sN --max-time 12 -H "$BH" "$P/api/v1/streaming/user" > "$work/sse" 2>/dev/null & +sse=$! +sleep 1 +announcement=$(api "$ADMIN" POST /clientapi/admin/announcements/insert -d "{\"text\":\"The pasture is *open* $run\",\"isPublished\":true}") +aid=$(echo "$announcement" | j "print(d['id'])") +[ -n "$aid" ] && [ "$(echo "$announcement" | j "print(d['isShown'] and 'open' in d['html'])")" = True ] && ok "the administrator publishes an announcement" || ko "no announcement: $announcement" +[ "$(curl -s -H "$AH" "$P/api/v1/announcements" | j "print([(a['read'], 'open' in a['content'], a['reactions']) for a in d if a['id'] == '$aid'] == [(False, True, [])])")" = True ] \ + && ok "a persona reads it, unread" || ko "the persona's announcements: $(curl -s -H "$AH" "$P/api/v1/announcements")" +[ "$(curl -s "$P/api/v1/announcements" | j "print([('read' in a) for a in d if a['id'] == '$aid'] == [False])")" = True ] \ + && ok "an anonymous reader gets it without a read state" || ko "the anonymous announcements: $(curl -s "$P/api/v1/announcements")" +[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$AH" "$P/api/v1/announcements/$aid/dismiss")" = 200 ] \ + && [ "$(curl -s -H "$AH" "$P/api/v1/announcements" | j "print([a['read'] for a in d if a['id'] == '$aid'])")" = "[True]" ] \ + && [ "$(curl -s -H "$BH" "$P/api/v1/announcements" | j "print([a['read'] for a in d if a['id'] == '$aid'])")" = "[False]" ] \ + && ok "dismissed by one persona, still unread for the other" || ko "dismissing is not per persona" +thumbs=$(python3 -c "import urllib.parse; print(urllib.parse.quote('👍'))") +curl -s -o /dev/null -X PUT -H "$AH" "$P/api/v1/announcements/$aid/reactions/$thumbs" +[ "$(curl -s -H "$AH" "$P/api/v1/announcements" | j "print([a['reactions'] for a in d if a['id'] == '$aid'] == [[{'name': '👍', 'count': 1, 'me': True}]])")" = True ] \ + && [ "$(curl -s -H "$BH" "$P/api/v1/announcements" | j "print([a['reactions'][0]['me'] for a in d if a['id'] == '$aid'])")" = "[False]" ] \ + && ok "a reaction is counted, and known as its own to the persona alone" || ko "the reaction: $(curl -s -H "$AH" "$P/api/v1/announcements")" +[ "$(curl -s -o /dev/null -w '%{http_code}' -X PUT -H "$AH" "$P/api/v1/announcements/$aid/reactions/abc")" = 422 ] \ + && ok "a reaction that is no emoji is refused (422)" || ko "a reaction that is no emoji was taken" +api "$ADMIN" POST /clientapi/admin/announcements/update -d "{\"id\":\"$aid\",\"text\":\"The pasture is *open*, edited $run\",\"isPublished\":true}" >/dev/null +curl -s -o /dev/null -X DELETE -H "$AH" "$P/api/v1/announcements/$aid/reactions/$thumbs" +[ "$(curl -s -H "$AH" "$P/api/v1/announcements" | j "print([a['reactions'] for a in d if a['id'] == '$aid'])")" = "[[]]" ] && ok "and taken back" || ko "the reaction stayed" +api "$ADMIN" POST "/clientapi/admin/announcements/delete?id=$aid" >/dev/null +[ "$(curl -s -H "$AH" "$P/api/v1/announcements" | j "print(any(a['id'] == '$aid' for a in d))")" = False ] && ok "a deleted announcement is gone" || ko "a deleted announcement is still listed" +wait "$sse" 2>/dev/null +grep -q '^event: announcement$' "$work/sse" && grep -q "edited $run" "$work/sse" && ok "the user stream tells it made and edited" || ko "no announcement event on the user stream" +grep -q '^event: announcement.reaction$' "$work/sse" && ok "the user stream tells its reactions" || ko "no announcement.reaction event" +grep -A1 '^event: announcement.delete$' "$work/sse" | grep -q "data: $aid" && ok "the user stream tells it deleted" || ko "no announcement.delete event" + +echo " media limits" +noise_png "$work/noise.png" 64 +limited=$(api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$edited" "f['media']['maxImageBytes']=2097152")") +[ "$(curl -s "$P/api/v2/instance" | j "print(d['configuration']['media_attachments']['image_size_limit'])")" = 2097152 ] \ + && ok "an image limit from the settings is what the instance API advertises" || ko "the image limit did not reach the instance API" +quota=$(api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$limited" "f['media']['quotaBytesPerRoot']=1024")") +[ "$(echo "$quota" | j "print(d['media']['effectiveQuotaBytesPerRoot'])")" = 1024 ] && ok "a quota of 1 KB takes effect" || ko "the quota: $(echo "$quota" | j "print(d['media'])")" +[ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$AH" "$P/api/v2/media" -F "file=@$work/noise.png;type=image/png")" = 422 ] \ + && ok "an upload over it is refused (422)" || ko "an upload over the quota was taken" +reset=$(api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$quota" "f['media']={}")") +[ "$(echo "$reset" | j "print(d['media']['effectiveQuotaBytesPerRoot'] == d['media']['configuredQuotaBytesPerRoot'] and d['media']['effectiveMaxImageBytes'] == d['media']['configuredMaxImageBytes'])")" = True ] \ + && [ "$(curl -s -o /dev/null -w '%{http_code}' -X POST -H "$AH" "$P/api/v2/media" -F "file=@$work/noise.png;type=image/png")" = 200 ] \ + && ok "reset, the configured limits are back and the upload goes through" || ko "the limits did not reset" + +echo " the thumbnail" +noise_png "$work/thumb.png" 96 +thumb=$(curl -s -X POST -H "Authorization: Bearer $ADMIN" -F "file=@$work/thumb.png;type=image/png" "$P/clientapi/admin/settings/thumbnail") +turl=$(echo "$thumb" | j "print(d['thumbnailUrl'])") +[ "$(echo "$thumb" | j "print(d['thumbnailIsDefault'])")" = False ] && [ "$(curl -s "$P/api/v1/instance" | j "print(d['thumbnail'])")" = "$turl" ] \ + && [ "$(curl -s "$P/api/v2/instance" | j "print(d['thumbnail']['url'])")" = "$turl" ] \ + && ok "an uploaded thumbnail is the instance's" || ko "the thumbnail: $(echo "$thumb" | head -c 300)" +[ "$(pfetch -o /dev/null -w '%{http_code} %{content_type}' "$turl")" = "200 image/jpeg" ] && ok "served, re-encoded as a JPEG" || ko "the thumbnail is not served" +[ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.MediaAttachment.countDocuments({ServerUse: 'thumbnail', TrashedAt: null}))")" = 1 ] \ + && ok "its row is held by the server" || ko "the thumbnail's row is not held" +[ "$(api "$ADMIN" DELETE /clientapi/admin/settings/thumbnail | j "print(d['thumbnailIsDefault'])")" = True ] \ + && [ "$(curl -s "$P/api/v1/instance" | j "print(d['thumbnail'].endswith('/media/missing-header.png'))")" = True ] \ + && [ "$(podman exec pasture-mongo mongosh --quiet PrivaPub --eval "print(db.MediaAttachment.countDocuments({ServerUse: 'thumbnail', TrashedAt: null}))")" = 0 ] \ + && ok "removed, the default picture is back and the old one trashed" || ko "the thumbnail could not be removed" + +echo " relays from the settings" +relays=$(api "$ADMIN" GET /clientapi/admin/relays) +[ "$(echo "$relays" | j "print(sorted(r['address'] for r in d if r['source'] == 'configuration') == ['https://aoderelay.test/actor', 'https://relay.test/actor'])")" = True ] \ + && ok "the configured relays are listed as the configuration's" || ko "the relays: $relays" +[ "$(code_of "$ADMIN" POST "/clientapi/admin/relays/delete?address=https://relay.test/actor")" = 409 ] \ + && ok "a configured relay can't be removed from the page (409)" || ko "a configured relay was removed from the page" +extra="https://relay.test/inbox" +[ "$(code_of "$ADMIN" POST /clientapi/admin/relays/insert -d "{\"address\":\"$extra\"}")" = 202 ] && ok "a relay is added through the settings" || ko "the relay was not added" +until_true 30 '[ "$(relay_state "$extra")" = 1 ]' && ok "and subscribed to, accepted" \ + || { [ "$(relay_state "$extra")" = 0 ] && ok "and subscribed to (requested; the relay answers a second subscription of the server's slowly)" || ko "the relay added was never asked ($(relay_state "$extra"))"; } +[ "$(api "$ADMIN" GET /clientapi/admin/relays | j "print([r['source'] for r in d if r['address'] == '$extra'])")" = "['settings']" ] \ + && ok "listed as the settings'" || ko "the added relay is not listed as the settings'" +[ "$(code_of "$ADMIN" POST "/clientapi/admin/relays/delete?address=$extra")" = 202 ] && until_true 15 '[ "$(relay_state "$extra")" = none ]' \ + && ok "removed, its subscription goes" || ko "the added relay could not be removed" +sleep 3 +[ "$(relay_state https://relay.test/actor)" = 1 ] && relay_subscribers | grep -qx privapub.test \ + && ok "and the relay's configured subscription stays (no Undo for an actor still named)" || ko "removing the added address ended the configured subscription" + +echo " activity" +[ "$(curl -s "$P/api/v1/instance/activity")" = "[]" ] && ok "the weekly activity is not published by default" || ko "the activity was published unasked" +curl -s -o /dev/null -X POST -H "$AH" "$P/api/v1/statuses" -d "status=a post this week $run&visibility=public" +api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$reset" "f['publishActivity']=True")" >/dev/null +[ "$(curl -s "$P/api/v1/instance/activity" | j "print(len(d) == 12 and int(d[0]['statuses']) >= 1 and d[0]['logins'] == '0' and d[0]['registrations'].isdigit() and int(d[0]['week']) > int(d[1]['week']))")" = True ] \ + && ok "published, twelve weeks, this one first, counting its posts" || ko "the activity: $(curl -s "$P/api/v1/instance/activity" | head -c 300)" + +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.promote','settings.update','rule.insert','rule.update','rule.delete','domain_block.insert','domain_block.delete','report.resolve','announcement.insert','announcement.update','announcement.delete','settings.thumbnail','relay.insert','relay.delete'} - seen)))") +[ -z "$missing" ] && [ -n "$log" ] && ok "it lists every act of the scenario" || ko "the log misses: $missing" +[ "$(echo "$log" | j "print(all(i.get('by') in ('$ROOT_USER', 'cli') for i in d['items']))")" = True ] \ + && [ "$(echo "$log" | j "print(any(i['action'] == 'root.promote' and i.get('by') == 'cli' for i in d['items']))")" = True ] \ + && ok "each by the administrator's login, or the command line's" || ko "the log names someone else" +echo "$log" | grep -qE '127\.0\.0\.1|11\.42\.|::1' && ko "the log holds an address" || ok "and holds no address" +page=$(api "$ADMIN" GET "/clientapi/moderator/log?limit=2") +next=$(echo "$page" | j "print(d['next'])") +first=$(echo "$page" | j "print(' '.join(i['id'] for i in d['items']))") +[ -n "$next" ] && [ "$(api "$ADMIN" GET "/clientapi/moderator/log?limit=2&before=$next" | j "print(len(d['items']) == 2 and not set(i['id'] for i in d['items']) & set('$first'.split()) and all(i['id'] < '$next' for i in d['items']))")" = True ] \ + && ok "paged newest first" || ko "the log does not page" + +echo " back to the defaults" +restored=$(api "$ADMIN" POST /clientapi/admin/settings -d "$(settings_form "$before" "pass")") +[ "$(curl -s "$P/api/v1/instance" | j "print(d['title'] == '$(echo "$before" | j "print(d['title'])")' and d['rules'] == [] and d['contact_account'] is None)")" = True ] \ + && [ "$(curl -s -o /dev/null -w '%{http_code}' "$P/api/v1/instance/terms_of_service")" = 404 ] \ + && [ "$(curl -s "$P/api/v1/instance/activity")" = "[]" ] \ + && [ "$(curl -s "$P/nodeinfo/2.1" | j "print(d['metadata']['nodeName'])")" = "$(echo "$before" | j "print(d['title'])")" ] \ + && ok "every setting is as the scenario found it" || ko "the settings were not put back: $(echo "$restored" | head -c 300)" diff --git a/tools/pasture/sweep.sh b/tools/pasture/sweep.sh index 784a911..f83d1b9 100755 --- a/tools/pasture/sweep.sh +++ b/tools/pasture/sweep.sh @@ -7,7 +7,8 @@ # run.sh as they are. Each batch's output is kept in out/sweep-.log. # usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order) # A gts mastodon misskey sharkey akkoma, with relay, aoderelay and mailpit; scenarios: those five, threads followsync -# pins moves relay (relay's covers both relays) accounts (mailpit's) +# pins moves relay (relay's covers both relays) accounts (mailpit's) instance (the server's own settings, last: it +# changes them all and puts them back) # B lemmy lemmy19 piefed mbin nodebb # C peertube pixelfed vernissage wordpress ghost writefreely owncast castopod funkwhale # D friendica mobilizon gancio bookwyrm smithereen mitra snac ktistec forte hubzilla @@ -27,7 +28,7 @@ batch_peers() { } batch_scenarios() { case "$1" in - A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts" ;; + A) echo "gts mastodon misskey sharkey akkoma threads followsync pins moves relay accounts instance" ;; *) batch_peers "$1" ;; esac }