R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
Build / Build (push) Successful in 1m3s

A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media
overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the
web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits.
/clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the
staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its
personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published,
Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids.
Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones.

Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 04:02:47 +02:00
1 parent f67e2ae35e
commit 70d1ffe9a2
46 files changed
+2223 -107

No files matched your search

+101 -9
View File
@@ -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<T> 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 <root> # 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<MediaOptions>`) 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<MediaOptions>` 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
+11 -6
View File
@@ -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 <host>"), 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
@@ -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; }
}
}
@@ -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<ViewAdminAction> 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; }
}
}
@@ -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<ViewAnnouncementReaction> 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;
}
}
@@ -11,6 +11,8 @@ namespace PrivaPub.ClientModels.Admin
public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; }
public List<string> RuleIds { get; set; } = [];//the server's rules it says were broken
public List<string> 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; }
@@ -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<ViewServerRule> Rules { get; set; } = [];//deleted ones too (IsDeleted), as old reports name them
public List<ViewRelay> 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<ServerRuleForm> 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
}
}
@@ -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<IActionResult> List(CancellationToken token) =>
Json(await Map(await _announcements.Shown(token), MyId, token));
[HttpPost("/api/v1/announcements/{id}/dismiss"), Scope("write:accounts")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> Unreact(string id, string name, CancellationToken token) => Reaction(id, name, add: false, token);
async Task<IActionResult> 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);
}
/// <summary>Mastodon's Announcement entities, as the viewer (a persona, or nobody) sees them.</summary>
public static async Task<List<Dictionary<string, object>>> Map(IReadOnlyList<Announcement> announcements, string viewerId, CancellationToken token)
{
var ids = announcements.Select(a => a.ID).ToList();
if (ids.Count == 0)
return new List<Dictionary<string, object>>();
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => ids.Contains(r.AnnouncementId)).ExecuteAsync(token);
var read = viewerId == default
? new HashSet<string>()
: (await DB.Default.Find<AnnouncementRead>().Match(r => r.AvatarId == viewerId && ids.Contains(r.AnnouncementId)).ExecuteAsync(token))
.Select(r => r.AnnouncementId).ToHashSet();
return announcements.Select(a =>
{
var entity = new Dictionary<string, object>
{
["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<object>(),
["statuses"] = Array.Empty<object>(),
["tags"] = Array.Empty<object>(),
["emojis"] = Array.Empty<object>(),
["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<string, object> Reaction(string name, int count, bool? me)
{
var reaction = new Dictionary<string, object> { ["name"] = name, ["count"] = count };
if (me is { } mine)
reaction["me"] = mine;
return reaction;
}
/// <summary>The `announcement.reaction` event's payload: the emoji, how many chose it now, and on which.</summary>
public static async Task<Dictionary<string, object>> ReactionEvent(string announcementId, string name, CancellationToken token) => new()
{
["name"] = name,
["count"] = await DB.Default.CountAsync<AnnouncementReaction>(r => r.AnnouncementId == announcementId && r.Name == name, token),
["announcement_id"] = announcementId
};
}
}
@@ -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<RegistrationOptions> _registrations;
readonly IInstanceSettings _settings;
readonly MastodonMapper _mapper;
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> registrations)
public InstanceController(ILocalActorService localActors, IOptionsMonitor<RegistrationOptions> 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<object> ContactAccount(ServerSettings settings, CancellationToken token)
{
var id = settings.ContactAccountId;
if (string.IsNullOrEmpty(id))
return default;
var avatar = await DB.Default.Find<Avatar>().Match(a => a.ID == id && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
if (avatar == default || (await Counted.BarredPersonas(token)).Contains(id))
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<string> LanguageCodes(IServiceProvider services)
{
@@ -75,27 +115,28 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/instance"), AllowAnonymous]
public async Task<IActionResult> 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<object>()
contact_account = await ContactAccount(settings, token),
rules = Rules(settings)
});
}
@@ -109,13 +150,13 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> 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<object>()
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<string>());
// 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<object>());
public async Task<IActionResult> Activity(CancellationToken token)
{
if (!Settings.PublishActivity)
return Json(Array.Empty<object>());
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<object>());
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 = $"<p>{Description}</p>" });
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<IActionResult> DomainBlocks(CancellationToken token) =>
Json((await DB.Default.Find<DomainBlock>().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);
}
}
}
@@ -127,7 +127,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
Host = host,
Software = "privapub",
Version = BuildInfo.Ref,
NodeName = "PrivaPub",
NodeName = HttpContext.RequestServices.GetRequiredService<Domain.Instance.IInstanceSettings>().Current.Title,
Protocols = new() { "activitypub" },
OpenRegistrations = _registrations.CurrentValue.IsOpen,
DescribedAt = MastodonJson.Time(DateTime.UtcNow),
@@ -21,7 +21,7 @@ namespace PrivaPub.Api.Mastodon.Controllers
public async Task<IActionResult> 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<string>(),
rule_ids = report.RuleIds,
target_account = await _mapper.Account(report.TargetAccountId, token)
});
}
@@ -143,9 +143,6 @@ namespace PrivaPub.Api.Mastodon.Controllers
[HttpGet("/api/v1/custom_emojis"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult CustomEmojis() => Json(Array.Empty<object>());
[HttpGet("/api/v1/announcements"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult Announcements() => Json(Array.Empty<object>());
[HttpGet("/api/v1/suggestions"), Scope("read")]
public IActionResult SuggestionsV1() => Json(Array.Empty<object>());
@@ -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<object>());
[HttpGet("/api/v1/instance/privacy_policy"), Microsoft.AspNetCore.Authorization.AllowAnonymous]
public IActionResult PrivacyPolicy() => Json(new
{
updated_at = "2026-10-04T00:00:00.000Z",
content = "<p>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.</p>"
});
}
}
@@ -59,8 +59,16 @@ namespace PrivaPub.Api.Mastodon.Controllers
// is not the persona's to see
async Task<string> 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<Domain.Social.IAnnouncements>().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);
@@ -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<GenericRes> localizer,
ILogger<BackupController> logger) : ControllerBase
ILogger<BackupController> logger, IModerationLog log) : ControllerBase
{
[HttpGet, Route("")]
public async Task<IActionResult> 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<IActionResult> 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();
}
@@ -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<GenericRes> localizer, Domain.Media.IMediaProxy proxy)
public DomainBlockController(IDomainBlocks domainBlocks, IStringLocalizer<GenericRes> 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<IActionResult> Delete([FromQuery] string domain, CancellationToken token)
{
domain = DomainBlocks.Normalise(domain);
await DB.Default.DeleteAsync<DomainBlock>(b => b.Domain == domain);
var deleted = await DB.Default.DeleteAsync<DomainBlock>(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
};
}
@@ -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<InstanceAdminController> _logger;
public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer<GenericRes> localizer, ILocalActorService localActors,
ILogger<InstanceAdminController> logger)
{
_settings = settings;
_log = log;
_localizer = localizer;
_localActors = localActors;
_logger = logger;
}
// the token says administrator; the database has the last word
async Task<RootUser> Administrator(CancellationToken token)
{
var root = await DB.Default.Find<RootUser>().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<IActionResult> 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<IActionResult> 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<string>();
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<string> get, Action<string> 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<string>();
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<IActionResult> 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<IActionResult> 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<bool> IsContactable(string avatarId, CancellationToken token) =>
await DB.Default.Find<Avatar>().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<ViewServerSettings> View(ServerSettings settings, CancellationToken token)
{
var services = HttpContext.RequestServices;
var configured = services.GetRequiredService<IConfiguration>().GetSection("Media").Get<MediaOptions>() ?? new MediaOptions();
var effective = services.GetRequiredService<IOptionsMonitor<MediaOptions>>().CurrentValue;
var contact = settings.ContactAccountId == default ? default
: await DB.Default.Find<Avatar>().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<RootUser>().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<IOptionsMonitor<RegistrationOptions>>().CurrentValue.Mode.ToString(),
PublishActivity = settings.PublishActivity,
UpdatedAt = settings.UpdatedAt,
UpdatedBy = updatedBy
};
}
#endregion
#region announcements
[HttpGet, Route("/clientapi/admin/announcements/list")]
public async Task<IActionResult> Announcements([FromServices] IAnnouncements announcements, CancellationToken token)
{
if (await Administrator(token) == default)
return Forbid();
var views = new List<ViewAnnouncement>();
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ViewAnnouncement> View(Announcement announcement, CancellationToken token)
{
var reactions = await DB.Default.Find<AnnouncementReaction>().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<AnnouncementRead>(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<IActionResult> 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<IActionResult> 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<IActionResult> 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<List<ViewRelay>> RelayViews(ServerSettings settings, CancellationToken token)
{
var configured = HttpContext.RequestServices.GetRequiredService<IRelays>().Configured;
var subscriptions = (await DB.Default.Find<RelaySubscription>().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
}
}
@@ -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<IActionResult> Reports([FromQuery] bool resolved, CancellationToken token) =>
Ok((await DB.Default.Find<Report>().Match(r => r.IsResolved == resolved).Sort(r => r.ID, Order.Descending).Limit(100).ExecuteAsync(token))
public async Task<IActionResult> 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<Report>().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<IActionResult> Resolve(string id, CancellationToken token)
{
var by = User.GetUserId();
var result = await DB.Default.Update<Report>().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<Report>().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<IActionResult> Log([FromQuery] string before, [FromQuery] int limit = 50, CancellationToken token = default)
{
var me = await DB.Default.Find<RootUser>().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<AdminAction>();
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<RootUser>().Match(u => actorIds.Contains(u.ID)).Project(u => u.Include(x => x.UserName)).ExecuteAsync(token))
.ToDictionary(u => u.ID, u => u.UserName)
: new Dictionary<string, string>();
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
});
}
}
}
@@ -43,6 +43,11 @@ namespace PrivaPub.Domain.Content
public string TagUrl(string tag) => $"{_localActors.BaseAddress}/tags/{Uri.EscapeDataString(tag)}";
/// <summary>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.</summary>
public static string Document(string markdown) =>
string.IsNullOrWhiteSpace(markdown) ? string.Empty : ContentSanitizer.Html(Markdig.Markdown.ToHtml(markdown, Pipeline));
public Task<RenderedContent> Markdown(string text, CancellationToken token) =>
Decorate(string.IsNullOrWhiteSpace(text) ? string.Empty : Markdig.Markdown.ToHtml(text, Pipeline), token);
@@ -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
{
/// <summary>The settings as they are now, never null: the defaults (today's literals) until an administrator saves.
/// Never change it: Update does.</summary>
ServerSettings Current { get; }
/// <summary>Reads the document again; tells whoever listens (ChangeToken) when it changed.</summary>
Task Reload(CancellationToken token);
/// <summary>Changes the settings as stored now (or the defaults when none are), saves them and makes them current.</summary>
Task<ServerSettings> Update(Action<ServerSettings> change, string byRootId, CancellationToken token);
/// <summary>Fires once the settings change (a save here, or a reload that found another version).</summary>
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<InstanceSettings> _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<InstanceSettings> 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<ServerSettings> Update(Action<ServerSettings> 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<ServerSettings>(MongoDB.Bson.BsonExtensionMethods.ToBson(settings)), stored: true);
return settings;
}
finally
{
_saving.Release();
}
}
static Task<ServerSettings> Find(CancellationToken token) =>
DB.Default.Find<ServerSettings>().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<InstanceSettingsRefresher> 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<MediaOptions> reader sees it, since the settings' change token is one of its sources.
public sealed class MediaLimitsFromSettings(IInstanceSettings settings) : IPostConfigureOptions<MediaOptions>
{
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<MediaOptions>
{
public string Name => Options.DefaultName;
public IChangeToken GetChangeToken() => settings.ChangeToken();
}
}
+7 -2
View File
@@ -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<Avatar>().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<Models.ServerSettings>().Match(x => x.ID == Models.ServerSettings.Name).ExecuteFirstAsync(token);
var serverHeld = new HashSet<string> { 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))
+4 -3
View File
@@ -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<MediaAttachment>().Match(m => m.TrashedAt != null).Limit(2000).ExecuteAsync(token);
var purgeBefore = DateTime.UtcNow - TrashGrace;
+54
View File
@@ -46,6 +46,9 @@ namespace PrivaPub.Domain.Media
Task<bool> ProcessPending(MediaAttachment pending, CancellationToken token);
/// <summary>A persona's new avatar or header ("avatar" or "header"), cropped to its size, as a row of its own.</summary>
Task<MediaOutcome> ProfileImage(string avatarId, string kind, IFormFile file, int width, int height, CancellationToken token);
/// <summary>A picture of the server's own (its thumbnail, "thumbnail"), fitted within 1200×630, as a row nobody owns,
/// held by its <see cref="MediaAttachment.ServerUse"/>.</summary>
Task<MediaOutcome> ServerImage(string use, IFormFile file, CancellationToken token);
/// <summary>Trashes the media <paramref name="which"/> 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.</summary>
Task<long> Trash(System.Linq.Expressions.Expression<Func<MediaAttachment, bool>> 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<MediaOutcome> 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<MediaOutcome> OverQuota(string avatarId, long incoming, CancellationToken token)
{
@@ -0,0 +1,59 @@
using MongoDB.Entities;
using PrivaPub.Models;
using PrivaPub.Models.User;
namespace PrivaPub.Domain.Moderation
{
public interface IModerationLog
{
/// <summary>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.</summary>
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<ModerationLog> _logger;
public ModerationLog(ILogger<ModerationLog> 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<RootToAvatar>().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)] + "…";
}
}
+25
View File
@@ -88,5 +88,30 @@ namespace PrivaPub.Domain.Privacy
var barred = await BarredPersonas(token);
return await DB.Default.CountAsync<PostEntity>(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<List<(DateTime Week, long Posts, long Registrations)>> 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<PostEntity>(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<RootUser>(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";
}
}
+18 -5
View File
@@ -14,8 +14,8 @@ namespace PrivaPub.Domain.Relationships
{
public interface IReportService
{
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category, bool forward,
CancellationToken token);
Task<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
IReadOnlyList<string> 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<Report> File(LocalActor reporter, string targetAccountId, IReadOnlyList<string> postIds, string comment, string category,
bool forward, CancellationToken token)
IReadOnlyList<string> 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<string>()).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)
+162
View File
@@ -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<List<Announcement>> Shown(CancellationToken token);
Task<Announcement> FindShown(string id, CancellationToken token);
Task<bool> Dismiss(string id, string avatarId, CancellationToken token);
/// <summary>200 when it is done, 404 for an announcement not shown, 422 (with why) for a name that isn't one emoji.</summary>
Task<(int Status, string Error)> React(string id, string avatarId, string name, bool add, CancellationToken token);
Task<List<Announcement>> All(CancellationToken token);
Task<Announcement> Insert(AnnouncementDraft draft, CancellationToken token);
Task<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token);
Task<Announcement> 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<Func<Announcement, bool>> 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<List<Announcement>> Shown(CancellationToken token) =>
(await DB.Default.Find<Announcement>().Match(IsShown()).ExecuteAsync(token))
.OrderBy(a => a.StartsAt ?? a.PublishedAt ?? a.CreatedAt).ThenBy(a => a.ID).ToList();
public Task<Announcement> FindShown(string id, CancellationToken token) =>
string.IsNullOrEmpty(id) ? Task.FromResult<Announcement>(default)
: DB.Default.Find<Announcement>().Match(a => a.ID == id).Match(IsShown()).ExecuteFirstAsync(token);
public async Task<bool> Dismiss(string id, string avatarId, CancellationToken token)
{
if (await FindShown(id, token) == default)
return false;
try
{
await DB.Default.Update<AnnouncementRead>()
.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<Models.Post.CustomEmoji>(), 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<AnnouncementReaction>(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<AnnouncementReaction>().Match(r => r.AnnouncementId == id && r.AvatarId == avatarId && r.Name == name).ExecuteAnyAsync(token))
return (StatusCodes.Status200OK, default);
var names = await DB.Default.Fluent<AnnouncementReaction>().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<List<Announcement>> All(CancellationToken token) =>
await DB.Default.Find<Announcement>().Sort(a => a.ID, Order.Descending).Limit(200).ExecuteAsync(token);
public async Task<Announcement> 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<Announcement> Update(string id, AnnouncementDraft draft, CancellationToken token)
{
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().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<Announcement> Delete(string id, CancellationToken token)
{
var announcement = string.IsNullOrEmpty(id) ? default : await DB.Default.Find<Announcement>().Match(a => a.ID == id).ExecuteFirstAsync(token);
if (announcement == default)
return default;
await DB.Default.DeleteAsync<Announcement>(a => a.ID == id);
await DB.Default.DeleteAsync<AnnouncementRead>(r => r.AnnouncementId == id);
await DB.Default.DeleteAsync<AnnouncementReaction>(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);
}
}
+13 -2
View File
@@ -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;
}
}
@@ -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> _appConfiguration;
readonly Domain.Instance.IInstanceSettings _settings;
InstanceActor _instanceActor;
ReporterActor _reporterActor;
ReaderActor _readerActor;
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> appConfiguration)
public LocalActorService(DbEntities dbEntities, IOptionsMonitor<AppConfiguration> 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<LocalActor> 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,
@@ -23,10 +23,13 @@ namespace PrivaPub.Federation.Controllers
readonly ILocalActorService _localActors;
readonly DbEntities _dbEntities;
readonly IOptionsMonitor<RegistrationOptions> _registrations;
readonly Domain.Instance.IInstanceSettings _settings;
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> registrations)
public WellKnownController(ILocalActorService localActors, DbEntities dbEntities, IOptionsMonitor<RegistrationOptions> 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" }
}
};
+41 -9
View File
@@ -18,6 +18,10 @@ namespace PrivaPub.Federation.Relays
{
public interface IRelays
{
/// <summary>The relays Federation:Relays names, as it names them.</summary>
IReadOnlyList<string> Configured { get; }
/// <summary>The relays named by the configuration or by the server's settings, each once.</summary>
IReadOnlyList<string> Named { get; }
Task Reconcile(CancellationToken token);
Task<bool> Answered(JsonNode activity, ForeignAvatar actor, bool accepted, CancellationToken token);
Task<bool> 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<string> _accepted;
public Relays(IOptions<FederationOptions> options, IRemoteActorService remoteActors, ILocalActorService localActors, IDeliveryService delivery)
public Relays(IOptions<FederationOptions> 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<string> Configured => Clean(_options.Value.Relays);
public IReadOnlyList<string> Named => Clean(Configured.Concat(_settings.Current.Relays ?? new()));
static List<string> Clean(IEnumerable<string> addresses) =>
(addresses ?? Array.Empty<string>()).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<RelaySubscription>().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<RelaySubscription>(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<RelaySubscriber> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromHours(6);
@@ -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<BsonDocument> 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<BsonDocument>(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)),
+22 -8
View File
@@ -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<int> 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<IModerationLog>().Write(AdminAction.Cli, action, kind, id, label, note: note);
static async Task<int> Promote(IServiceProvider services, bool promote, string userName, TextWriter output, bool created = false)
{
userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().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<RootUser>().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<int> Report(string login, TextWriter output)
@@ -266,22 +277,24 @@ namespace PrivaPub.Infrastructure.Cli
await DB.Default.Update<RootToAvatar>().MatchID(link.ID).Modify(ra => ra.RootId, root.ID).ExecuteAsync();
await services.GetRequiredService<IOpenIddictTokenManager>().RevokeBySubjectAsync(persona.ID);
await services.GetRequiredService<IOpenIddictAuthorizationManager>().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<int> ResetTotp(string login, TextWriter output)
static async Task<int> ResetTotp(IServiceProvider services, string login, TextWriter output)
{
login = login.ToLowerInvariant();
var updated = await DB.Default.Update<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null)
.Modify(u => u.Totp, null).ExecuteAsync();
if (updated.MatchedCount == 0)
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == login && u.DeletedAt == null).ExecuteFirstAsync();
var updated = root == default ? default : await DB.Default.Update<RootUser>().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;
}
+11
View File
@@ -48,6 +48,17 @@ namespace PrivaPub.Infrastructure.Data
await Plain<Models.Media.MediaAttachment>(token, m => m.ScheduledStatusId);
await Plain<Models.Media.MediaAttachment>(token, m => m.ProfileOfAvatarId);
await Plain<Models.Media.MediaAttachment>(token, m => m.TrashedAt);
await Plain<Models.Media.MediaAttachment>(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<AnnouncementRead>().Key(r => r.AnnouncementId, KeyType.Ascending).Key(r => r.AvatarId, KeyType.Ascending)
.Option(o => o.Unique = true).CreateAsync(token);
await Plain<AnnouncementRead>(token, r => r.AvatarId);
await DB.Default.Index<AnnouncementReaction>().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<AnnouncementReaction>(token, r => r.AvatarId);
// the staff's log, read newest first
await DB.Default.Index<Models.AdminAction>().Key(a => a.At, KeyType.Descending).CreateAsync(token);
await Unique<ForeignAvatar>(a => a.ActorURI, Builders<ForeignAvatar>.Filter.Gt(a => a.ActorURI, ""), token);
await Plain<ForeignAvatar>(token, a => a.PublicKeyId);
+64
View File
@@ -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";
}
}
@@ -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;
}
+1
View File
@@ -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; }
+65
View File
@@ -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<ServerRule> Rules { get; set; } = new();//soft-deleted ones stay, so old reports keep their meaning
public List<string> Relays { get; set; } = new();//besides Federation:Relays
public ServerMediaLimits Media { get; set; } = new();
public List<string> 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 };
/// <summary>The rules shown to everyone: those not deleted, in their order.</summary>
public IEnumerable<ServerRule> 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; }
}
}
+36
View File
@@ -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;
}
}
+1
View File
@@ -72,6 +72,7 @@ namespace PrivaPub.Models.Social
public List<string> PostIds { get; set; } = new();
public List<string> ObjectURIs { get; set; } = new();
public string Category { get; set; } = "other";
public List<string> 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; }
+9
View File
@@ -59,6 +59,14 @@ try
.AddScoped<PrivaPub.Api.Mastodon.Mappers.AccountSearch>()
.AddSingleton<PrivaPub.Domain.Social.Trends>()
.Configure<PrivaPub.Domain.Media.MediaOptions>(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<MediaOptions> reader
.AddSingleton<PrivaPub.Domain.Instance.IInstanceSettings, PrivaPub.Domain.Instance.InstanceSettings>()
.AddHostedService<PrivaPub.Domain.Instance.InstanceSettingsRefresher>()
.AddSingleton<IPostConfigureOptions<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsFromSettings>()
.AddSingleton<IOptionsChangeTokenSource<PrivaPub.Domain.Media.MediaOptions>, PrivaPub.Domain.Instance.MediaLimitsChangeSource>()
.AddSingleton<PrivaPub.Domain.Moderation.IModerationLog, PrivaPub.Domain.Moderation.ModerationLog>()
.AddSingleton<PrivaPub.Domain.Social.IAnnouncements, PrivaPub.Domain.Social.Announcements>()
.AddSingleton<PrivaPub.Domain.Media.IMediaService, PrivaPub.Domain.Media.MediaService>()
.AddSingleton<PrivaPub.Domain.Media.IMediaProxy, PrivaPub.Domain.Media.MediaProxy>()
.AddHostedService<PrivaPub.Domain.Media.MediaJanitor>()
@@ -125,6 +133,7 @@ try
{
await DB.Default.MigrateAsync<Program>();
await Indexes.Create();
await app.Services.GetRequiredService<PrivaPub.Domain.Instance.IInstanceSettings>().Reload(CancellationToken.None);
}
catch (Exception ex)
{
+7 -2
View File
@@ -19,14 +19,19 @@ namespace PrivaPub.Services
public class Mailer : IMailer
{
readonly IOptionsMonitor<AppConfiguration> _app;
readonly Domain.Instance.IInstanceSettings _settings;
public Mailer(IOptionsMonitor<AppConfiguration> app) => _app = app;
public Mailer(IOptionsMonitor<AppConfiguration> 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 };
+3
View File
@@ -69,6 +69,9 @@ namespace PrivaPub.Services
await DB.Default.DeleteAsync<Models.Social.PersonaList>(l => l.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.PersonaFilter>(f => f.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.FollowedTag>(t => t.AvatarId == avatar.ID);
// what it did with the server's announcements, kept for it alone
await DB.Default.DeleteAsync<Models.Social.AnnouncementRead>(r => r.AvatarId == avatar.ID);
await DB.Default.DeleteAsync<Models.Social.AnnouncementReaction>(r => r.AvatarId == avatar.ID);
// (the reader of feeds stops following what nobody reads any more at its next round)
await DB.Default.DeleteAsync<Models.Social.FeedSubscription>(s => s.AvatarId == avatar.ID);
// nothing of it publishes later, and none of its files stays served: its posts' media, its pictures and
+2 -1
View File
@@ -1,9 +1,10 @@
@page "/oauth/login"
@model PrivaPub.Web.Pages.OAuth.LoginModel
@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings
@{
ViewData["Title"] = "Sign in";
}
<header><h1>Sign in to PrivaPub</h1></header>
<header><h1>Sign in to @InstanceSettings.Current.Title</h1></header>
<p class="meta">Sign in with your private login. You will then choose which persona the application acts as; the
application never sees the login.</p>
<form method="post">
+2 -1
View File
@@ -1,3 +1,4 @@
@inject PrivaPub.Domain.Instance.IInstanceSettings InstanceSettings
<!DOCTYPE html>
<html lang="en">
<head>
@@ -32,7 +33,7 @@
<body>
<main>
@RenderBody()
<footer>PrivaPub</footer>
<footer>@InstanceSettings.Current.Title</footer>
</main>
</body>
</html>
+226
View File
@@ -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 <view json> <python statements changing f>: 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 <path> <side>: 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.<script>alert(1)</script>',
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('<strong>the pasture</strong>' in d['content'] and '<script' not in d['content'] and d['updated_at'][:10] == '$(date -u +%F)')")" = True ] \
&& ok "the extended description is rendered from Markdown, sanitized, dated today" || ko "the extended description: $ext"
[ "$(curl -s "$P/api/v1/instance/privacy_policy" | j "print('<em>little</em>' 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 '<em>open</em>' 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'], '<em>open</em>' 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)"
+3 -2
View File
@@ -7,7 +7,8 @@
# run.sh as they are. Each batch's output is kept in out/sweep-<batch>.log.
# usage: tools/pasture/sweep.sh [A|B|C|D ...] (default: every batch, in that order)
# 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
}