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
215 lines
11 KiB
C#
215 lines
11 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|