From 4c889803741e1412304e248090de504847124d29 Mon Sep 17 00:00:00 2001 From: thepra Date: Sat, 17 Oct 2026 04:32:57 +0200 Subject: [PATCH] R5 the server's own settings in decePub: instance, rules, announcements, relays, media limits, the staff's log, About Administration's Pleroma placeholders give way to real sections: Instance (title, descriptions, contact, thumbnail, privacy policy, terms, the weekly activity), Rules, Announcements, Relays, Media limits and the Moderation log, next to Reports, blocked servers (now published or obfuscated), statistics and backups; moderators see Reports and the log. Every save sends the whole settings form built from the latest read, since PrivaPub replaces what it carries. /about shows the server's documents to anyone. The report dialog picks the rules a violation breaks, and reports show them. Announcements show above the feed tabs, dismissed and reacted to, live from the user stream. Login names the server. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof --- CLAUDE.md | 85 +++- ClientJsonContext.cs | 9 + Components/Admin/AnnouncementsPanel.razor | 245 ++++++++++ Components/Admin/InstancePanel.razor | 399 ++++++++++++++++ Components/Admin/MediaLimitsPanel.razor | 214 +++++++++ Components/Admin/ModerationLogPanel.razor | 161 +++++++ Components/Admin/RelaysPanel.razor | 191 ++++++++ Components/Admin/ReportsPanel.razor | 28 +- Components/Admin/RulesPanel.razor | 177 ++++++++ Components/Admin/ServerBlocksPanel.razor | 41 +- Components/AnnouncementsBanner.razor | 207 +++++++++ Components/ReportDialog.razor | 64 ++- MastodonJsonContext.cs | 8 + Models/APIs.cs | 21 + Models/Mastodon/Announcement.cs | 35 ++ Models/Mastodon/InstanceV2.cs | 61 ++- Models/Mastodon/PrivaPubApi.cs | 4 +- Models/Types/ReportCategory.cs | 5 +- Pages/About.razor | 223 +++++++++ Pages/Administration.razor | 247 ++++------ Pages/Index.razor | 4 +- Pages/Login.razor | 18 +- Program.cs | 3 + Resources/AllStrings.it.resx | 531 +++++++++++++++++++++- Resources/AllStrings.resx | 531 +++++++++++++++++++++- Services/AboutService.cs | 96 ++++ Services/AccountService.cs | 10 +- Services/AdminService.cs | 24 +- Services/AnnouncementService.cs | 48 ++ Services/InstanceAdminService.cs | 178 ++++++++ Services/StreamingService.cs | 72 ++- Shared/NavMenu.razor | 4 + Styles/feed.css | 38 ++ 33 files changed, 3743 insertions(+), 239 deletions(-) create mode 100644 Components/Admin/AnnouncementsPanel.razor create mode 100644 Components/Admin/InstancePanel.razor create mode 100644 Components/Admin/MediaLimitsPanel.razor create mode 100644 Components/Admin/ModerationLogPanel.razor create mode 100644 Components/Admin/RelaysPanel.razor create mode 100644 Components/Admin/RulesPanel.razor create mode 100644 Components/AnnouncementsBanner.razor create mode 100644 Models/Mastodon/Announcement.cs create mode 100644 Pages/About.razor create mode 100644 Services/AboutService.cs create mode 100644 Services/AnnouncementService.cs create mode 100644 Services/InstanceAdminService.cs diff --git a/CLAUDE.md b/CLAUDE.md index 682dc1d..3af8b1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,7 +160,8 @@ Below `md` the app is laid out for a phone, and a touch screen gets thumb-sized - No history entries are pushed. - **What can't be undone asks first** through `Services/ConfirmService.cs` (`Ask`, shown by `ConfirmHost` in `MainLayout`): deleting a post, taking it off a wall, muting, blocking, removing a follower, revoking a quote, leaving a - group, dropping a scheduled post and signing out. + group, dropping a scheduled post, signing out, and for an administrator deleting an announcement and removing the + server's thumbnail. - **On a touch screen:** - a tap on a post opens its thread (`main.js`, `data-thread` on the card, its `data-thread-link`), and cards are pinned to the globe with a mouse only; @@ -297,8 +298,11 @@ Never redeclare the base's members in a component. persona's lists and their members; members are only accounts it follows, found with `accounts/search?following=true`), `FilterService` (the persona's v2 filters, their keywords and filtered posts), `DiscoveryService` (hashtags and following them, trends, the directory), `PortabilityService` (Mastodon's CSV exports and imports), `AdminService` - (`/clientapi/moderator` and `/clientapi/admin`), `MediaService` (uploads, and deleting those no post holds), - `GroupService`, `InstanceService` and + (`/clientapi/moderator`, the staff's log among it, and `/clientapi/admin`'s blocks, statistics and backups), + `InstanceAdminService` (the server's own settings, thumbnail, announcements and relays: PrivaPub's + `InstanceAdminController`), `AboutService` (what the server says of itself to anyone: the instance API, its documents + and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and + reacting), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and `ProvenanceService`. - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in @@ -317,6 +321,14 @@ Never redeclare the base's members in a component. sensitive-media button), a picture's focal point (`object-position`), and an event's online link, participants and status. A picture, video or sound that fails to load says "Media unavailable" with "Open original" (`data-media-unavailable`), and a gifv plays only on a tap under `prefers-reduced-motion`. +- **About** (`Pages/About.razor` at `/about`, no sign-in, `data-about`; linked from NavMenu as `data-nav-about` and from + Login as `data-about-offer`): the server's name (`data-about-title`), short description and thumbnail, its long + description (`data-about-description`, `/api/v1/instance/extended_description`), contact (`data-about-contact`), rules + (`data-about-rules`, `data-about-rule`), privacy policy (`data-about-privacy`), terms of service (`data-about-terms`, + none while PrivaPub answers 404) and the domain blocks the administrator published (`data-about-blocks`, + `data-about-block`, an obfuscated one partly starred as PrivaPub gives it). The documents are PrivaPub's sanitized + HTML in `post-content doc-content` (`Styles/feed.css` spaces their headings). Login's heading names the server + (`data-login-title`, `/api/v2/instance` read without credentials and kept for the session). - **Profiles and search** (`Pages/Profile.razor` at `/account/{id}`, `Pages/Search.razor` at `/search?q=`): a card's author and a notification's account open the profile in the app; "Open original" is the only way to the remote site. A profile shows the posts the account pins above its posts (`pinned=true`: what an account elsewhere features); the @@ -324,7 +336,9 @@ Never redeclare the base's members in a component. An account that moved says where to (`Account.Moved`, which PrivaPub fills once the new account names the old one), linking the new account's profile. Another's profile and posts can be reported (`Components/ReportDialog.razor`, `/api/v1/reports`): a category, words, - and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. + and for a remote account a copy to its server, which PrivaPub sends from its own actor, never naming the persona. A + violation (`ReportCategory.Violation`, offered while the server has rules) ticks the server's rules it breaks + (`/api/v1/instance/rules`, sent as `rule_ids`; `data-report-rules`, a checkbox `data-report-rule="{id}"` each). Who liked or boosted a post opens from its card's menu (`Components/InteractedByDialog.razor`). On the persona's own profile its counts are buttons (`data-profile-following`, `data-profile-followers`) opening the accounts it follows and its followers (`Components/AccountListDialog.razor`, 40 a page by the Link header): PrivaPub @@ -412,9 +426,56 @@ Never redeclare the base's members in a component. PrivaPub in Mastodon's layout and downloaded through a link; and an archive (its own, or Mastodon's) uploaded in pieces by `js/transfer.js` and imported into it, the parts chosen (likes, located and scheduled posts off unless ticked), with progress, a report and Stop. Imported posts are delivered to no one. -- **Administration** (`Pages/Administration.razor`, `Components/Admin/`, for moderators): reports, then for - administrators the servers blocked for everyone, the federation statistics and the backups. PrivaPub has no admin list - of users, and adding an admin endpoint waits for the owner, so the Users section stays empty. +- **Administration** (`Pages/Administration.razor`, `Components/Admin/`): each section is a `DCollapse` marked + `data-admin-section`, and its panel loads when the page opens. Moderators see Reports (`reports`) and the Moderation + log (`log`); administrators also see Instance (`instance`), Rules (`rules`), Announcements (`announcements`), + Blocked servers (`blocks`), Relays (`relays`), Media limits (`media`), Statistics (`statistics`) and Backups + (`backups`). PrivaPub checks the role on every call (moderators: `/clientapi/moderator`, administrators: + `/clientapi/admin`, checked against its database too). Users & personas come with R6, custom emoji with R7: until + then they have no section. + - **The settings save whole.** `POST /clientapi/admin/settings` replaces everything `ServerSettingsForm` carries (a rule + left out is removed), so Instance, Rules and Media limits each save through `InstanceAdminService.SaveSettings`: it + reads the settings PrivaPub has now, builds the form from them (`FormOf`: the rules not deleted, in order, the media + overrides, every text), lets the panel set the fields the administrator changed there (the others keep what PrivaPub + has, whoever saved it meanwhile), posts the whole form, and the panel shows what PrivaPub answers. A save also makes + `AboutService` read the instance again. + - **Instance** (`InstancePanel`, `data-instance`): the server's name (`data-instance-title`), short description + (`data-instance-short-description`), Markdown description (`data-instance-description`), contact address + (`data-instance-email`) and contact persona (`data-instance-contact`, removed with `data-instance-contact-remove`; + picked from `data-instance-contact-option` buttons: the administrator's own personas, or this server's accounts as + `data-instance-contact-search` finds them through `/api/v2/search`, a local account's `acct` having no host), privacy + policy (`data-instance-privacy`), terms (`data-instance-terms`, their date `data-instance-terms-date`), and whether the + weekly activity is published (`data-instance-publish-activity`, off by default, with why). Registrations are the + configuration's and only shown (`data-instance-registrations`). `data-instance-save`, `data-instance-reset`. The + thumbnail (`data-instance-thumbnail`, a file input; `data-instance-thumbnail-image`, `data-instance-thumbnail-remove`, + asked first) is saved as soon as it is picked, streamed from the file as multipart `file`. + - **Rules** (`RulesPanel`, `data-rules`): one row a rule (`data-rule="{position}"`, `data-rule-id`, its + `data-rule-text` and `data-rule-hint`, `data-rule-up`, `data-rule-down`, `data-rule-delete`), `data-rule-add`, + `data-rule-save`, `data-rule-reset`. A removed rule is kept (old reports name it) and listed greyed + (`data-rule-deleted="{id}"`); `data-rule-restore` lists it again with its id. + - **Announcements** (`AnnouncementsPanel`, `data-announcements`): each (`data-announcement="{id}"`, + `data-announcement-state` shown, ended or draft) with its HTML, times, `data-announcement-reads` and reactions; + `data-announcement-new` and `data-announcement-edit` open `data-announcement-dialog` (`data-announcement-text`, + `-starts`, `-ends` as datetime-local in the reader's time, `-all-day`, `-published`, `-save`, `-cancel`); + `data-announcement-delete` asks first. + - **Blocked servers** (`ServerBlocksPanel`, `data-server-blocks`): a block is published on About and + `/api/v1/instance/domain_blocks` only when asked (`data-server-block-published`; `data-server-block-obfuscate` stars + part of its name), since that tells the blocked server and everyone; `data-server-block-edit` fills the form with a + block, since blocking a server again changes its block. `data-server-block-save`. + - **Relays** (`RelaysPanel`, `data-relays`): each (`data-relay="{address}"`, `data-relay-state`, `data-relay-source` + configuration or settings; `data-relay-remove` for the settings' ones: a configured one is removed only from + `Federation:Relays`, and PrivaPub answers 409), added by `data-relay-address` and `data-relay-add`. PrivaPub answers + 202 and subscribes in the background, so the panel reads the list again after 4 and 15 seconds. + - **Media limits** (`MediaLimitsPanel`, `data-media-limits`): `data-media-limit="quota|image|video|seconds"` rows, + inputs `data-media-quota`, `-image`, `-video` (megabytes) and `-seconds`, empty for the configured value, beside the + configured and the effective values (`data-media-effective`) and the hard caps as maxima; `data-media-save`, + `data-media-reset`, `data-media-use-configured`. What was set stays to the byte while its megabytes are not changed. + - **Moderation log** (`ModerationLogPanel`, `data-log`, `/clientapi/moderator/log`): newest first, 50 at a time + (`data-log-more` asks for those before `Next`), each entry (`data-log-entry="{id}"`, `data-log-action`) with who + (`data-log-by`: you, a login name to administrators, `cli`, or none), what, to what (`data-log-target`) and its note + (`data-log-note`). `data-log-reload`. + - **Reports** (`ReportsPanel`): a violation's rules (`data-report-rules`, `data-report-rule-id`) as PrivaPub keeps their + texts, a removed rule's too. - **Statistics** (`Components/Admin/StatisticsPanel.razor`, `/clientapi/admin/statistics`, typed in ClientModels' `Admin/ViewStatistics.cs`): the period's overview, the servers (by traffic, failures, latency or name), the crawler, the ledger's events and the server's own days. Counted per server, never per account. @@ -461,6 +522,16 @@ Never redeclare the base's members in a component. streaming API (`Services/StreamingService.cs`: one WebSocket, the persona's token as its protocol, never in a URL) for home, lists, local, federated, hashtags and notifications, and wait behind a "new" button; a deletion or an edit it tells applies at once. Other feeds, and any feed while the stream is down, are polled every 45 s while visible. + - **Announcements** (`Components/AnnouncementsBanner.razor`, above the tabs, `data-announcement-banner`): the ones + shown now that the persona has not dismissed, one at a time (`data-announcement-previous`, `-next`, + `-position`), dismissed (`data-announcement-dismiss`, `POST /api/v1/announcements/:id/dismiss`) and reacted to + (`data-announcement-reaction="{emoji}"` toggles the persona's own, `data-announcement-react` offers more, + `data-announcement-react-emoji`; `PUT`/`DELETE /api/v1/announcements/:id/reactions/:name`, then read again). They + come live on the user stream (`announcement`, `announcement.reaction`, `announcement.delete`): + `StreamingService.HearAnnouncements` keeps it subscribed whatever feed is followed (and opens the socket for it + alone on a polled feed), and raises what it tells as `Announced`; the user stream's posts and notifications are + the feed's only while the feed followed is the user stream itself. The banner renders itself and never touches the + feed's cards. - **At volume:** the list is a `` (only the cards near the view are rendered), and a card re-renders only when its own events run or its render key changes (`Content.RenderKey`: the post, its status, notification, conversation, place, grouping, where it is shown, the persona and the connection). Anything that changes what a diff --git a/ClientJsonContext.cs b/ClientJsonContext.cs index ec32c40..7927b86 100644 --- a/ClientJsonContext.cs +++ b/ClientJsonContext.cs @@ -90,6 +90,15 @@ namespace decePubClient; [JsonSerializable(typeof(ImportPersonaArchiveForm))] [JsonSerializable(typeof(ViewArchiveTicket))] [JsonSerializable(typeof(ViewArchiveUpload))] +[JsonSerializable(typeof(ViewServerSettings))] +[JsonSerializable(typeof(ServerSettingsForm))] +[JsonSerializable(typeof(ViewAnnouncement))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AnnouncementForm))] +[JsonSerializable(typeof(ViewRelay))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(RelayForm))] +[JsonSerializable(typeof(ViewAdminActionsPage))] public partial class ClientJsonContext : JsonSerializerContext { } diff --git a/Components/Admin/AnnouncementsPanel.razor b/Components/Admin/AnnouncementsPanel.razor new file mode 100644 index 0000000..0d197a5 --- /dev/null +++ b/Components/Admin/AnnouncementsPanel.razor @@ -0,0 +1,245 @@ +@inherits LocalizableComponentBase +@using System.Globalization + +@* The administrators' announcements (/clientapi/admin/announcements): written in Markdown, with when they run and + whether they are all day, kept as drafts until published; shown to every persona above its feed while published and + not ended. Each says how many personas dismissed it and how they reacted. Deleting one takes its reads and reactions + with it, and every persona's stream hears it go. *@ +
+
+ + @Localizer["Write an announcement"] + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (announcements.Count == 0) + { +

@Localizer["No announcement yet."]

+ } + + @foreach (var announcement in announcements) + { +
+
+ @switch (StateOf(announcement)) + { + case "shown": + @Localizer["Shown now"] + break; + case "ended": + @Localizer["Ended"] + break; + default: + @Localizer["Draft"] + break; + } + @if (announcement.AllDay) + { + @Localizer["All day"] + } + + @Localizer["Written {0} ago", announcement.CreatedAt.GetPassedTime(Localizer)] + + + + @Localizer["Edit"] + + + @Localizer["Delete"] + +
+ @if (announcement.StartsAt is not null || announcement.EndsAt is not null) + { +

@When(announcement)

+ } +
@((MarkupString)announcement.Html)
+
+ @Localizer["Dismissed by {0}", announcement.Reads] + @foreach (var reaction in announcement.Reactions) + { + + @reaction.Name @reaction.Count + + } +
+
+ } +
+ +@if (editing is not null) +{ + + @(editing.Id is null ? Localizer["New announcement"] : Localizer["Edit the announcement"]) + + + + +
+ + +
+

@Localizer["Both are optional: once its end is past, it is no longer shown."]

+ + + +
+ @Localizer["Cancel"] + + @if (isSaving) + { + + } + @Localizer["Save"] + +
+
+
+
+} + +@code { + const string LocalInput = "yyyy-MM-ddTHH:mm"; + + [Inject] InstanceAdminService InstanceAdminService { get; set; } + [Inject] ConfirmService Confirm { get; set; } + + List announcements = []; + // the announcement being written or changed, with its times as the datetime-local inputs hold them (the reader's own) + AnnouncementForm editing; + string startsAt; + string endsAt; + string error; + bool isSaving; + HashSet busy = []; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => announcements = await InstanceAdminService.Announcements(); + + static string StateOf(ViewAnnouncement announcement) => announcement.IsShown ? "shown" : announcement.IsPublished ? "ended" : "draft"; + + string When(ViewAnnouncement announcement) + { + var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt; + var starts = announcement.StartsAt?.ToLocalTime().ToString(format); + var ends = announcement.EndsAt?.ToLocalTime().ToString(format); + return (starts, ends) switch + { + ({ } from, { } to) => Localizer["From {0} to {1}", from, to], + ({ } from, null) => Localizer["From {0}", from], + _ => Localizer["until {0}", ends] + }; + } + + void Closed(bool open) + { + if (!open) + editing = null; + } + + void New() + { + error = null; + editing = new AnnouncementForm(); + startsAt = null; + endsAt = null; + } + + void Edit(ViewAnnouncement announcement) + { + error = null; + editing = new AnnouncementForm + { + Id = announcement.Id, + Text = announcement.Text, + StartsAt = announcement.StartsAt, + EndsAt = announcement.EndsAt, + AllDay = announcement.AllDay, + IsPublished = announcement.IsPublished + }; + startsAt = announcement.StartsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture); + endsAt = announcement.EndsAt?.ToLocalTime().ToString(LocalInput, CultureInfo.InvariantCulture); + } + + // a datetime-local value, the reader's own time, in UTC; null when empty or not a time + static DateTime? Utc(string local) => + DateTime.TryParse(local, CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var at) ? at.ToUniversalTime() : null; + + async Task Save() + { + error = null; + editing.StartsAt = Utc(startsAt); + editing.EndsAt = Utc(endsAt); + if (editing.StartsAt is { } from && editing.EndsAt is { } to && to < from) + { + error = Localizer["An announcement can't end before it starts."]; + return; + } + + isSaving = true; + var result = await InstanceAdminService.SaveAnnouncement(editing); + isSaving = false; + if (result.IsInvalid) + { + error = result.ErrorMessage; + return; + } + + var saved = (ViewAnnouncement)result.Data; + var at = announcements.FindIndex(a => a.Id == saved.Id); + if (at < 0) + announcements.Insert(0, saved); + else + announcements[at] = saved; + editing = null; + Toast.AddResponseSuccess(Localizer["The announcement is saved."]); + } + + async Task Delete(ViewAnnouncement announcement) + { + if (!await Confirm.Ask(Localizer["Delete the announcement?"], Localizer["It disappears for everyone, with its reactions."], Localizer["Delete"], true)) + return; + + busy.Add(announcement.Id); + var result = await InstanceAdminService.DeleteAnnouncement(announcement.Id); + busy.Remove(announcement.Id); + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + announcements.Remove(announcement); + Toast.AddResponseSuccess(Localizer["The announcement is deleted."]); + } +} diff --git a/Components/Admin/InstancePanel.razor b/Components/Admin/InstancePanel.razor new file mode 100644 index 0000000..1ee9564 --- /dev/null +++ b/Components/Admin/InstancePanel.razor @@ -0,0 +1,399 @@ +@inherits LocalizableComponentBase +@using System.Globalization + +@* What the server says of itself (/clientapi/admin/settings): its name, its short description and its Markdown + description, its thumbnail, who answers for it (an address and a persona of this server, picked by searching the + local accounts or from the administrator's own personas), its privacy policy and terms of service, and whether its + weekly activity is published. The thumbnail is saved as soon as it is picked, streamed from the file. Saving starts + from the settings PrivaPub has now (InstanceAdminService.SaveSettings), so what Rules or Media limits saved stays. + Registrations are the configuration's and only shown. *@ +
+ @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (settings is null) + { +
+

@Localizer["The server's settings could not be read."]

+ @Localizer["Retry"] +
+ } + else + { +
+ @Localizer["Thumbnail"] + @Localizer[ +
+ + @if (isUploading) + { + + } + @if (!settings.ThumbnailIsDefault) + { + + @Localizer["Back to the default picture"] + + } +
+

@Localizer["Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata.", MaximumThumbnail.GetFileSize(Localizer)]

+ +
+ + + + + + + + +
+ @Localizer["Contact"] + + @Localizer["Contact persona"] + @if (form.ContactAccountId is { Length: > 0 }) + { +
+ + + @(contactName ?? form.ContactAccountId) + @if (contactHandle is { Length: > 0 }) + { + @contactHandle + } + + + @Localizer["No contact persona"] + +
+ } + else + { +

@Localizer["None: the server names nobody."]

+ } + @* its form attribute names no form, so Enter in the search never saves the settings *@ + + + +
+ @if (contactQuery is { Length: > 0 }) + { + @foreach (var account in contactMatches) + { + + @account.Name@("@" + account.Acct) + + } + @if (contactMatches.Count == 0 && !isSearching) + { + @Localizer["No persona of this server matches."] + } + } + else + { + @foreach (var persona in CascadingState.Personas) + { + + @(persona.Name ?? persona.UserName) + + } + } +
+

@Localizer["The contact persona is shown to everyone as the one who runs this server."]

+
+ + +
+ + + @Localizer["Empty, the server has no terms. Without a date, new terms take effect today."] +
+ +
+ +

@Localizer["Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default."]

+
+ +

+ @Localizer["Sign-ups"] + @(settings.RegistrationMode == "Open" ? Localizer["open to anyone"] : Localizer["by invitation only"]) + @Localizer["(set in the server's configuration, Registrations:Mode)"] +

+ + +
+ @if (settings.UpdatedAt is { } savedAt) + { + + @Localizer["Saved {0} ago by {1}", savedAt.GetPassedTime(Localizer), SavedBy] + + } + @Localizer["Discard changes"] + + @if (isSaving) + { + + } + @Localizer["Save"] + +
+
+ } +
+ +@code { + [Inject] InstanceAdminService InstanceAdminService { get; set; } + [Inject] AccountService AccountService { get; set; } + [Inject] MediaService MediaService { get; set; } + [Inject] ConfirmService Confirm { get; set; } + + ViewServerSettings settings; + ServerSettingsForm form = new(); + // the terms' date as the date input holds it (yyyy-MM-dd), empty for none + string termsDate; + // the contact persona as shown: what the settings name, or the one just picked + string contactName; + string contactHandle; + string contactQuery; + List contactMatches = []; + int searching; + bool isSearching; + bool isSaving; + bool isUploading; + List pictureTypes = []; + List thumbnailErrors = []; + + string PictureTypes => string.Join(',', pictureTypes); + + // what PrivaPub takes for a picture now, within its hard cap + long MaximumThumbnail => settings?.Media.EffectiveMaxImageBytes is long effective and > 0 + ? Math.Min(effective, ServerMediaLimitsForm.HardMaxImageBytes) + : ServerMediaLimitsForm.HardMaxImageBytes; + + string SavedBy => settings?.UpdatedBy switch + { + null => Localizer["someone"], + "cli" => Localizer["the command line"], + var name => name + }; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + pictureTypes = [.. (await MediaService.Limits()).SupportedMimeTypes.Where(t => t.StartsWith("image/", StringComparison.Ordinal))]; + settings = await InstanceAdminService.Settings(); + Reset(); + } + + async Task Retry() + { + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + void Reset() + { + if (settings is null) + return; + form = InstanceAdminService.FormOf(settings); + termsDate = settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + contactName = settings.ContactAccount?.Name ?? settings.ContactAccount?.UserName; + contactHandle = settings.ContactAccount?.Handle; + contactQuery = null; + contactMatches = []; + } + + async Task SearchContact(string query) + { + contactQuery = query; + var lookup = ++searching; + if (string.IsNullOrWhiteSpace(query)) + { + contactMatches = []; + isSearching = false; + return; + } + + isSearching = true; + await Task.Delay(300); + if (lookup != searching) + return; + var found = await AccountService.Suggest(query.Trim(), false); + if (lookup != searching) + return; + // the server's own personas only: a local account's acct has no host + contactMatches = [.. found.Accounts.Where(a => !a.Group && a.Acct is { Length: > 0 } acct && !acct.Contains('@'))]; + isSearching = false; + } + + void PickContact(string accountId, string name, string handle) + { + form.ContactAccountId = accountId; + contactName = name; + contactHandle = handle; + contactQuery = null; + contactMatches = []; + } + + void RemoveContact() + { + form.ContactAccountId = null; + contactName = null; + contactHandle = null; + } + + async Task PickThumbnail(InputFileChangeEventArgs eventArgs) + { + thumbnailErrors = []; + var file = eventArgs.File; + if (!pictureTypes.Contains(file.ContentType)) + { + thumbnailErrors.Add(Localizer["\"{0}\" is not a kind of file this server takes.", file.Name]); + return; + } + if (file.Size > MaximumThumbnail) + { + thumbnailErrors.Add(Localizer["Supplied file \"{0}\" with size {1} exceeds the maximum of {2}.", file.Name, file.Size.GetFileSize(Localizer), + MaximumThumbnail.GetFileSize(Localizer)]); + return; + } + + isUploading = true; + var result = await InstanceAdminService.SaveThumbnail(file, MaximumThumbnail); + isUploading = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + // the form keeps what is being written; only the picture and the dates come from the answer + settings = (ViewServerSettings)result.Data; + Toast.AddResponseSuccess(Localizer["Thumbnail saved."]); + } + + async Task RemoveThumbnail() + { + if (!await Confirm.Ask(Localizer["Remove the thumbnail?"], Localizer["The picture uploaded is deleted, and the server shows its default picture again."], + Localizer["Remove"], true)) + return; + + isUploading = true; + var result = await InstanceAdminService.RemoveThumbnail(); + isUploading = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + settings = (ViewServerSettings)result.Data; + Toast.AddResponseSuccess(Localizer["Thumbnail removed."]); + } + + // the value written here when it differs from the one shown, else the one PrivaPub has now + static string Changed(string written, string shown, string latest) => written == shown ? latest : written; + + async Task Save() + { + var effective = default(DateTime?); + if (termsDate is { Length: > 0 }) + { + if (!DateTime.TryParseExact(termsDate, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var on)) + { + Toast.AddResponseError(Localizer["\"{0}\" is not a date.", termsDate]); + return; + } + effective = on; + } + + // only what was changed here: what another administrator saved meanwhile to the rest stays + var shown = InstanceAdminService.FormOf(settings); + var dateChanged = termsDate != settings.TermsEffectiveAt?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + isSaving = true; + var result = await InstanceAdminService.SaveSettings(latest => + { + latest.Title = Changed(form.Title?.Trim(), shown.Title, latest.Title); + latest.ShortDescription = Changed(form.ShortDescription, shown.ShortDescription, latest.ShortDescription); + latest.Description = Changed(form.Description, shown.Description, latest.Description); + latest.ContactEmail = Changed(form.ContactEmail?.Trim(), shown.ContactEmail, latest.ContactEmail); + latest.ContactAccountId = Changed(form.ContactAccountId, shown.ContactAccountId, latest.ContactAccountId); + latest.PrivacyPolicy = Changed(form.PrivacyPolicy, shown.PrivacyPolicy, latest.PrivacyPolicy); + latest.TermsOfService = Changed(form.TermsOfService, shown.TermsOfService, latest.TermsOfService); + latest.TermsEffectiveAt = dateChanged ? effective : latest.TermsEffectiveAt; + latest.PublishActivity = form.PublishActivity != shown.PublishActivity ? form.PublishActivity : latest.PublishActivity; + }); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + settings = (ViewServerSettings)result.Data; + Reset(); + Toast.AddResponseSuccess(Localizer["The server's settings are saved."]); + } +} diff --git a/Components/Admin/MediaLimitsPanel.razor b/Components/Admin/MediaLimitsPanel.razor new file mode 100644 index 0000000..de8523c --- /dev/null +++ b/Components/Admin/MediaLimitsPanel.razor @@ -0,0 +1,214 @@ +@inherits LocalizableComponentBase + +@* The media limits the administrator sets over the configuration's (Media:*, /clientapi/admin/settings): what a login's + media may take in all, how big a picture and a video may be, and how long a sound or a video may last. Left empty, a + limit is the configuration's; beside each, the configured value and the one in force now, and PrivaPub's hard caps + are the maxima (40 MB, 99 MB, four hours). Saving starts from the settings PrivaPub has now + (InstanceAdminService.SaveSettings), so what Instance or Rules saved stays. *@ +
+ @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (settings is null) + { +
+

@Localizer["The server's settings could not be read."]

+ @Localizer["Retry"] +
+ } + else + { +

@Localizer["Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload."]

+ +
+ +

+ @Localizer["Configured: {0}", Quota(settings.Media.ConfiguredQuotaBytesPerRoot)] · + @Localizer["In force: {0}", Quota(settings.Media.EffectiveQuotaBytesPerRoot)] · + @Localizer["0: no quota"] +

+
+ +
+ +

+ @Localizer["Configured: {0}", settings.Media.ConfiguredMaxImageBytes.GetFileSize(Localizer)] · + @Localizer["In force: {0}", settings.Media.EffectiveMaxImageBytes.GetFileSize(Localizer)] · + @Localizer["At most {0}", settings.Media.HardMaxImageBytes.GetFileSize(Localizer)] +

+
+ +
+ +

+ @Localizer["Configured: {0}", settings.Media.ConfiguredMaxVideoBytes.GetFileSize(Localizer)] · + @Localizer["In force: {0}", settings.Media.EffectiveMaxVideoBytes.GetFileSize(Localizer)] · + @Localizer["At most {0}", settings.Media.HardMaxVideoBytes.GetFileSize(Localizer)] +

+
+ +
+ +

+ @Localizer["Configured: {0}", Length(settings.Media.ConfiguredMaxSeconds)] · + @Localizer["In force: {0}", Length(settings.Media.EffectiveMaxSeconds)] · + @Localizer["At most {0}", Length(settings.Media.HardMaxSeconds)] +

+
+ + +
+ + @Localizer["Use the configured values"] + + @Localizer["Discard changes"] + + @if (isSaving) + { + + } + @Localizer["Save the limits"] + +
+ } +
+ +@code { + const long Megabyte = 1024 * 1024; + + [Inject] InstanceAdminService InstanceAdminService { get; set; } + + ViewServerSettings settings; + // the overrides as being written, in megabytes and seconds; null: the configured value + long? quotaMb; + long? imageMb; + long? videoMb; + int? seconds; + List errors = []; + bool isSaving; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + settings = await InstanceAdminService.Settings(); + Reset(); + } + + async Task Retry() + { + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + void Reset() + { + errors = []; + if (settings is null) + return; + quotaMb = Megabytes(settings.Media.QuotaBytesPerRoot); + imageMb = Megabytes(settings.Media.MaxImageBytes); + videoMb = Megabytes(settings.Media.MaxVideoBytes); + seconds = settings.Media.MaxSeconds; + } + + void UseConfigured() + { + errors = []; + quotaMb = null; + imageMb = null; + videoMb = null; + seconds = null; + } + + string Quota(long bytes) => bytes <= 0 ? Localizer["no quota"] : bytes.GetFileSize(Localizer); + + static string Length(int value) => NerdRow.Duration(TimeSpan.FromSeconds(value)); + + static long? Megabytes(long? bytes) => bytes is { } value ? (long)Math.Round(value / (double)Megabyte) : null; + + // what was set stays to the byte while its megabytes are left as they were shown + static long? Bytes(long? megabytes, long? before) => + megabytes is not { } value ? null : before is { } kept && Megabytes(kept) == value ? kept : value * Megabyte; + + async Task Save() + { + errors = []; + var media = settings.Media; + if (quotaMb is < 0) + errors.Add(Localizer["The quota can't be below 0."]); + if (imageMb is { } image && (image < 1 || image * Megabyte > media.HardMaxImageBytes)) + errors.Add(Localizer["A picture's limit goes from 1 MB to {0}.", media.HardMaxImageBytes.GetFileSize(Localizer)]); + if (videoMb is { } video && (video < 1 || video * Megabyte > media.HardMaxVideoBytes)) + errors.Add(Localizer["A video's limit goes from 1 MB to {0}.", media.HardMaxVideoBytes.GetFileSize(Localizer)]); + if (seconds is { } length && (length < 1 || length > media.HardMaxSeconds)) + errors.Add(Localizer["A length goes from 1 second to {0}.", Length(media.HardMaxSeconds)]); + if (errors.Count > 0) + return; + + // only the limits changed here: what another administrator saved meanwhile to the others stays + var quotaChanged = quotaMb != Megabytes(media.QuotaBytesPerRoot); + var imageChanged = imageMb != Megabytes(media.MaxImageBytes); + var videoChanged = videoMb != Megabytes(media.MaxVideoBytes); + var secondsChanged = seconds != media.MaxSeconds; + isSaving = true; + var result = await InstanceAdminService.SaveSettings(latest => latest.Media = new ServerMediaLimitsForm + { + QuotaBytesPerRoot = quotaChanged ? Bytes(quotaMb, media.QuotaBytesPerRoot) : latest.Media.QuotaBytesPerRoot, + MaxImageBytes = imageChanged ? Bytes(imageMb, media.MaxImageBytes) : latest.Media.MaxImageBytes, + MaxVideoBytes = videoChanged ? Bytes(videoMb, media.MaxVideoBytes) : latest.Media.MaxVideoBytes, + MaxSeconds = secondsChanged ? seconds : latest.Media.MaxSeconds + }); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + settings = (ViewServerSettings)result.Data; + Reset(); + Toast.AddResponseSuccess(Localizer["The media limits are saved."]); + } +} diff --git a/Components/Admin/ModerationLogPanel.razor b/Components/Admin/ModerationLogPanel.razor new file mode 100644 index 0000000..ee5af5c --- /dev/null +++ b/Components/Admin/ModerationLogPanel.razor @@ -0,0 +1,161 @@ +@inherits LocalizableComponentBase + +@* The staff's log (/clientapi/moderator/log), newest first, 50 at a time: who acted (to administrators every login's + name; to a moderator only its own; "the command line" for PrivaPub's CLI; nobody when an act on a persona of the + acting login would tie the two), what was done, to what, and its note. Never an IP address. A moderator who isn't an + administrator reads what isn't about a login. *@ +
+
+ + @Localizer["Read again"] + +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (failed) + { +

@Localizer["The log could not be read."]

+ } + else if (entries.Count == 0) + { +

@Localizer["Nothing was done yet."]

+ } + +
    + @foreach (var entry in entries) + { +
  1. +
    + @ActionName(entry.Action) + @if (entry.TargetLabel is { Length: > 0 } label) + { + @label + } + + @entry.At.GetPassedTime(Localizer) +
    +
    + @By(entry) + @entry.Action + @if (entry.ReportId is { Length: > 0 } reportId) + { + @Localizer["Report: {0}", reportId] + } +
    + @if (entry.Note is { Length: > 0 } note) + { +

    @note

    + } +
  2. + } +
+ + @if (next is not null) + { + + @if (isLoadingMore) + { + + } + @Localizer["Load more"] + + } +
+ +@code { + const int PageSize = 50; + + [Inject] AdminService AdminService { get; set; } + + List entries = []; + // the next page's `before`; null on the last + string next; + bool failed; + bool isLoadingMore; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + var page = await AdminService.Log(null, PageSize); + failed = page is null; + entries = page?.Items ?? []; + next = page?.Next; + } + + async Task Reload() + { + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + async Task More() + { + isLoadingMore = true; + var page = await AdminService.Log(next, PageSize); + isLoadingMore = false; + if (page is null) + { + Toast.AddResponseError(Localizer["The log could not be read."]); + return; + } + + var known = entries.Select(e => e.Id).ToHashSet(); + entries.AddRange(page.Items.Where(e => known.Add(e.Id))); + next = page.Next; + } + + string By(ViewAdminAction entry) => entry switch + { + { ByYou: true } => Localizer["by you"], + { By: "cli" } => Localizer["by the command line"], + { By: { Length: > 0 } name } => Localizer["by {0}", name], + _ => Localizer["by someone not named"] + }; + + string ActionName(string action) => action switch + { + "domain_block.insert" => Localizer["Server blocked"], + "domain_block.delete" => Localizer["Server unblocked"], + "backup.create" => Localizer["Backup made"], + "backup.delete" => Localizer["Backup deleted"], + "backup.restore" => Localizer["Backup restored"], + "settings.update" => Localizer["Settings saved"], + "settings.thumbnail" => Localizer["Thumbnail changed"], + "rule.insert" => Localizer["Rule added"], + "rule.update" => Localizer["Rule changed"], + "rule.delete" => Localizer["Rule removed"], + "announcement.insert" => Localizer["Announcement written"], + "announcement.update" => Localizer["Announcement changed"], + "announcement.delete" => Localizer["Announcement deleted"], + "relay.insert" => Localizer["Relay added"], + "relay.delete" => Localizer["Relay removed"], + "report.resolve" => Localizer["Report resolved"], + "root.promote" => Localizer["Login made administrator"], + "root.demote" => Localizer["Login no longer administrator"], + "root.create" => Localizer["Login made"], + "root.delete" => Localizer["Login deleted"], + "root.reset_totp" => Localizer["Two-factor sign-in reset"], + "persona.move" => Localizer["Persona moved to another login"], + _ => action + }; +} diff --git a/Components/Admin/RelaysPanel.razor b/Components/Admin/RelaysPanel.razor new file mode 100644 index 0000000..46cf290 --- /dev/null +++ b/Components/Admin/RelaysPanel.razor @@ -0,0 +1,191 @@ +@inherits LocalizableComponentBase + +@* The relays the server's actor subscribes to (/clientapi/admin/relays): the configuration's (Federation:Relays, removed + only there) and the ones added here, each with its subscription's state. PrivaPub answers an addition or a removal at + once (202) and subscribes or unsubscribes in the background, since reaching a relay takes a fetch, so the panel reads + the list again a few seconds later. *@ +
+
+ + + + + @Localizer["Add the relay"] + +
+

@Localizer["The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here."]

+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (relays.Count == 0) + { +

@Localizer["The server subscribes to no relay."]

+ } + + @foreach (var relay in relays) + { +
+
+ @relay.Address + @switch (relay.State) + { + case "accepted": + @Localizer["Subscribed"] + break; + case "pending": + @Localizer["Waiting for the relay"] + break; + case "rejected": + @Localizer["Refused by the relay"] + break; + default: + @Localizer["Not asked yet"] + break; + } + @if (relay.Source == "configuration") + { + + @Localizer["Configured"] + + } + else + { + + @Localizer["Remove"] + + } +
+ @if (relay.ActorURI is { Length: > 0 } actor && actor != relay.Address) + { +

@Localizer["Its actor: {0}", actor]

+ } +

+ @if (relay.RequestedAt is { } requested) + { + @Localizer["Asked {0} ago", requested.GetPassedTime(Localizer)] + } + @if (relay.AnsweredAt is { } answered) + { + · @Localizer["answered {0} ago", answered.GetPassedTime(Localizer)] + } +

+ @if (relay.Source == "configuration") + { +

@Localizer["Named by the configuration (Federation:Relays): removed only there."]

+ } +
+ } +
+ +@code { + // when the list is read again after a change: subscribing to a relay is a fetch, then its answer + static readonly TimeSpan[] Rereads = [TimeSpan.FromSeconds(4), TimeSpan.FromSeconds(15)]; + + [Inject] InstanceAdminService InstanceAdminService { get; set; } + + List relays = []; + string address; + bool isSaving; + CancellationTokenSource rereading; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => relays = await InstanceAdminService.Relays(); + + async Task Add() + { + isSaving = true; + var result = await InstanceAdminService.AddRelay(address); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relays = (List)result.Data; + address = null; + Toast.AddResponseSuccess(Localizer["The relay is added: the server subscribes to it now."]); + await ReadAgainSoon(); + } + + async Task Remove(ViewRelay relay) + { + isSaving = true; + var result = await InstanceAdminService.RemoveRelay(relay.Address); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + relays = (List)result.Data; + Toast.AddResponseSuccess(Localizer["The relay is removed: the server unsubscribes from it now."]); + await ReadAgainSoon(); + } + + // the background subscription answers later: the list is read again, the last change's timer replacing the one before + async Task ReadAgainSoon() + { + if (rereading is not null) + { + await rereading.CancelAsync(); + rereading.Dispose(); + } + rereading = new(); + _ = ReadAgain(rereading.Token); + } + + async Task ReadAgain(CancellationToken cancellationToken) + { + foreach (var pause in Rereads) + { + try + { + await Task.Delay(pause, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } + var read = await InstanceAdminService.Relays(cancellationToken); + if (cancellationToken.IsCancellationRequested) + return; + // an empty answer to a list that had relays is a failed read: a change here showed its own list at once + if (read.Count == 0 && relays.Count > 0) + continue; + relays = read; + await InvokeAsync(StateHasChanged); + } + } + + public override async ValueTask DisposeAsync() + { + if (rereading is not null) + { + await rereading.CancelAsync(); + rereading.Dispose(); + } + await base.DisposeAsync(); + } +} diff --git a/Components/Admin/ReportsPanel.razor b/Components/Admin/ReportsPanel.razor index 396b4a3..bdd0059 100644 --- a/Components/Admin/ReportsPanel.razor +++ b/Components/Admin/ReportsPanel.razor @@ -1,7 +1,8 @@ @inherits LocalizableComponentBase @* The reports moderators handle (/clientapi/moderator/reports): from someone here (never said who) or from another - server, about an account and some of its posts; resolved here. *@ + server, about an account and some of its posts, a violation with the server's rules it names (their texts as PrivaPub + keeps them, a removed rule's too); resolved here. *@
@@ -57,6 +58,27 @@ @Localizer["From"] @(report.Reporter == "local" ? Localizer["someone here"] : report.Reporter)

+ @if (report.RuleIds.Count > 0) + { +
+ @Localizer["Rules it names"] +
    + @foreach (var (ruleId, text) in RulesOf(report)) + { +
  1. + @if (text is { Length: > 0 }) + { + @text + } + else + { + @Localizer["A rule no longer kept"] + } +
  2. + } +
+
+ } @if (report.Comment is { Length: > 0 } comment) {

@comment

@@ -113,6 +135,10 @@ IsLoading = false; } + // each rule the report names with its text, in the report's order (an empty text: the server no longer has it) + static IEnumerable<(string Id, string Text)> RulesOf(ViewReport report) => + report.RuleIds.Select((id, i) => (id, i < report.Rules.Count ? report.Rules[i] : null)); + string CategoryName(string category) => category switch { "spam" => Localizer["Spam"], diff --git a/Components/Admin/RulesPanel.razor b/Components/Admin/RulesPanel.razor new file mode 100644 index 0000000..29c960e --- /dev/null +++ b/Components/Admin/RulesPanel.razor @@ -0,0 +1,177 @@ +@inherits LocalizableComponentBase + +@* The server's rules (/clientapi/admin/settings): written, explained, put in order and removed here, then saved together. + A rule keeps its id while it is listed, so the reports that name it keep their meaning; one removed is kept, greyed + below, as old reports name it, and can come back. Saving starts from the settings PrivaPub has now + (InstanceAdminService.SaveSettings), so what Instance or Media limits saved stays. *@ +
+ @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (settings is null) + { +
+

@Localizer["The server's settings could not be read."]

+ @Localizer["Retry"] +
+ } + else + { +

@Localizer["Everyone reads them on the About page, and a report of a violation names the ones broken."]

+ @if (rows.Count == 0) + { +

@Localizer["The server has no rules."]

+ } +
    + @foreach (var (row, position) in Numbered) + { +
  1. +
    + @(position + 1). +
    + + +
    +
    + + + + + + + + + +
    +
    +
  2. + } +
+
+ + @Localizer["Add a rule"] + + + @Localizer["Discard changes"] + + @if (isSaving) + { + + } + @Localizer["Save the rules"] + +
+ @if (rows.Count >= ServerSettingsForm.MaxRules) + { +

@Localizer["A server has {0} rules at most.", ServerSettingsForm.MaxRules]

+ } + + @if (Removed.Count > 0) + { +
+ @Localizer["Removed rules, kept as old reports name them"] + @foreach (var rule in Removed) + { +
+ + @rule.Text + @if (rule.DeletedAt is { } deletedAt) + { + @Localizer["Removed {0} ago", deletedAt.GetPassedTime(Localizer)] + } + + + @Localizer["Bring it back"] + +
+ } +
+ } + } +
+ +@code { + [Inject] InstanceAdminService InstanceAdminService { get; set; } + + ViewServerSettings settings; + // the rules as being written, in their order: a listed rule keeps its id, a new one has none + List rows = []; + bool isSaving; + + IEnumerable<(ServerRuleForm Row, int Position)> Numbered => rows.Select((row, position) => (row, position)); + + // the rules the server keeps deleted, unless they are listed again above + List Removed => [.. (settings?.Rules ?? []).Where(r => r.IsDeleted && rows.All(row => row.Id != r.Id))]; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + settings = await InstanceAdminService.Settings(); + Reset(); + } + + async Task Retry() + { + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + void Reset() => rows = settings is null ? [] : InstanceAdminService.FormOf(settings).Rules; + + void Add() => rows.Add(new ServerRuleForm()); + + // gone from the list, it is removed when the rules are saved (kept, marked, for the reports naming it) + void Remove(ServerRuleForm row) => rows.Remove(row); + + void Move(int position, int by) + { + var row = rows[position]; + rows.RemoveAt(position); + rows.Insert(position + by, row); + } + + // listed again with its id, it is the same rule: the reports naming it name it still + void Restore(ViewServerRule rule) => rows.Add(new ServerRuleForm { Id = rule.Id, Text = rule.Text, Hint = rule.Hint }); + + async Task Save() + { + var rules = rows.Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text?.Trim(), Hint = string.IsNullOrWhiteSpace(r.Hint) ? null : r.Hint.Trim() }).ToList(); + isSaving = true; + var result = await InstanceAdminService.SaveSettings(latest => latest.Rules = rules); + isSaving = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + settings = (ViewServerSettings)result.Data; + Reset(); + Toast.AddResponseSuccess(Localizer["The rules are saved."]); + } +} diff --git a/Components/Admin/ServerBlocksPanel.razor b/Components/Admin/ServerBlocksPanel.razor index b462095..4b31a7c 100644 --- a/Components/Admin/ServerBlocksPanel.razor +++ b/Components/Admin/ServerBlocksPanel.razor @@ -1,7 +1,10 @@ @inherits LocalizableComponentBase @* Servers blocked for everyone here (/clientapi/admin/domainblocks): suspended (nothing from or to them) or silenced - (kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. *@ + (kept out of the public feeds), their media refused or not, with a comment for the public and one for the admins. + A block is published (/api/v1/instance/domain_blocks, the About page) only when asked, since that tells the blocked + server and everyone else; published, its name can be partly starred. Blocking a server again changes its block, so + Edit fills the form with it. *@
@@ -13,13 +16,19 @@
+
+ + +
+

@Localizer["A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it."]

+ Disabled="@(IsDefaultDisabled || isSaving || string.IsNullOrWhiteSpace(form.Domain))" data-server-block-save> @Localizer["Block server"] @@ -48,7 +57,16 @@ { @Localizer["Media refused"] } + @if (block.Published) + { + + @(block.Obfuscate ? Localizer["Published, partly hidden"] : Localizer["Published"]) + + } @block.CreatedAt.GetPassedTime(Localizer) + + @Localizer["Edit"] + @Localizer["Unblock"] @@ -89,6 +107,25 @@ async Task LoadData() => blocks = await AdminService.ServerBlocks(); + // a block that isn't published can't be partly hidden either + void Publish(bool published) + { + form.Published = published; + if (!published) + form.Obfuscate = false; + } + + void Edit(ViewDomainBlock block) => form = new() + { + Domain = block.Domain, + Suspend = block.Severity == "Suspend", + RejectMedia = block.RejectMedia, + Published = block.Published, + Obfuscate = block.Obfuscate, + PublicComment = block.PublicComment, + PrivateComment = block.PrivateComment + }; + async Task Block() { isSaving = true; diff --git a/Components/AnnouncementsBanner.razor b/Components/AnnouncementsBanner.razor new file mode 100644 index 0000000..5f8d269 --- /dev/null +++ b/Components/AnnouncementsBanner.razor @@ -0,0 +1,207 @@ +@inherits LocalizableComponentBase + +@* The administrators' announcements shown now (/api/v1/announcements) that the persona has not dismissed, one at a time + above the feed's tabs: their words (sanitized HTML from the administrator's Markdown), when they run, and the + reactions personas left, which the persona adds and takes back with a unicode emoji. Dismissed, one is gone for this + persona. New, changed and removed ones, and the reactions' counts, come through the user stream + (StreamingService.HearAnnouncements); the banner renders itself and never touches the feed's cards. *@ +@if (Current is { } current) +{ +
+
+ + @Localizer["Announcement"] + + @current.PublishedAt.GetPassedTime(Localizer) + + @if (ShownCount > 1) + { + + + + + @Localizer["{0} of {1}", index + 1, ShownCount] + + + + + } + + + + +
+ @if (current.StartsAt is not null || current.EndsAt is not null) + { +

@When(current)

+ } +
@((MarkupString)current.Content)
+
+ @foreach (var reaction in current.Reactions) + { + + @if (reaction.Url is { Length: > 0 } image) + { + @reaction.Name + } + else + { + @reaction.Name + } + @reaction.Count + + } + + + + @foreach (var emoji in QuickReactions) + { + @emoji + } + + +
+
+} + +@code { + static readonly string[] QuickReactions = ["👍", "❤️", "😂", "🎉", "🔥", "😮", "😢"]; + + [Inject] AnnouncementService AnnouncementService { get; set; } + [Inject] StreamingService Streaming { get; set; } + + List announcements = []; + int index; + bool isActing; + bool isReactionsOpen; + string loadedPersonaId; + + // what the persona has not dismissed, oldest first as the server lists them + List Shown => [.. announcements.Where(a => a.Read != true)]; + + int ShownCount => announcements.Count(a => a.Read != true); + + // the one in view, or none + Announcement Current => Shown is [_, ..] shown ? shown[Math.Clamp(index, 0, shown.Count - 1)] : null; + + protected override void OnInitialized() => Streaming.Announced += OnAnnounced; + + // another persona reads its own: its dismissals and reactions + protected override async Task OnParametersSetAsync() + { + if (CascadingState.PersonaId is null || CascadingState.PersonaId == loadedPersonaId) + return; + loadedPersonaId = CascadingState.PersonaId; + + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + announcements = await AnnouncementService.List(); + index = 0; + await Streaming.HearAnnouncements(true); + } + + string When(Announcement announcement) + { + var format = announcement.AllDay ? SUtility.dateTimeFormat : SUtility.dateTimeFormatExt; + var starts = announcement.StartsAt?.ToLocalTime().ToString(format); + var ends = announcement.EndsAt?.ToLocalTime().ToString(format); + return (starts, ends) switch + { + ({ } from, { } to) => Localizer["From {0} to {1}", from, to], + ({ } from, null) => Localizer["From {0}", from], + _ => Localizer["until {0}", ends] + }; + } + + async Task Dismiss() + { + if (Current is not { } announcement) + return; + isActing = true; + var result = await AnnouncementService.Dismiss(announcement.Id); + isActing = false; + if (result.IsInvalid) + { + Toast.AddResponseError(result); + return; + } + + announcement.Read = true; + index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1)); + } + + // the counts are read again afterwards: the stream may have told of this very reaction already + async Task React(string emoji, bool add) + { + if (Current is not { } announcement) + return; + isActing = true; + var result = await AnnouncementService.React(announcement.Id, emoji, add); + if (result.IsInvalid) + { + isActing = false; + Toast.AddResponseError(result); + return; + } + + var position = index; + announcements = await AnnouncementService.List(); + index = Math.Clamp(position, 0, Math.Max(0, ShownCount - 1)); + isActing = false; + } + + Task OnAnnounced(AnnouncementMessage message) => InvokeAsync(() => + { + switch (message.Event) + { + case "announcement" when message.Announcement is { } changed: + var at = announcements.FindIndex(a => a.Id == changed.Id); + if (at < 0) + announcements.Add(changed); + else + announcements[at] = changed; + break; + case "announcement.delete" when message.DeletedId is { } id: + announcements.RemoveAll(a => a.Id == id); + break; + case "announcement.reaction" when message.Reaction is { } reaction && announcements.Find(a => a.Id == reaction.AnnouncementId) is { } reacted: + var known = reacted.Reactions.Find(r => r.Name == reaction.Name); + reacted.Reactions = reaction.Count <= 0 + ? [.. reacted.Reactions.Where(r => r.Name != reaction.Name)] + : known is null + ? [.. reacted.Reactions, new AnnouncementReaction { Name = reaction.Name, Count = reaction.Count, Me = false }] + : [.. reacted.Reactions.Select(r => r.Name == reaction.Name ? new AnnouncementReaction { Name = r.Name, Count = reaction.Count, Me = r.Me, Url = r.Url } : r)]; + break; + default: + return; + } + index = Math.Clamp(index, 0, Math.Max(0, ShownCount - 1)); + StateHasChanged(); + }); + + public override async ValueTask DisposeAsync() + { + Streaming.Announced -= OnAnnounced; + await Streaming.HearAnnouncements(false); + await base.DisposeAsync(); + } +} diff --git a/Components/ReportDialog.razor b/Components/ReportDialog.razor index 5417240..d4f6231 100644 --- a/Components/ReportDialog.razor +++ b/Components/ReportDialog.razor @@ -1,7 +1,9 @@ @inherits LocalizableComponentBase @* Reports an account, and the post it was opened from, to the persona's server's moderators (/api/v1/reports). - Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. *@ + Forwarded, a remote account's server gets the report too, from PrivaPub's own actor: it never names the persona. A + violation names the server's rules it breaks (/api/v1/instance/rules, sent as rule_ids); it is offered only while the + server has rules. *@ @Localizer["Report {0}", Account.Name] @@ -11,8 +13,31 @@ + @if (choices.Count > 0) + { + + } + @if (form.Category is ReportCategory.Violation) + { +
+ @Localizer["Which of the server's rules does it break?"] + @foreach (var choice in choices) + { + + } +
+ } @if (Host is { } host) @@ -30,6 +55,7 @@ @code { [Inject] AccountService AccountService { get; set; } + [Inject] AboutService AboutService { get; set; } [Parameter, EditorRequired] public Account Account { get; set; } /// The post it was opened from, sent with the report. @@ -39,17 +65,41 @@ ReportForm form = new(); bool isSending; string error; + // the server's rules, each ticked or not, for a violation + List choices = []; // the account's own server, for a remote account string Host => Account.Acct?.Split('@') is [_, { Length: > 0 } host] ? host : null; - protected override void OnInitialized() => IsLoading = false; + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() => choices = [.. (await AboutService.Rules()).Select(rule => new RuleChoice { Rule = rule })]; async Task Send() { - isSending = true; error = null; - var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, Host is not null && form.Forward); + var ruleIds = form.Category is ReportCategory.Violation ? choices.Where(c => c.Picked).Select(c => c.Rule.Id).ToList() : []; + if (form.Category is ReportCategory.Violation && ruleIds.Count == 0) + { + error = Localizer["Tick the rules it breaks."]; + return; + } + + isSending = true; + var result = await AccountService.Report(Account.Id, Post is null ? [] : [Post.Id], form.Comment, form.Category, ruleIds, + Host is not null && form.Forward); isSending = false; if (result.IsInvalid) { @@ -62,4 +112,10 @@ } Task Closed(bool open) => open ? Task.CompletedTask : OnClosed.InvokeAsync(); + + sealed class RuleChoice + { + public InstanceRule Rule { get; init; } + public bool Picked { get; set; } + } } diff --git a/MastodonJsonContext.cs b/MastodonJsonContext.cs index b815b94..e357905 100644 --- a/MastodonJsonContext.cs +++ b/MastodonJsonContext.cs @@ -59,6 +59,14 @@ namespace decePubClient; [JsonSerializable(typeof(GroupedNotifications))] [JsonSerializable(typeof(MarkersForm))] [JsonSerializable(typeof(UnreadCount))] +[JsonSerializable(typeof(InstanceRule))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(InstanceDocument))] +[JsonSerializable(typeof(TermsOfService))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Announcement))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(AnnouncementReactionEvent))] public partial class MastodonJsonContext : JsonSerializerContext { } diff --git a/Models/APIs.cs b/Models/APIs.cs index 719e8e6..55222df 100644 --- a/Models/APIs.cs +++ b/Models/APIs.cs @@ -50,6 +50,18 @@ public static class APIs public const string POST_DeletePost = "clientapi/post/delete"; public const string GET_ModeratorReports = "clientapi/moderator/reports"; public static string POST_M_ResolveReport(string reportId) => $"clientapi/moderator/reports/{Uri.EscapeDataString(reportId)}/resolve"; + public const string GET_ModeratorLog = "clientapi/moderator/log"; + public const string GET_AdminSettings = "clientapi/admin/settings"; + public const string POST_AdminSettings = "clientapi/admin/settings"; + public const string POST_AdminThumbnail = "clientapi/admin/settings/thumbnail"; + public const string DELETE_AdminThumbnail = "clientapi/admin/settings/thumbnail"; + public const string GET_AdminAnnouncements = "clientapi/admin/announcements/list"; + public const string POST_AdminAnnouncementInsert = "clientapi/admin/announcements/insert"; + public const string POST_AdminAnnouncementUpdate = "clientapi/admin/announcements/update"; + public static string POST_M_AdminAnnouncementDelete(string id) => $"clientapi/admin/announcements/delete?id={Uri.EscapeDataString(id)}"; + public const string GET_AdminRelays = "clientapi/admin/relays"; + public const string POST_AdminRelayInsert = "clientapi/admin/relays/insert"; + public static string POST_M_AdminRelayDelete(string address) => $"clientapi/admin/relays/delete?address={Uri.EscapeDataString(address)}"; public const string GET_AdminDomainBlocks = "clientapi/admin/domainblocks/list"; public const string POST_AdminDomainBlock = "clientapi/admin/domainblocks/insert"; public static string POST_M_AdminDomainUnblock(string domain) => $"clientapi/admin/domainblocks/delete?domain={Uri.EscapeDataString(domain)}"; @@ -111,6 +123,15 @@ public static class APIs public static string PUT_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}"; public static string DELETE_M_Media(string mediaId) => $"api/v1/media/{Uri.EscapeDataString(mediaId)}"; public const string GET_InstanceV2 = "api/v2/instance"; + public const string GET_InstanceRules = "api/v1/instance/rules"; + public const string GET_InstanceExtendedDescription = "api/v1/instance/extended_description"; + public const string GET_InstancePrivacyPolicy = "api/v1/instance/privacy_policy"; + public const string GET_InstanceTermsOfService = "api/v1/instance/terms_of_service"; + public const string GET_InstanceDomainBlocks = "api/v1/instance/domain_blocks"; + public const string GET_Announcements = "api/v1/announcements"; + public static string POST_M_DismissAnnouncement(string announcementId) => $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/dismiss"; + public static string M_AnnouncementReaction(string announcementId, string emoji) => + $"api/v1/announcements/{Uri.EscapeDataString(announcementId)}/reactions/{Uri.EscapeDataString(emoji)}"; public const string DELETE_ProfileAvatar = "api/v1/profile/avatar"; public const string DELETE_ProfileHeader = "api/v1/profile/header"; public const string GET_AccountLookup = "api/v1/accounts/lookup"; diff --git a/Models/Mastodon/Announcement.cs b/Models/Mastodon/Announcement.cs new file mode 100644 index 0000000..4d7d1cb --- /dev/null +++ b/Models/Mastodon/Announcement.cs @@ -0,0 +1,35 @@ +namespace decePubClient.Models.Mastodon; + +/// /api/v1/announcements: what the server's administrators announce, shown while published and not ended, as +/// the persona sees it (Read and each reaction's Me are the persona's own; signed out, neither is said). +public class Announcement +{ + public string Id { get; set; } + /// Sanitized HTML, from the administrator's Markdown. + public string Content { get; set; } + public DateTime? StartsAt { get; set; } + public DateTime? EndsAt { get; set; } + public bool AllDay { get; set; } + public DateTime PublishedAt { get; set; } + public DateTime UpdatedAt { get; set; } + /// Whether the persona dismissed it. + public bool? Read { get; set; } + public List Reactions { get; set; } = []; +} + +/// A unicode emoji chosen on an announcement, how many personas chose it, and whether this one did. +public class AnnouncementReaction +{ + public string Name { get; set; } + public int Count { get; set; } + public bool? Me { get; set; } + public string Url { get; set; } +} + +/// The streaming API's announcement.reaction payload: the emoji, how many chose it now, and on which. +public class AnnouncementReactionEvent +{ + public string Name { get; set; } + public int Count { get; set; } + public string AnnouncementId { get; set; } +} diff --git a/Models/Mastodon/InstanceV2.cs b/Models/Mastodon/InstanceV2.cs index f2e9a15..25f3959 100644 --- a/Models/Mastodon/InstanceV2.cs +++ b/Models/Mastodon/InstanceV2.cs @@ -1,11 +1,39 @@ namespace decePubClient.Models.Mastodon; -/// /api/v2/instance, the part the client reads: what an upload may be. +/// /api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact, +/// its rules, and what an upload may be. public class InstanceV2 { + public string Domain { get; set; } + public string Title { get; set; } + /// The short description, as plain text. + public string Description { get; set; } + public InstanceThumbnail Thumbnail { get; set; } = new(); + public InstanceContact Contact { get; set; } = new(); + public List Rules { get; set; } = []; public InstanceConfiguration Configuration { get; set; } = new(); } +public class InstanceThumbnail +{ + public string Url { get; set; } +} + +/// Who answers for the server: an address, and the persona its administrator named (null when none). +public class InstanceContact +{ + public string Email { get; set; } + public Account Account { get; set; } +} + +/// One of the server's rules (/api/v1/instance/rules and the instance's own), in their order. +public class InstanceRule +{ + public string Id { get; set; } + public string Text { get; set; } + public string Hint { get; set; } +} + public class InstanceConfiguration { public MediaConfiguration MediaAttachments { get; set; } = new(); @@ -21,3 +49,34 @@ public class MediaConfiguration public long VideoMatrixLimit { get; set; } public int VideoFrameRateLimit { get; set; } } + +/// /api/v1/instance/extended_description and /privacy_policy: the server's text as sanitized HTML, and when it +/// last changed. +public class InstanceDocument +{ + public DateTime? UpdatedAt { get; set; } + public string Content { get; set; } +} + +/// /api/v1/instance/terms_of_service (Mastodon 4.4): the one version PrivaPub keeps; none while it has none +/// (404). +public class TermsOfService +{ + /// yyyy-MM-dd. + public string EffectiveDate { get; set; } + /// Whether they apply already (false while their date is ahead). + public bool Effective { get; set; } + public string Content { get; set; } + public string SucceededBy { get; set; } +} + +/// /api/v1/instance/domain_blocks: a block the administrator published; its domain partly starred when it is +/// obfuscated, its digest the SHA-256 of the whole name. +public class PublicDomainBlock +{ + public string Domain { get; set; } + public string Digest { get; set; } + /// suspend, silence or noop. + public string Severity { get; set; } + public string Comment { get; set; } +} diff --git a/Models/Mastodon/PrivaPubApi.cs b/Models/Mastodon/PrivaPubApi.cs index 33bec2c..b146b17 100644 --- a/Models/Mastodon/PrivaPubApi.cs +++ b/Models/Mastodon/PrivaPubApi.cs @@ -254,8 +254,10 @@ public class ReportRequest public string AccountId { get; set; } public List StatusIds { get; set; } = []; public string Comment { get; set; } - /// spam, legal or other. + /// spam, legal, violation or other. public string Category { get; set; } + /// The server's rules a violation breaks (only a violation names rules). + public List RuleIds { get; set; } = []; public bool Forward { get; set; } } diff --git a/Models/Types/ReportCategory.cs b/Models/Types/ReportCategory.cs index e9fd597..3096b7f 100644 --- a/Models/Types/ReportCategory.cs +++ b/Models/Types/ReportCategory.cs @@ -1,10 +1,11 @@ namespace decePubClient.Models.Types { - /// Why an account is reported (Mastodon's categories, without its server rules, which PrivaPub has none of). + /// Why an account is reported (Mastodon's categories). A violation names the server's rules it breaks. public enum ReportCategory { Other = 0, Spam = 1, - Legal = 2 + Legal = 2, + Violation = 3 } } diff --git a/Pages/About.razor b/Pages/About.razor new file mode 100644 index 0000000..ec2df43 --- /dev/null +++ b/Pages/About.razor @@ -0,0 +1,223 @@ +@page "/about" +@inherits LocalizableComponentBase + +@Localizer["About {0}", instance?.Title ?? "PrivaPub"] + +@* What this server says of itself, to anyone, signed in or not (AboutService, the Mastodon instance API without + credentials): its name, short description and thumbnail, its long description, who answers for it, its rules, its + privacy policy and terms of service, and the domain blocks its administrator published (an obfuscated one partly + starred, as the server gives it). The documents are the server's sanitized HTML. *@ +
+ +
+ + @if (IsLoading) + { +

+ + @Localizer["loading"] +

+ } + else if (instance is null) + { +
+

@Localizer["The server's description could not be read."]

+ @Localizer["Retry"] +
+ } + else + { +
+ @if (instance.Thumbnail.Url is { Length: > 0 } thumbnail) + { + @* decorative: the server's name follows *@ + + } +
+

@instance.Title

+ @if (instance.Domain is { Length: > 0 } domain) + { +

@domain

+ } + @if (instance.Description is { Length: > 0 } summary) + { +

@summary

+ } +
+
+ + @if (description?.Content is { Length: > 0 } extended) + { +
+
@((MarkupString)extended)
+ @if (description.UpdatedAt is { } describedAt) + { +

@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]

+ } +
+ } + + @if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null) + { + + } + +
+

@Localizer["Rules"]

+ @if (instance.Rules.Count == 0) + { +

@Localizer["The server has no rules."]

+ } + else + { +
    + @foreach (var rule in instance.Rules) + { +
  1. + @rule.Text + @if (rule.Hint is { Length: > 0 } hint) + { +

    @hint

    + } +
  2. + } +
+ } +
+ + @if (privacy?.Content is { Length: > 0 } policy) + { +
+

@Localizer["Privacy policy"]

+
@((MarkupString)policy)
+ @if (privacy.UpdatedAt is { } policyAt) + { +

@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]

+ } +
+ } + + @if (terms?.Content is { Length: > 0 } termsText) + { +
+

@Localizer["Terms of service"]

+ @if (TermsDate is { } effectiveOn) + { +

+ @(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn]) +

+ } +
@((MarkupString)termsText)
+
+ } + + @if (blocks.Count > 0) + { +
+

@Localizer["Moderated servers"]

+

@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]

+
    + @foreach (var block in blocks) + { +
  • +
    + @block.Domain + @switch (block.Severity) + { + case "suspend": + @Localizer["Suspended"] + break; + case "silence": + @Localizer["Silenced"] + break; + default: + @Localizer["Limited"] + break; + } +
    + @if (block.Comment is { Length: > 0 } reason) + { +

    @reason

    + } +
  • + } +
+
+ } + } + +
+ +
+ +@code { + [Inject] AboutService AboutService { get; set; } + + InstanceV2 instance; + InstanceDocument description; + InstanceDocument privacy; + TermsOfService terms; + List blocks = []; + + // the terms' date as the reader writes dates + string TermsDate => DateTime.TryParseExact(terms?.EffectiveDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, + System.Globalization.DateTimeStyles.None, out var on) ? on.ToString(SUtility.dateTimeFormat) : null; + + protected override async Task OnInitializedAsync() + { + if (CascadingState.IsOffline) + { + WhenOnline(LoadData); + StartOnlinePolling(); + IsLoading = false; + return; + } + + await LoadData(); + IsLoading = false; + } + + async Task LoadData() + { + var reading = AboutService.Instance(fresh: true); + var describing = AboutService.ExtendedDescription(); + var policing = AboutService.PrivacyPolicy(); + var terming = AboutService.Terms(); + var blocking = AboutService.DomainBlocks(); + await Task.WhenAll(reading, describing, policing, terming, blocking); + instance = reading.Result; + description = describing.Result; + privacy = policing.Result; + terms = terming.Result; + blocks = blocking.Result; + } + + async Task Retry() + { + IsLoading = true; + await LoadData(); + IsLoading = false; + } + + // signed in, the contact's profile in the app; signed out, its own page + string AccountLink(Account account) => + CascadingState.IsSignedIn ? $"account/{Uri.EscapeDataString(account.Id)}" : account.Url; +} diff --git a/Pages/Administration.razor b/Pages/Administration.razor index ea916f1..5943c28 100644 --- a/Pages/Administration.razor +++ b/Pages/Administration.razor @@ -4,30 +4,15 @@ @Localizer["Administration"] -@* Moderators handle reports; administrators also block servers for everyone, read the federation statistics, and back - up and restore the server. The - other sections wait for PrivaPub endpoints that do not exist yet. *@ - +@* Moderators handle reports and read the staff's log. Administrators also say what the server is (Instance), set its + rules, write its announcements, block servers for everyone, choose its relays and media limits, read the federation + statistics, and back up and restore the server. PrivaPub checks the role on every call; the sections only follow it. + Each section is a collapse marked data-admin-section; its panel loads when the page opens. *@
- -
-

- @Localizer["Users"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - +

@Localizer["Reports"] @@ -40,8 +25,60 @@ + +

+

+ @Localizer["Moderation log"] +

+
+ +
+ +
+
+ + - + +
+

+ @Localizer["Instance"] +

+
+ +
+ +
+
+
+ + +
+

+ @Localizer["Rules"] +

+
+ +
+ +
+
+
+ + +
+

+ @Localizer["Announcements"] +

+
+ +
+ +
+
+
+ +

@Localizer["Blocked servers"] @@ -54,7 +91,33 @@ - + +

+

+ @Localizer["Relays"] +

+
+ +
+ +
+
+ + + +
+

+ @Localizer["Media limits"] +

+
+ +
+ +
+
+
+ +

@Localizer["Statistics"] @@ -67,7 +130,7 @@ - +

@Localizer["Backups"] @@ -81,144 +144,6 @@ - -

-

- @Localizer["ActivityPub?"] -

-
- -
-

- @Localizer["Empty"] -

-
-
- - - -
-

- @Localizer["Authentication"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Emoji?"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Frontend"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Instance"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Link formatter"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Metadata"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Policies"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
- - -
-

- @Localizer["Upload"] -

-
- -
-

- @Localizer["Empty"] -

-
-
-
-
- -@code { -} diff --git a/Pages/Index.razor b/Pages/Index.razor index 284771f..ec3cf2f 100644 --- a/Pages/Index.razor +++ b/Pages/Index.razor @@ -10,7 +10,7 @@ default on phones. Pages of older posts load as the end comes near (wwwroot/js/feed.js). Newer ones come through PrivaPub's streaming API (StreamingService) for the feeds it streams, and are polled every 45 seconds while the page is visible otherwise, or while the stream is down; either way they wait behind the "new" button. A deletion or an edit - the stream tells is applied at once. *@ + the stream tells is applied at once. The administrators' announcements show above the tabs (AnnouncementsBanner). *@
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@ @@ -29,6 +29,8 @@ } + @* the administrators' announcements: a component of its own, outside the virtualized cards *@ + diff --git a/Pages/Login.razor b/Pages/Login.razor index 9f2b3bb..cccc44d 100644 --- a/Pages/Login.razor +++ b/Pages/Login.razor @@ -6,7 +6,9 @@ one (CascadingState.LoadSession picks the last one used, or the only one). A server without an administrator offers its setup (Setup); anyone else joins through a group's invitation (Join). A forgotten password is recovered through the login's verified address (PasswordRecovery). With two-factor sign-in on, PrivaPub answers a right password with a - 428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). *@ + 428, and the same form goes again with the authenticator app's six digits or a recovery code (LoginForm.OneTimeCode). + The heading names the server as its administrator calls it (/api/v2/instance, read without credentials); About tells + the rest. *@
@@ -26,7 +28,7 @@ } -

@Localizer["Sign in to PrivaPub"]

+

@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]

@if (needsCode) { @* The name and the password stay in the form, out of sight, and go again with the code. *@ @@ -93,6 +95,9 @@

@Localizer["Have an invitation?"] @Localizer["Join with an invitation"]

+

+ @Localizer["About this server"] +

} else if (CascadingState.Personas.Count == 0) @@ -134,11 +139,14 @@ @code { [Inject] AuthService AuthService { get; set; } + [Inject] AboutService AboutService { get; set; } readonly LoginForm form = new(); string error; bool isWorking; bool setupPending; + // the server's own name, once read + string serverTitle; /// PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on. bool needsCode; /// The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits. @@ -160,7 +168,11 @@ IsLoading = false; } - async Task LoadData() => setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending(); + async Task LoadData() + { + serverTitle = (await AboutService.Instance())?.Title; + setupPending = !CascadingState.IsSignedIn && await AuthService.SetupPending(); + } async Task SignIn() { diff --git a/Program.cs b/Program.cs index 82e8acf..72149e0 100644 --- a/Program.cs +++ b/Program.cs @@ -44,6 +44,9 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Resources/AllStrings.it.resx b/Resources/AllStrings.it.resx index 3b2c139..34c99a9 100644 --- a/Resources/AllStrings.it.resx +++ b/Resources/AllStrings.it.resx @@ -41,9 +41,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - ActivityPub? - Amministrazione @@ -53,9 +50,6 @@ Audio - - Autenticazione - Blocca @@ -83,9 +77,6 @@ Diretto - - Emoji? - Vuoto @@ -101,9 +92,6 @@ Solo follower - - Frontend - Generale @@ -125,9 +113,6 @@ Colore del tema chiaro - - Formattazione dei link - caricamento @@ -149,9 +134,6 @@ Visibilità del messaggio: {0} - - Metadati - Manca il contenuto: scrivi un messaggio o allega un file @@ -167,9 +149,6 @@ Altro - - Regole - Pubblica @@ -2597,4 +2576,514 @@ scritti / scartati / falliti + + "{0}" non è una data. + + + (nella configurazione del server, Registrations:Mode) + + + 0: nessuna quota + + + Una durata va da 1 secondo a {0}. + + + Il limite di un'immagine va da 1 MB a {0}. + + + Un blocco pubblicato compare nella pagina Informazioni e nell'API dell'istanza: il server bloccato, e chiunque, può leggerlo. + + + Una regola non più conservata + + + Un server ha al massimo {0} regole. + + + Il limite di un video va da 1 MB a {0}. + + + Informazioni sul server + + + Informazioni su {0} + + + Aggiungi una regola + + + Aggiungi il relay + + + Tutto il giorno + + + Un annuncio non può finire prima di cominciare. + + + Annuncio + + + Annuncio modificato + + + Annuncio eliminato + + + Annuncio scritto + + + Annunci + + + Chiesto {0} fa + + + Al massimo {0} + + + Torna all'immagine predefinita + + + Backup eliminato + + + Backup fatto + + + Backup ripristinato + + + Entrambi facoltativi: passata la fine, non è più mostrato. + + + Ripristinala + + + Modificato {0} fa + + + Configurato + + + Configurato: {0} + + + Contatto + + + Indirizzo di contatto + + + Persona di contatto + + + Eliminare l'annuncio? + + + Descrizione (Markdown) + + + Annulla le modifiche + + + Ignorato da {0} + + + Bozza + + + Modifica l'annuncio + + + Vuoto, un limite è quello della configurazione del server. Ogni modifica vale subito, dal prossimo caricamento. + + + Vuoti, il server non ha termini. Senza una data, i nuovi termini valgono da oggi. + + + Concluso + + + Chiunque le legge nella pagina Informazioni, e una segnalazione di violazione indica quelle infrante. + + + Spiegazione + + + Dal {0} + + + Dal {0} al {1} + + + Nascondi parte del nome + + + In vigore dal + + + In vigore dal {0} + + + In vigore dal {0} + + + In vigore: {0} + + + Sparisce per tutti, con le sue reazioni. + + + Il suo attore: {0} + + + Immagine più grande + + + Video o audio più grande + + + Modificato il {0} + + + Limitato + + + Login eliminato + + + Login creato + + + Login reso amministratore + + + Login non più amministratore + + + Video o audio più lungo + + + MB + + + Media che un login può tenere in tutto + + + Limiti dei media + + + Server moderati + + + Registro di moderazione + + + Sposta giù + + + Sposta su + + + Indicato dalla configurazione (Federation:Relays): si toglie solo lì. + + + Nuovo annuncio + + + Annuncio successivo + + + Annuncio precedente + + + Ancora nessun annuncio. + + + Nessuna persona di contatto + + + Nessuna persona di questo server corrisponde. + + + Nessuno + + + Nessuna: il server non indica nessuno. + + + Non ancora chiesto + + + Non è stato ancora fatto nulla. + + + Persona spostata a un altro login + + + Testo semplice, come lo danno l'API dell'istanza, NodeInfo e l'attore del server. + + + Informativa sulla privacy + + + Informativa sulla privacy (Markdown) + + + Pubblicalo + + + Pubblica l'attività settimanale + + + Pubblicato + + + Pubblicato, in parte nascosto + + + Pubblicato: mostrato a ogni persona + + + Rileggi + + + Rifiutato dal relay + + + Relay aggiunto + + + Relay tolto + + + Relay + + + Togli la regola + + + Togliere l'anteprima? + + + Regole tolte, conservate perché le vecchie segnalazioni le indicano + + + Tolta {0} fa + + + Segnalazione risolta + + + Segnalazione: {0} + + + Regola aggiunta + + + Regola modificata + + + Regola tolta + + + Regole + + + Regole indicate + + + Salva i limiti + + + Salva le regole + + + Salvata appena scelta: al massimo {0}, adattata a 1200×630 e privata dei metadati. + + + Salvato {0} fa da {1} + + + Cerca le persone di questo server + + + Server bloccato + + + Nome del server + + + Server sbloccato + + + Impostazioni salvate + + + Descrizione breve + + + Mostrato ora + + + Accedi a {0} + + + Iscrizioni + + + Iscritto + + + Termini di servizio + + + Termini di servizio (Markdown) + + + L'annuncio è eliminato. + + + L'annuncio è salvato. + + + La persona di contatto è mostrata a tutti come chi gestisce questo server. + + + L'indirizzo https dell'attore del relay, o della sua inbox. I post pubblici di questo server gli arrivano, e i suoi arrivano qui. + + + Non è stato possibile leggere il registro. + + + I limiti dei media sono salvati. + + + L'immagine caricata viene eliminata, e il server torna a mostrare quella predefinita. + + + La quota non può essere sotto 0. + + + Il relay è aggiunto: il server ora si iscrive. + + + Il relay è tolto: il server ora annulla l'iscrizione. + + + L'indirizzo del relay + + + La regola + + + Le regole sono salvate. + + + Il server non ha regole. + + + Il server non è iscritto a nessun relay. + + + Non è stato possibile leggere la descrizione del server. + + + Le impostazioni del server sono salvate. + + + Non è stato possibile leggere le impostazioni del server. + + + Questo server non scambia con questi, o li tiene fuori dai suoi feed pubblici. + + + Anteprima + + + Anteprima cambiata + + + Anteprima tolta. + + + Anteprima salvata. + + + Spunta le regole che infrange. + + + Dodici settimane di post locali e di nuovi login, leggibili da chiunque. Su un server piccolo quei conteggi dicono chi si è iscritto e quando si scrive: di norma è spento. + + + Accesso a due fattori azzerato + + + Fino al + + + Usa i valori configurati + + + Viola le regole del server + + + In attesa del relay + + + Cosa significa, se serve dirlo (facoltativo) + + + Cosa dice (Markdown) + + + Quali regole del server infrange? + + + Scrivi un annuncio + + + Scritto {0} fa + + + risposto {0} fa + + + solo su invito + + + da qualcuno non nominato + + + dalla riga di comando + + + da te + + + da {0} + + + come configurato + + + https://relay.example/actor + + + nessuna quota + + + aperte a chiunque + + + secondi + + + qualcuno + + + la riga di comando + diff --git a/Resources/AllStrings.resx b/Resources/AllStrings.resx index a8a39ac..4625c7f 100644 --- a/Resources/AllStrings.resx +++ b/Resources/AllStrings.resx @@ -41,9 +41,6 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - ActivityPub? - Administration @@ -53,9 +50,6 @@ Audio - - Authentication - Block @@ -83,9 +77,6 @@ Direct - - Emoji? - Empty @@ -101,9 +92,6 @@ Followers only - - Frontend - General @@ -125,9 +113,6 @@ Light theme colour - - Link formatter - loading @@ -149,9 +134,6 @@ Message scope type: {0} - - Metadata - Missing content, either message or media @@ -167,9 +149,6 @@ Other - - Policies - Post @@ -2597,4 +2576,514 @@ written / dropped / failed + + "{0}" is not a date. + + + (set in the server's configuration, Registrations:Mode) + + + 0: no quota + + + A length goes from 1 second to {0}. + + + A picture's limit goes from 1 MB to {0}. + + + A published block is listed on the About page and in the instance API: the blocked server, and everyone, can read it. + + + A rule no longer kept + + + A server has {0} rules at most. + + + A video's limit goes from 1 MB to {0}. + + + About this server + + + About {0} + + + Add a rule + + + Add the relay + + + All day + + + An announcement can't end before it starts. + + + Announcement + + + Announcement changed + + + Announcement deleted + + + Announcement written + + + Announcements + + + Asked {0} ago + + + At most {0} + + + Back to the default picture + + + Backup deleted + + + Backup made + + + Backup restored + + + Both are optional: once its end is past, it is no longer shown. + + + Bring it back + + + Changed {0} ago + + + Configured + + + Configured: {0} + + + Contact + + + Contact address + + + Contact persona + + + Delete the announcement? + + + Description (Markdown) + + + Discard changes + + + Dismissed by {0} + + + Draft + + + Edit the announcement + + + Empty, a limit is the one the server's configuration sets. Every change applies at once, to the next upload. + + + Empty, the server has no terms. Without a date, new terms take effect today. + + + Ended + + + Everyone reads them on the About page, and a report of a violation names the ones broken. + + + Explanation + + + From {0} + + + From {0} to {1} + + + Hide part of its name + + + In effect from + + + In effect from {0} + + + In effect since {0} + + + In force: {0} + + + It disappears for everyone, with its reactions. + + + Its actor: {0} + + + Largest picture + + + Largest video or sound + + + Last changed on {0} + + + Limited + + + Login deleted + + + Login made + + + Login made administrator + + + Login no longer administrator + + + Longest video or sound + + + MB + + + Media a login may keep in all + + + Media limits + + + Moderated servers + + + Moderation log + + + Move down + + + Move up + + + Named by the configuration (Federation:Relays): removed only there. + + + New announcement + + + Next announcement + + + Previous announcement + + + No announcement yet. + + + No contact persona + + + No persona of this server matches. + + + None + + + None: the server names nobody. + + + Not asked yet + + + Nothing was done yet. + + + Persona moved to another login + + + Plain text, as the instance API, NodeInfo and the server's own actor give it. + + + Privacy policy + + + Privacy policy (Markdown) + + + Publish it + + + Publish the weekly activity + + + Published + + + Published, partly hidden + + + Published: shown to every persona + + + Read again + + + Refused by the relay + + + Relay added + + + Relay removed + + + Relays + + + Remove the rule + + + Remove the thumbnail? + + + Removed rules, kept as old reports name them + + + Removed {0} ago + + + Report resolved + + + Report: {0} + + + Rule added + + + Rule changed + + + Rule removed + + + Rules + + + Rules it names + + + Save the limits + + + Save the rules + + + Saved as soon as it is picked: at most {0}, fitted within 1200×630 and stripped of its metadata. + + + Saved {0} ago by {1} + + + Search this server's personas + + + Server blocked + + + Server name + + + Server unblocked + + + Settings saved + + + Short description + + + Shown now + + + Sign in to {0} + + + Sign-ups + + + Subscribed + + + Terms of service + + + Terms of service (Markdown) + + + The announcement is deleted. + + + The announcement is saved. + + + The contact persona is shown to everyone as the one who runs this server. + + + The https address of the relay's actor, or of its inbox. Public posts of this server go to it, and its posts come here. + + + The log could not be read. + + + The media limits are saved. + + + The picture uploaded is deleted, and the server shows its default picture again. + + + The quota can't be below 0. + + + The relay is added: the server subscribes to it now. + + + The relay is removed: the server unsubscribes from it now. + + + The relay's address + + + The rule + + + The rules are saved. + + + The server has no rules. + + + The server subscribes to no relay. + + + The server's description could not be read. + + + The server's settings are saved. + + + The server's settings could not be read. + + + This server does not exchange with these, or keeps them out of its public feeds. + + + Thumbnail + + + Thumbnail changed + + + Thumbnail removed. + + + Thumbnail saved. + + + Tick the rules it breaks. + + + Twelve weeks of local posts and new logins, for anyone to read. On a small server those counts tell who joined and when people write: it is off by default. + + + Two-factor sign-in reset + + + Until + + + Use the configured values + + + Breaks the server's rules + + + Waiting for the relay + + + What it means, if it needs saying (optional) + + + What it says (Markdown) + + + Which of the server's rules does it break? + + + Write an announcement + + + Written {0} ago + + + answered {0} ago + + + by invitation only + + + by someone not named + + + by the command line + + + by you + + + by {0} + + + as configured + + + https://relay.example/actor + + + no quota + + + open to anyone + + + seconds + + + someone + + + the command line + diff --git a/Services/AboutService.cs b/Services/AboutService.cs new file mode 100644 index 0000000..71709ba --- /dev/null +++ b/Services/AboutService.cs @@ -0,0 +1,96 @@ +using System.Net; + +using decePubClient.Extensions; +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels; + +namespace decePubClient.Services +{ + /// + /// What this server says of itself to anyone, through the Mastodon instance API, never with credentials: its title, + /// descriptions, contact and rules (/api/v2/instance, read once a session and forgotten when the administrator saves), + /// its long description, privacy policy and terms of service (sanitized HTML from the administrator's Markdown), and + /// the domain blocks it publishes. Values come back null and lists empty on failure; nothing throws. + /// + public class AboutService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + InstanceV2 instance; + + /// The instance's description, kept for the session; read again when `fresh`. + public async Task Instance(bool fresh = false, CancellationToken cancellationToken = default) + { + if (instance is not null && !fresh) + return instance; + using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceV2, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + { + await LogFailure(response, nameof(Instance), cancellationToken); + return instance; + } + + return instance = await response.MastodonReadFromJsonAsync(cancellationToken) ?? instance; + } + + /// Drops what was read, after the administrator changed it. + public void Forget() => instance = null; + + /// The server's rules in their order (not the ones its administrator removed). + public async Task> Rules(CancellationToken cancellationToken = default) + { + using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceRules, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Rules), cancellationToken); + return []; + } + + public Task ExtendedDescription(CancellationToken cancellationToken = default) => + Document(APIs.Mastodon.GET_InstanceExtendedDescription, nameof(ExtendedDescription), cancellationToken); + + public Task PrivacyPolicy(CancellationToken cancellationToken = default) => + Document(APIs.Mastodon.GET_InstancePrivacyPolicy, nameof(PrivacyPolicy), cancellationToken); + + /// The terms of service, or null while the server has none (its 404). + public async Task Terms(CancellationToken cancellationToken = default) + { + using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceTermsOfService, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken); + + if (response.StatusCode is not HttpStatusCode.NotFound) + await LogFailure(response, nameof(Terms), cancellationToken); + return null; + } + + /// The blocks the administrator published, as the server shows them (an obfuscated one partly starred). + public async Task> DomainBlocks(CancellationToken cancellationToken = default) + { + using var response = await httpService.GetTotallyAnon(APIs.Mastodon.GET_InstanceDomainBlocks, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(DomainBlocks), cancellationToken); + return []; + } + + async Task Document(string route, string caller, CancellationToken cancellationToken) + { + using var response = await httpService.GetTotallyAnon(route, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync(cancellationToken); + + await LogFailure(response, caller, cancellationToken); + return null; + } + + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.NotFound)) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AboutService)}.{caller}", cancellationToken); + } + } +} diff --git a/Services/AccountService.cs b/Services/AccountService.cs index f06edc5..13ad563 100644 --- a/Services/AccountService.cs +++ b/Services/AccountService.cs @@ -286,10 +286,11 @@ namespace decePubClient.Services return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } - /// Reports an account, with the posts named, to the persona's server's moderators; forwarded, its own server - /// gets the report too, from PrivaPub's actor and never naming the persona. - public async Task Report(string accountId, List statusIds, string comment, ReportCategory category, bool forward, - CancellationToken cancellationToken = default) + /// Reports an account, with the posts named, to the persona's server's moderators (a violation with the + /// server's rules it breaks); forwarded, its own server gets the report too, from PrivaPub's actor and never naming + /// the persona. + public async Task Report(string accountId, List statusIds, string comment, ReportCategory category, List ruleIds, + bool forward, CancellationToken cancellationToken = default) { using var response = await httpService.Post(APIs.Mastodon.POST_Reports, new ReportRequest { @@ -297,6 +298,7 @@ namespace decePubClient.Services StatusIds = statusIds, Comment = comment?.Trim(), Category = category.ToString().ToLowerInvariant(), + RuleIds = category is ReportCategory.Violation ? ruleIds : [], Forward = forward }, cancellationToken: cancellationToken); return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); diff --git a/Services/AdminService.cs b/Services/AdminService.cs index 3007a29..79d821b 100644 --- a/Services/AdminService.cs +++ b/Services/AdminService.cs @@ -9,10 +9,11 @@ using PrivaPub.ClientModels.Admin; namespace decePubClient.Services { /// - /// What PrivaPub's moderators and administrators do as the root: reports (/clientapi/moderator), servers blocked - /// for the whole instance, the federation statistics (the overview, the servers, one server's days and events, the - /// ledger's events, the server's own days) and the backups (/clientapi/admin). PrivaPub checks the root's role on every - /// call. Lists come back empty and values null on failure; nothing throws. + /// What PrivaPub's moderators and administrators do as the root: reports and the staff's log (/clientapi/moderator), + /// servers blocked for the whole instance, the federation statistics (the overview, the servers, one server's days and + /// events, the ledger's events, the server's own days) and the backups (/clientapi/admin). The server's own settings, + /// announcements and relays are InstanceAdminService's. PrivaPub checks the root's role on every call. Lists come back + /// empty and values null on failure; nothing throws. /// public class AdminService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) { @@ -34,6 +35,21 @@ namespace decePubClient.Services return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); } + /// One page of the staff's log, newest first: those before `before` (the previous page's Next) when given. + /// Administrators read all of it with login names; a moderator reads what isn't about a login. Null on failure. + public async Task Log(string before, int limit, CancellationToken cancellationToken = default) + { + var parameters = new List { $"limit={limit}" }; + if (before is { Length: > 0 }) + parameters.Add($"before={Uri.EscapeDataString(before)}"); + using var response = await httpService.Get(APIs.ClientApi.GET_ModeratorLog, [.. parameters], cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync(cancellationToken); + + await LogFailure(response, nameof(Log), cancellationToken); + return null; + } + /// The servers blocked for everyone here, by name. public async Task> ServerBlocks(CancellationToken cancellationToken = default) { diff --git a/Services/AnnouncementService.cs b/Services/AnnouncementService.cs new file mode 100644 index 0000000..9fbff56 --- /dev/null +++ b/Services/AnnouncementService.cs @@ -0,0 +1,48 @@ +using System.Net; + +using decePubClient.Extensions; +using decePubClient.Models; +using decePubClient.Models.Mastodon; + +using PrivaPub.ClientModels; + +namespace decePubClient.Services +{ + /// + /// The administrators' announcements as the current persona reads them (/api/v1/announcements): the ones shown now, + /// dismissed one by one, and reacted to with a unicode emoji (eight different ones at most on each). Dismissals and + /// reactions are the persona's own and never leave PrivaPub; the stream tells of new, changed and removed ones + /// (StreamingService.Announced). The administrators write them through InstanceAdminService. + /// + public class AnnouncementService(IHttpService httpService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + /// The announcements shown now, with whether the persona dismissed each. + public async Task> List(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.Mastodon.GET_Announcements, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.MastodonReadFromJsonAsync>(cancellationToken) ?? []; + + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not (int)HttpStatusCode.Gone) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AnnouncementService)}.{nameof(List)}", cancellationToken); + return []; + } + + public async Task Dismiss(string announcementId, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.Mastodon.POST_M_DismissAnnouncement(announcementId), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + /// Adds the persona's reaction, or takes it back; PrivaPub refuses anything but one unicode emoji (422). + public async Task React(string announcementId, string emoji, bool add, CancellationToken cancellationToken = default) + { + var route = APIs.Mastodon.M_AnnouncementReaction(announcementId, emoji); + using var response = add + ? await httpService.Put(route, cancellationToken: cancellationToken) + : await httpService.Delete(route, cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + } +} diff --git a/Services/InstanceAdminService.cs b/Services/InstanceAdminService.cs new file mode 100644 index 0000000..cc273d0 --- /dev/null +++ b/Services/InstanceAdminService.cs @@ -0,0 +1,178 @@ +using System.Net; +using System.Net.Http.Headers; + +using decePubClient.Extensions; +using decePubClient.Models; + +using Microsoft.AspNetCore.Components.Forms; + +using PrivaPub.ClientModels; +using PrivaPub.ClientModels.Admin; + +namespace decePubClient.Services +{ + /// + /// The server's own settings from the administrator's page (PrivaPub's InstanceAdminController, /clientapi/admin): what + /// it says of itself, its rules, its thumbnail, its media limits, whether its weekly activity is published, its + /// announcements and the relays it subscribes to. PrivaPub checks the administrator against its database on every call. + /// A save replaces everything the form carries (a rule left out is removed), so SaveSettings always starts from the + /// settings as PrivaPub has them now and changes only what the caller changes. Lists come back empty and values null on + /// failure; nothing throws. + /// + public class InstanceAdminService(IHttpService httpService, AboutService aboutService, ILoggingService logger, CoalescingStringLocalizer localizer) + { + /// The settings as the page shows them, deleted rules too; null on failure. + public async Task Settings(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync(cancellationToken); + + await LogFailure(response, nameof(Settings), cancellationToken); + return null; + } + + /// The form that saves the settings exactly as they are: the rules not deleted in their order, the media + /// overrides, every text. + public static ServerSettingsForm FormOf(ViewServerSettings settings) => new() + { + Title = settings.Title, + ShortDescription = settings.ShortDescription, + Description = settings.Description, + ContactEmail = settings.ContactEmail, + ContactAccountId = settings.ContactAccountId, + PrivacyPolicy = settings.PrivacyPolicy, + TermsOfService = settings.TermsOfService, + TermsEffectiveAt = settings.TermsEffectiveAt, + Rules = [.. settings.Rules.Where(r => !r.IsDeleted).OrderBy(r => r.Position).Select(r => new ServerRuleForm { Id = r.Id, Text = r.Text, Hint = r.Hint })], + Media = new() + { + QuotaBytesPerRoot = settings.Media.QuotaBytesPerRoot, + MaxImageBytes = settings.Media.MaxImageBytes, + MaxVideoBytes = settings.Media.MaxVideoBytes, + MaxSeconds = settings.Media.MaxSeconds + }, + PublishActivity = settings.PublishActivity + }; + + /// + /// Reads the settings as PrivaPub has them now, lets `change` edit the form made of them, and saves the whole form; + /// Data is the ViewServerSettings saved. What another panel or another administrator saved meanwhile stays. + /// + public async Task SaveSettings(Action change, CancellationToken cancellationToken = default) + { + using var current = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken); + if (!current.IsSuccessStatusCode) + return await current.Content.ReadWebResult(current.StatusCode, localizer, cancellationToken); + if (await current.DefaultReadFromJsonAsync(cancellationToken) is not { } latest) + return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadGateway); + + var form = FormOf(latest); + change(form); + using var response = await httpService.Post(APIs.ClientApi.POST_AdminSettings, form, cancellationToken: cancellationToken); + return await Saved(response, cancellationToken); + } + + /// A new thumbnail, streamed from the browser's file; PrivaPub checks it, strips it and fits it within + /// 1200×630. Data is the ViewServerSettings. + public async Task SaveThumbnail(IBrowserFile file, long maximumSize, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_AdminThumbnail, (Func)(() => + { + var picture = new StreamContent(file.OpenReadStream(maximumSize, cancellationToken)); + picture.Headers.ContentType = new MediaTypeHeaderValue(file.ContentType); + return new MultipartFormDataContent { { picture, "file", file.Name } }; + }), cancellationToken: cancellationToken); + return await Saved(response, cancellationToken); + } + + /// Back to the default picture; the uploaded one is trashed. Data is the ViewServerSettings. + public async Task RemoveThumbnail(CancellationToken cancellationToken = default) + { + using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminThumbnail, cancellationToken: cancellationToken); + return await Saved(response, cancellationToken); + } + + /// Every announcement, published or not, with its reads and reactions. + public async Task> Announcements(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_AdminAnnouncements, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Announcements), cancellationToken); + return []; + } + + /// Writes a new announcement (no Id) or changes one; Data is the ViewAnnouncement. + public async Task SaveAnnouncement(AnnouncementForm form, CancellationToken cancellationToken = default) + { + var route = form.Id is { Length: > 0 } ? APIs.ClientApi.POST_AdminAnnouncementUpdate : APIs.ClientApi.POST_AdminAnnouncementInsert; + using var response = await httpService.Post(route, form, cancellationToken: cancellationToken); + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + /// Deletes an announcement, its reads and reactions with it; every persona's stream hears it go. + public async Task DeleteAnnouncement(string id, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminAnnouncementDelete(id), cancellationToken: cancellationToken); + return response.IsSuccessStatusCode ? new WebResult() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + } + + /// The relays the server subscribes to: the configuration's, then the settings'. + public async Task> Relays(CancellationToken cancellationToken = default) + { + using var response = await httpService.Get(APIs.ClientApi.GET_AdminRelays, cancellationToken: cancellationToken); + if (response.IsSuccessStatusCode) + return await response.DefaultReadFromJsonAsync>(cancellationToken) ?? []; + + await LogFailure(response, nameof(Relays), cancellationToken); + return []; + } + + /// Subscribes to a relay by its actor's or inbox's https address. PrivaPub answers 202 and subscribes in the + /// background, so the list it answers with (Data) shows the relay before its state does; read it again shortly. + public async Task AddRelay(string address, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_AdminRelayInsert, new RelayForm { Address = address?.Trim() }, + cancellationToken: cancellationToken); + return await RelayList(response, cancellationToken); + } + + /// Unsubscribes from a relay the settings named (a configured one is refused with 409); answered 202 like + /// AddRelay. + public async Task RemoveRelay(string address, CancellationToken cancellationToken = default) + { + using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminRelayDelete(address), cancellationToken: cancellationToken); + return await RelayList(response, cancellationToken); + } + + async Task Saved(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + // the title and the rules everyone reads have changed + aboutService.Forget(); + return new WebResult { Data = await response.DefaultReadFromJsonAsync(cancellationToken) }; + } + + async Task RelayList(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (!response.IsSuccessStatusCode) + return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + + return new WebResult { Data = await response.DefaultReadFromJsonAsync>(cancellationToken) ?? [] }; + } + + async Task LogFailure(HttpResponseMessage response, string caller, CancellationToken cancellationToken) + { + var result = await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); + if (result.StatusCode is not ((int)HttpStatusCode.Gone or (int)HttpStatusCode.Forbidden)) + await logger.ProcessWarning(result.ToString(localizer), $"{nameof(InstanceAdminService)}.{caller}", cancellationToken); + } + } +} diff --git a/Services/StreamingService.cs b/Services/StreamingService.cs index 7f4325d..7e08f2e 100644 --- a/Services/StreamingService.cs +++ b/Services/StreamingService.cs @@ -16,24 +16,37 @@ namespace decePubClient.Services /// what it is about (a Status or a Notification for those, the deleted id for delete). public sealed record StreamMessage(string Event, Status Status = default, Notification Notification = default, string DeletedId = default); + /// What the user stream told of the administrators' announcements: one new or changed (announcement), one + /// gone (announcement.delete, its id), or a reaction's count now (announcement.reaction). + public sealed record AnnouncementMessage(string Event, Announcement Announcement = default, string DeletedId = default, + AnnouncementReactionEvent Reaction = default); + /// /// PrivaPub's streaming API (/api/v1/streaming) as the current persona: one WebSocket, its token given as the /// socket's protocol (never in a URL), subscribed to the streams the page asks for. What comes is raised as /// StreamMessages; when the socket drops it is opened again after a pause that grows to a minute, and IsOpen tells the /// page to poll meanwhile. Nothing throws. + /// The announcements come on the user stream, which HearAnnouncements keeps subscribed whatever feed is followed; what + /// else it tells is raised only while the feed followed is the user stream itself. /// public sealed class StreamingService(TokenAuthStateProvider authStateProvider, IHttpService httpService, AppConfiguration configuration, ILoggingService logger) : IAsyncDisposable { + const string UserStream = "user"; + ClientWebSocket socket; CancellationTokenSource running; StreamSubscription current; + bool announcements; public bool IsOpen => socket?.State == WebSocketState.Open && current is not null; /// Raised for each message of the stream subscribed now. public event Func Received; + /// Raised for each announcement event, while HearAnnouncements is on. + public event Func Announced; + Uri Endpoint { get @@ -47,23 +60,49 @@ namespace decePubClient.Services /// Listens to one stream (the feed in view), or to none; a change subscribes the open socket anew. public async Task Follow(StreamSubscription subscription, CancellationToken cancellationToken = default) { - if (subscription == current && (running is not null || subscription is null)) + if (subscription == current && (running is not null || subscription is null && !announcements)) return; var previous = current; current = subscription; if (socket?.State == WebSocketState.Open) { - if (previous is not null) + // the user stream stays while the announcements are heard on it + if (previous is not null && !(announcements && previous.Stream == UserStream)) await Say("unsubscribe", previous, cancellationToken); if (subscription is not null) await Say("subscribe", subscription, cancellationToken); return; } - if (subscription is not null && running is null) + Start(); + } + + /// Hears the announcements (the user stream) or stops hearing them; the socket opens for them alone when no + /// feed is followed. + public async Task HearAnnouncements(bool hear, CancellationToken cancellationToken = default) + { + if (hear == announcements) { - running = new(); - _ = Run(running.Token); + if (hear) + Start(); + return; } + announcements = hear; + if (socket?.State == WebSocketState.Open) + { + if (current?.Stream != UserStream) + await Say(hear ? "subscribe" : "unsubscribe", new StreamSubscription(UserStream), cancellationToken); + return; + } + if (hear) + Start(); + } + + void Start() + { + if (running is not null || current is null && !announcements) + return; + running = new(); + _ = Run(running.Token); } async Task Say(string type, StreamSubscription subscription, CancellationToken cancellationToken) @@ -132,6 +171,8 @@ namespace decePubClient.Services await socket.ConnectAsync(Endpoint, cancellationToken); if (current is not null) await Say("subscribe", current, cancellationToken); + if (announcements && current?.Stream != UserStream) + await Say("subscribe", new StreamSubscription(UserStream), cancellationToken); } async Task Read(CancellationToken cancellationToken) @@ -152,6 +193,24 @@ namespace decePubClient.Services if (JsonNode.Parse(text.ToString()) is not JsonObject message || message["event"]?.GetValue() is not { } name || message["payload"]?.GetValue() is not { } payload) continue; + if (name.StartsWith("announcement", StringComparison.Ordinal)) + { + var announced = name switch + { + "announcement" => new AnnouncementMessage(name, Announcement: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), + "announcement.delete" => new AnnouncementMessage(name, DeletedId: payload), + "announcement.reaction" => new AnnouncementMessage(name, + Reaction: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), + _ => null + }; + if (announced is not null && announcements && Announced is { } listeners) + await listeners(announced); + continue; + } + // the user stream held for the announcements tells the home feed's posts and notifications too: they are the + // feed's only while it is the one followed + if ((message["stream"] as JsonArray)?.FirstOrDefault()?.GetValue() == UserStream && current?.Stream != UserStream) + continue; var told = name switch { "update" or "status.update" => new StreamMessage(name, Status: JsonSerializer.Deserialize(payload, SUtility.MastodonSerializer)), @@ -164,7 +223,8 @@ namespace decePubClient.Services } } - /// Closes the socket; the page polls from then on. + /// Closes the socket; the page polls from then on. The announcements stay asked for: the next Follow opens + /// the socket for them too. public async Task Stop() { current = null; diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 54f059e..ef7c577 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -81,6 +81,10 @@ @Localizer["Settings"] + + + @Localizer["About this server"] + diff --git a/Styles/feed.css b/Styles/feed.css index b0d320a..5f50829 100644 --- a/Styles/feed.css +++ b/Styles/feed.css @@ -62,6 +62,44 @@ padding-inline-start: 1.25rem; } + /* the server's own documents (About, announcements), rendered from its administrator's Markdown: headings and + lists spaced like a page, which a post never needs */ + .doc-content h1, + .doc-content h2, + .doc-content h3, + .doc-content h4 { + font-weight: 700; + line-height: 1.25; + margin-block: 1em 0.5em; + } + + .doc-content h1 { + font-size: 1.35em; + } + + .doc-content h2 { + font-size: 1.2em; + } + + .doc-content h3, + .doc-content h4 { + font-size: 1.05em; + } + + .doc-content > :first-child { + margin-top: 0; + } + + .doc-content ul, + .doc-content ol { + margin-block: 0 0.5em; + } + + .doc-content hr { + margin-block: 1em; + border-color: var(--neo-strong); + } + /* daisyUI's tooltip is a pill; a block of nerd stats needs the corners of a box. Hidden, it takes no room (daisyUI only makes it transparent, and one past a card's edge made a phone's feed scroll sideways), and on a touch screen it never shows: a tap opens the rows in a sheet instead (NerdTip). */