Files
decePubClient/Services/InstanceAdminService.cs
T
thepraandClaude Opus 5.5 d33697e3fc R8 the Mastodon API made real in decePub: featured tags, notify, languages, recommendations, notes, suggestions, the direct stream
A profile shows the hashtags an account features as chips opening their feed; under another account's header is what the
persona keeps of it for itself alone: for one it follows, a bell (follow with notify), the languages of its posts that
reach the home feed (follow with languages[]) and Recommend (endorsements, private here), and for any account a private
note saved on blur or with Save. Settings edits the persona's featured hashtags (added by name or from those used lately,
10 at most) and its reading preferences (/clientapi/avatar/private/preferences), which CascadingState keeps per persona
and the card applies to sensitive media and content warnings. A hashtag's tab features it on the profile. Explore has Who
to follow (/api/v2/suggestions, dismissed for good after asking); Administration has Staff picks. The Direct tab follows
the direct stream's conversation events; status notifications show the post. The follows CSV carries notify and languages.

Verified: Release build, 0 errors (the 3 warnings were there before). Not run against PrivaPub or in the pasture.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-18 08:58:30 +02: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.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);
}
/// <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);
}
}
}