Files
decePubClient/Services/InstanceAdminService.cs
T
thepraandClaude Opus 5.5 3827d903a3
Build / Build (push) Successful in 33s
House rules: numbers through one formatter, no made-up server errors, and the rules fail the build
- Every number shown on a page goes through NumbersExtensions: counts,
  percentages, the nerd readouts and the values written into a style.
- A failure the client builds itself (offline, timeout, retries run out, an
  answer it can't read) is a 400 that names its kind; only a server's own 5XX
  passes through.
- Login, sign-up, recovery and password confirmations leave autocomplete to
  the browser, so it can offer the saved login.
- The Daisy components start with empty lists and attributes, and a
  decorative image still renders alt="".
- Arasaka.HouseRules.Analyzers checks all of this in every build, together
  with PrivaPub.ClientModels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4YtEJrgxG12Pc39Fai8Bd
2026-11-10 01:26:39 +01:00

215 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>
/// 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.
/// </summary>
public class InstanceAdminService(IHttpService httpService, AboutService aboutService, ILoggingService logger, CoalescingStringLocalizer localizer)
{
/// <summary>The settings as the page shows them, deleted rules too; null on failure.</summary>
public async Task<ViewServerSettings> Settings(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminSettings, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<ViewServerSettings>(cancellationToken);
await LogFailure(response, nameof(Settings), cancellationToken);
return null;
}
/// <summary>The form that saves the settings exactly as they are: the rules not deleted in their order, the media
/// overrides, every text.</summary>
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
};
/// <summary>
/// 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.
/// </summary>
public async Task<WebResult> SaveSettings(Action<ServerSettingsForm> 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<ViewServerSettings>(cancellationToken) is not { } latest)
return new WebResult().Invalidate(localizer["The server sent an invalid response."], (int)HttpStatusCode.BadRequest);
var form = FormOf(latest);
change(form);
using var response = await httpService.Post(APIs.ClientApi.POST_AdminSettings, form, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>A new thumbnail, streamed from the browser's file; PrivaPub checks it, strips it and fits it within
/// 1200×630. Data is the ViewServerSettings.</summary>
public async Task<WebResult> SaveThumbnail(IBrowserFile file, long maximumSize, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminThumbnail, (Func<HttpContent>)(() =>
{
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);
}
/// <summary>Back to the default picture; the uploaded one is trashed. Data is the ViewServerSettings.</summary>
public async Task<WebResult> RemoveThumbnail(CancellationToken cancellationToken = default)
{
using var response = await httpService.Delete(APIs.ClientApi.DELETE_AdminThumbnail, cancellationToken: cancellationToken);
return await Saved(response, cancellationToken);
}
/// <summary>Every announcement, published or not, with its reads and reactions.</summary>
public async Task<List<ViewAnnouncement>> Announcements(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminAnnouncements, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewAnnouncement>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Announcements), cancellationToken);
return [];
}
/// <summary>Writes a new announcement (no Id) or changes one; Data is the ViewAnnouncement.</summary>
public async Task<WebResult> 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<ViewAnnouncement>(cancellationToken) };
}
/// <summary>Deletes an announcement, its reads and reactions with it; every persona's stream hears it go.</summary>
public async Task<WebResult> 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);
}
/// <summary>The relays the server subscribes to: the configuration's, then the settings'.</summary>
public async Task<List<ViewRelay>> Relays(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminRelays, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewRelay>>(cancellationToken) ?? [];
await LogFailure(response, nameof(Relays), cancellationToken);
return [];
}
/// <summary>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.</summary>
public async Task<WebResult> 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);
}
/// <summary>Unsubscribes from a relay the settings named (a configured one is refused with 409); answered 202 like
/// AddRelay.</summary>
public async Task<WebResult> 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);
}
/// <summary>The accounts the staff suggest first to every persona, in their order; one no longer shown (deleted,
/// suspended or silenced since) has IsShown false.</summary>
public async Task<List<ViewStaffPick>> StaffPicks(CancellationToken cancellationToken = default)
{
using var response = await httpService.Get(APIs.ClientApi.GET_AdminStaffPicks, cancellationToken: cancellationToken);
if (response.IsSuccessStatusCode)
return await response.DefaultReadFromJsonAsync<List<ViewStaffPick>>(cancellationToken) ?? [];
await LogFailure(response, nameof(StaffPicks), cancellationToken);
return [];
}
/// <summary>Suggests an account to every persona, by its Mastodon id: a persona here or a person elsewhere. PrivaPub
/// answers 404 for no such account, 409 for one picked already, 422 past its 40. Data is the picks now.</summary>
public async Task<WebResult> AddStaffPick(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_AdminStaffPickInsert, new StaffPickForm { AccountId = accountId },
cancellationToken: cancellationToken);
return await StaffPickList(response, cancellationToken);
}
/// <summary>No longer suggests the account (it can be picked again); Data is the picks now.</summary>
public async Task<WebResult> RemoveStaffPick(string accountId, CancellationToken cancellationToken = default)
{
using var response = await httpService.Post(APIs.ClientApi.POST_M_AdminStaffPickDelete(accountId), cancellationToken: cancellationToken);
return await StaffPickList(response, cancellationToken);
}
async Task<WebResult> StaffPickList(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewStaffPick>>(cancellationToken) ?? [] };
}
async Task<WebResult> 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<ViewServerSettings>(cancellationToken) };
}
async Task<WebResult> RelayList(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.DefaultReadFromJsonAsync<List<ViewRelay>>(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);
}
}
}