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

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
thepraandClaude Opus 5.5 committed 2026-10-17 04:32:57 +02:00
1 parent b8d7858acb
commit 4c88980374
33 files changed
+3743 -239

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
@page "/about"
@inherits LocalizableComponentBase
<Title>@Localizer["About {0}", instance?.Title ?? "PrivaPub"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto" data-about>
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (instance is null)
{
<div class="flex flex-col items-center gap-2 mt-6">
<p class="text-sm text-error">@Localizer["The server's description could not be read."]</p>
<DButton Size="DSize.Sm" Disabled="IsDefaultDisabled" OnClick="Retry">@Localizer["Retry"]</DButton>
</div>
}
else
{
<article class="flex flex-col w-full max-w-3xl rounded-xl neo-raised neo-sm overflow-hidden" data-about-server>
@if (instance.Thumbnail.Url is { Length: > 0 } thumbnail)
{
@* decorative: the server's name follows *@
<img src="@thumbnail" alt="" class="w-full aspect-[40/21] object-cover" />
}
<div class="flex flex-col gap-2 p-4 md:p-5">
<h1 class="text-2xl font-bold break-words" data-about-title>@instance.Title</h1>
@if (instance.Domain is { Length: > 0 } domain)
{
<p class="text-sm opacity-70 break-all">@domain</p>
}
@if (instance.Description is { Length: > 0 } summary)
{
<p class="text-base break-words" data-about-summary>@summary</p>
}
</div>
</article>
@if (description?.Content is { Length: > 0 } extended)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-description>
<div class="post-content doc-content break-words">@((MarkupString)extended)</div>
@if (description.UpdatedAt is { } describedAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", describedAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (instance.Contact.Email is { Length: > 0 } || instance.Contact.Account is not null)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-contact>
<h2 class="text-lg font-bold">@Localizer["Contact"]</h2>
@if (instance.Contact.Account is { } account)
{
<a class="flex items-center gap-3 p-2 rounded-lg neo-raised neo-2xs min-w-0" href="@AccountLink(account)" data-about-contact-account="@account.Id">
<img alt="" class="h-12 w-12 flex-none object-cover rounded-full" src="@(account.Avatar ?? "imgs/icon-192.png")" />
<span class="flex flex-col min-w-0">
<b class="truncate">@CustomEmojis.Text(account.Name, account.Emojis)</b>
<span class="text-xs opacity-70 truncate">@("@" + account.Acct)</span>
</span>
</a>
}
@if (instance.Contact.Email is { Length: > 0 } email)
{
<a class="link inline-flex items-center gap-2 break-all touch-target self-start" href="@($"mailto:{email}")" data-about-contact-email>
<i class="ion-md-mail" aria-hidden="true"></i>@email
</a>
}
</article>
}
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-rules>
<h2 class="text-lg font-bold">@Localizer["Rules"]</h2>
@if (instance.Rules.Count == 0)
{
<p class="text-sm opacity-70">@Localizer["The server has no rules."]</p>
}
else
{
<ol class="flex flex-col gap-2 list-decimal ps-6">
@foreach (var rule in instance.Rules)
{
<li class="break-words" data-about-rule="@rule.Id">
<span>@rule.Text</span>
@if (rule.Hint is { Length: > 0 } hint)
{
<p class="text-sm opacity-70">@hint</p>
}
</li>
}
</ol>
}
</article>
@if (privacy?.Content is { Length: > 0 } policy)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-privacy>
<h2 class="text-lg font-bold">@Localizer["Privacy policy"]</h2>
<div class="post-content doc-content break-words">@((MarkupString)policy)</div>
@if (privacy.UpdatedAt is { } policyAt)
{
<p class="text-xs opacity-70">@Localizer["Last changed on {0}", policyAt.ToLocalTime().ToString(SUtility.dateTimeFormat)]</p>
}
</article>
}
@if (terms?.Content is { Length: > 0 } termsText)
{
<article class="flex flex-col gap-2 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-terms>
<h2 class="text-lg font-bold">@Localizer["Terms of service"]</h2>
@if (TermsDate is { } effectiveOn)
{
<p class="text-xs opacity-70">
@(terms.Effective ? Localizer["In effect since {0}", effectiveOn] : Localizer["In effect from {0}", effectiveOn])
</p>
}
<div class="post-content doc-content break-words">@((MarkupString)termsText)</div>
</article>
}
@if (blocks.Count > 0)
{
<article class="flex flex-col gap-3 w-full max-w-3xl p-4 md:p-5 rounded-xl neo-raised neo-xs" data-about-blocks>
<h2 class="text-lg font-bold">@Localizer["Moderated servers"]</h2>
<p class="text-sm opacity-70">@Localizer["This server does not exchange with these, or keeps them out of its public feeds."]</p>
<ul class="flex flex-col gap-2">
@foreach (var block in blocks)
{
<li class="flex flex-col gap-1 p-2 rounded-lg neo-inset neo-2xs" data-about-block="@block.Domain">
<div class="flex flex-wrap items-center gap-2">
<b class="flex-1 min-w-0 break-all font-mono text-sm" title="@($"SHA-256 {block.Digest}")">@block.Domain</b>
@switch (block.Severity)
{
case "suspend":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Error">@Localizer["Suspended"]</DBadge>
break;
case "silence":
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft" Color="DColor.Warning">@Localizer["Silenced"]</DBadge>
break;
default:
<DBadge Size="DSize.Sm" Variant="DBadgeVariant.Soft">@Localizer["Limited"]</DBadge>
break;
}
</div>
@if (block.Comment is { Length: > 0 } reason)
{
<p class="text-xs break-words">@reason</p>
}
</li>
}
</ul>
</article>
}
}
</div>
</section>
@code {
[Inject] AboutService AboutService { get; set; }
InstanceV2 instance;
InstanceDocument description;
InstanceDocument privacy;
TermsOfService terms;
List<PublicDomainBlock> 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;
}
+86 -161
View File
@@ -4,30 +4,15 @@
<Title>@Localizer["Administration"]</Title>
@* 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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col gap-4 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Users"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="reports">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-flag text-xl"></i> <span>@Localizer["Reports"]</span>
@@ -40,8 +25,60 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="log">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-list-box text-xl"></i> <span>@Localizer["Moderation log"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<ModerationLogPanel />
</div>
</ChildContent>
</DCollapse>
<AuthorizeView Policy="@Policies.IsAdmin">
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="instance">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-information-circle text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<InstancePanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="rules">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-book text-xl"></i> <span>@Localizer["Rules"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RulesPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="announcements">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-megaphone text-xl"></i> <span>@Localizer["Announcements"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<AnnouncementsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="blocks">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-globe text-xl"></i> <span>@Localizer["Blocked servers"]</span>
@@ -54,7 +91,33 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="relays">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-radio text-xl"></i> <span>@Localizer["Relays"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<RelaysPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="media">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-images text-xl"></i> <span>@Localizer["Media limits"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<MediaLimitsPanel />
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="statistics">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-stats text-xl"></i> <span>@Localizer["Statistics"]</span>
@@ -67,7 +130,7 @@
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<DCollapse Icon="DCollapseIcon.Arrow" data-admin-section="backups">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-archive text-xl"></i> <span>@Localizer["Backups"]</span>
@@ -81,144 +144,6 @@
</DCollapse>
</AuthorizeView>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["ActivityPub?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Authentication"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Emoji?"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Frontend"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Instance"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Link formatter"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Metadata"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Policies"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2">
<i class="ion-md-albums text-xl"></i> <span>@Localizer["Upload"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
<p class="w-full text-center text-lg">
<i class="ion-ios-remove-circle-outline"></i> @Localizer["Empty"]
</p>
</div>
</ChildContent>
</DCollapse>
</div>
</section>
@code {
}
+3 -1
View File
@@ -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). *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
@* 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 @@
<i class="ion-md-create text-xl" aria-hidden="true"></i>
</DButton>
}
@* the administrators' announcements: a component of its own, outside the virtualized cards *@
<AnnouncementsBanner />
<FeedTabs Filter="Filter" Groups="groups" Lists="lists" UnreadNotifications="unreadNotifications" UnreadConversations="unreadConversations" Stats="FeedStats()" IsComposing="isComposing" OnCompose="ToggleCompose" OnGlobeFull="() => globeExpanded = true"
OnChanged="ChangeFeed" />
+15 -3
View File
@@ -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. *@
<section class="block relative w-full h-full neo-inset neo-xs rounded-xl">
<div class="flex flex-col items-center gap-6 p-4 md:p-5 w-full h-full absolute overflow-y-auto">
@@ -26,7 +28,7 @@
}
<EditForm Model="form" OnValidSubmit="SignIn" class="flex flex-col gap-4 w-full p-5 rounded-xl neo-raised neo-sm">
<DataAnnotationsValidator />
<h1 class="text-xl font-bold">@Localizer["Sign in to PrivaPub"]</h1>
<h1 class="text-xl font-bold break-words" data-login-title>@Localizer["Sign in to {0}", serverTitle ?? "PrivaPub"]</h1>
@if (needsCode)
{
@* The name and the password stay in the form, out of sight, and go again with the code. *@
@@ -93,6 +95,9 @@
<p class="text-sm text-center">
@Localizer["Have an invitation?"] <a class="link touch-target" href="join" data-join-offer>@Localizer["Join with an invitation"]</a>
</p>
<p class="text-sm text-center">
<a class="link touch-target" href="about" data-about-offer>@Localizer["About this server"]</a>
</p>
</div>
}
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;
/// <summary>PrivaPub asked for a code (its 428): the password was right, and two-factor sign-in is on.</summary>
bool needsCode;
/// <summary>The code asked for is a recovery code (two groups of five letters and digits), not the app's six digits.</summary>
@@ -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()
{