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 }