R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
Build / Build (push) Successful in 1m3s
Build / Build (push) Successful in 1m3s
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits. /clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published, Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids. Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones. Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
f67e2ae35e
commit
70d1ffe9a2
46 files changed
+2223
-107
No files matched your search
@@ -0,0 +1,481 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
using MongoDB.Entities;
|
||||
|
||||
using PrivaPub.ClientModels;
|
||||
using PrivaPub.ClientModels.Admin;
|
||||
using PrivaPub.Domain.Instance;
|
||||
using PrivaPub.Domain.Media;
|
||||
using PrivaPub.Domain.Moderation;
|
||||
using PrivaPub.Domain.Privacy;
|
||||
using PrivaPub.Domain.Social;
|
||||
using PrivaPub.Extensions;
|
||||
using PrivaPub.Federation.Actors;
|
||||
using PrivaPub.Federation.Relays;
|
||||
using PrivaPub.Infrastructure;
|
||||
using PrivaPub.Models;
|
||||
using PrivaPub.Models.Federation;
|
||||
using PrivaPub.Models.Social;
|
||||
using PrivaPub.Models.User;
|
||||
using PrivaPub.Resources;
|
||||
|
||||
namespace PrivaPub.Controllers.ClientToServer
|
||||
{
|
||||
// The server's own settings from the administrator's page (completion program R5): what it says of itself, its rules,
|
||||
// its thumbnail, its media limits, whether its activity is published, its announcements and the relays it subscribes to.
|
||||
// Only an administrator, checked again against the database. Every change is kept in the staff's log.
|
||||
[ApiController,
|
||||
Route("clientapi/admin"),
|
||||
Authorize(Policy = Policies.IsAdmin)]
|
||||
public class InstanceAdminController : ControllerBase
|
||||
{
|
||||
const int MaxSettingsRelays = 20;
|
||||
const string Thumbnail = "thumbnail";
|
||||
|
||||
readonly IInstanceSettings _settings;
|
||||
readonly IModerationLog _log;
|
||||
readonly IStringLocalizer _localizer;
|
||||
readonly ILocalActorService _localActors;
|
||||
readonly ILogger<InstanceAdminController> _logger;
|
||||
|
||||
public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer<GenericRes> localizer, ILocalActorService localActors,
|
||||
ILogger<InstanceAdminController> logger)
|
||||
{
|
||||
_settings = settings;
|
||||
_log = log;
|
||||
_localizer = localizer;
|
||||
_localActors = localActors;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
// the token says administrator; the database has the last word
|
||||
async Task<RootUser> Administrator(CancellationToken token)
|
||||
{
|
||||
var root = await DB.Default.Find<RootUser>().MatchID(User.GetUserId()).ExecuteFirstAsync(token);
|
||||
return root is { IsBanned: false, DeletedAt: null } && root.Policies.Contains(Policies.IsAdmin) ? root : default;
|
||||
}
|
||||
|
||||
IActionResult Refused(int status, string message) => StatusCode(status, new WebResult().Invalidate(_localizer[message], status));
|
||||
|
||||
#region settings
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/settings")]
|
||||
public async Task<IActionResult> Settings(CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
return Ok(await View(_settings.Current, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/settings")]
|
||||
public async Task<IActionResult> SaveSettings(ServerSettingsForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
var contact = Clean(form.ContactAccountId);
|
||||
if (contact != default && !await IsContactable(contact, token))
|
||||
return Refused(StatusCodes.Status422UnprocessableEntity, "The contact account must be a persona of this server.");
|
||||
var rules = form.Rules ?? new();
|
||||
if (rules.Count > ServerSettingsForm.MaxRules || rules.Any(r => string.IsNullOrWhiteSpace(r?.Text)))
|
||||
return Refused(StatusCodes.Status422UnprocessableEntity, "Every rule needs its text, and there can be 50 at most.");
|
||||
|
||||
var changed = new List<string>();
|
||||
var ruleActs = new List<(string Action, ServerRule Rule)>();
|
||||
var now = DateTime.UtcNow;
|
||||
var saved = await _settings.Update(settings =>
|
||||
{
|
||||
void Set(string name, string value, Func<string> get, Action<string> set)
|
||||
{
|
||||
if (get() == value)
|
||||
return;
|
||||
set(value);
|
||||
changed.Add(name);
|
||||
}
|
||||
Set("title", form.Title.Trim(), () => settings.Title, v => settings.Title = v);
|
||||
Set("short description", Clean(form.ShortDescription), () => settings.ShortDescription, v => settings.ShortDescription = v);
|
||||
Set("description", Clean(form.Description), () => settings.Description, v =>
|
||||
{
|
||||
settings.Description = v;
|
||||
settings.DescriptionUpdatedAt = now;
|
||||
});
|
||||
Set("contact email", Clean(form.ContactEmail), () => settings.ContactEmail, v => settings.ContactEmail = v);
|
||||
Set("contact account", contact, () => settings.ContactAccountId, v => settings.ContactAccountId = v);
|
||||
Set("privacy policy", Clean(form.PrivacyPolicy), () => settings.PrivacyPolicy, v =>
|
||||
{
|
||||
settings.PrivacyPolicy = v;
|
||||
settings.PrivacyPolicyUpdatedAt = now;
|
||||
});
|
||||
var terms = Clean(form.TermsOfService);
|
||||
Set("terms of service", terms, () => settings.TermsOfService, v => settings.TermsOfService = v);
|
||||
var effective = terms == default ? (DateTime?)null
|
||||
: form.TermsEffectiveAt is { } on ? DateTime.SpecifyKind(on.Date, DateTimeKind.Utc)
|
||||
: changed.Contains("terms of service") || settings.TermsEffectiveAt == default ? now.Date
|
||||
: settings.TermsEffectiveAt;
|
||||
if (effective != settings.TermsEffectiveAt)
|
||||
{
|
||||
settings.TermsEffectiveAt = effective;
|
||||
if (!changed.Contains("terms of service"))
|
||||
changed.Add("terms' date");
|
||||
}
|
||||
|
||||
// rules: the form's order; a named rule keeps its id, a new one gets one, one left out is deleted but kept
|
||||
var existing = settings.Rules.GroupBy(r => r.Id).ToDictionary(g => g.Key, g => g.First());
|
||||
var listed = new HashSet<string>();
|
||||
var position = 0;
|
||||
foreach (var asked in rules)
|
||||
{
|
||||
var text = asked.Text.Trim();
|
||||
var hint = Clean(asked.Hint);
|
||||
if (asked.Id != default && existing.TryGetValue(asked.Id, out var rule) && listed.Add(rule.Id))
|
||||
{
|
||||
if (rule.Text != text || rule.Hint != hint || rule.DeletedAt != default)
|
||||
ruleActs.Add((AdminActions.RuleUpdate, rule));
|
||||
else if (rule.Position != position)
|
||||
changed.Add("rules' order");
|
||||
rule.Text = text;
|
||||
rule.Hint = hint;
|
||||
rule.DeletedAt = default;
|
||||
rule.Position = position++;
|
||||
continue;
|
||||
}
|
||||
var added = new ServerRule { Text = text, Hint = hint, Position = position++ };
|
||||
settings.Rules.Add(added);
|
||||
listed.Add(added.Id);
|
||||
ruleActs.Add((AdminActions.RuleInsert, added));
|
||||
}
|
||||
foreach (var gone in settings.Rules.Where(r => r.DeletedAt == default && !listed.Contains(r.Id)))
|
||||
{
|
||||
gone.DeletedAt = now;
|
||||
ruleActs.Add((AdminActions.RuleDelete, gone));
|
||||
}
|
||||
|
||||
var media = form.Media ?? new();
|
||||
var limits = settings.Media ?? new();
|
||||
if (limits.QuotaBytesPerRoot != media.QuotaBytesPerRoot || limits.MaxImageBytes != media.MaxImageBytes
|
||||
|| limits.MaxVideoBytes != media.MaxVideoBytes || limits.MaxSeconds != media.MaxSeconds)
|
||||
{
|
||||
settings.Media = new ServerMediaLimits
|
||||
{
|
||||
QuotaBytesPerRoot = media.QuotaBytesPerRoot is { } quota ? Math.Max(0, quota) : (long?)null,
|
||||
MaxImageBytes = media.MaxImageBytes is { } image ? Math.Clamp(image, 1, MediaLimitsFromSettings.MaxImageBytes) : (long?)null,
|
||||
MaxVideoBytes = media.MaxVideoBytes is { } video ? Math.Clamp(video, 1, MediaLimitsFromSettings.MaxVideoBytes) : (long?)null,
|
||||
MaxSeconds = media.MaxSeconds is { } seconds ? Math.Clamp(seconds, 1, MediaLimitsFromSettings.MaxSeconds) : (int?)null
|
||||
};
|
||||
changed.Add("media limits");
|
||||
}
|
||||
if (settings.PublishActivity != form.PublishActivity)
|
||||
{
|
||||
settings.PublishActivity = form.PublishActivity;
|
||||
changed.Add(form.PublishActivity ? "activity published" : "activity unpublished");
|
||||
}
|
||||
}, admin.ID, token);
|
||||
|
||||
if (changed.Count > 0)
|
||||
await _log.Write(admin.ID, AdminActions.SettingsUpdate, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: string.Join(", ", changed), token: token);
|
||||
foreach (var (action, rule) in ruleActs)
|
||||
await _log.Write(admin.ID, action, AdminTargets.Rule, rule.Id, rule.Text, token: token);
|
||||
return Ok(await View(saved, token));
|
||||
}
|
||||
|
||||
// the server's thumbnail (the instance API's), from a picture checked and re-encoded like a profile picture
|
||||
[HttpPost, Route("/clientapi/admin/settings/thumbnail"), RequestSizeLimit(MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024),
|
||||
RequestFormLimits(MultipartBodyLengthLimit = MediaLimitsFromSettings.MaxImageBytes + 1024 * 1024)]
|
||||
public async Task<IActionResult> SaveThumbnail([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
var file = Request.HasFormContentType ? (await Request.ReadFormAsync(token)).Files["file"] : default;
|
||||
var outcome = await media.ServerImage(Thumbnail, file, token);
|
||||
if (!outcome.Ok)
|
||||
return StatusCode(outcome.Status, new WebResult().Invalidate(outcome.Error, outcome.Status));
|
||||
var previous = default(string);
|
||||
var saved = await _settings.Update(settings =>
|
||||
{
|
||||
previous = settings.ThumbnailMediaId;
|
||||
settings.ThumbnailMediaId = outcome.Attachment.ID;
|
||||
settings.ThumbnailUrl = media.Url(outcome.Attachment.FilePath);
|
||||
}, admin.ID, token);
|
||||
if (previous != default)
|
||||
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail replaced", token);
|
||||
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "replaced", token: token);
|
||||
return Ok(await View(saved, token));
|
||||
}
|
||||
|
||||
[HttpDelete, Route("/clientapi/admin/settings/thumbnail")]
|
||||
public async Task<IActionResult> DeleteThumbnail([FromServices] IMediaService media, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
var previous = default(string);
|
||||
var saved = await _settings.Update(settings =>
|
||||
{
|
||||
previous = settings.ThumbnailMediaId;
|
||||
settings.ThumbnailMediaId = default;
|
||||
settings.ThumbnailUrl = default;
|
||||
}, admin.ID, token);
|
||||
if (previous != default)
|
||||
{
|
||||
await media.Trash(m => m.ID == previous && m.ServerUse == Thumbnail, "thumbnail removed", token);
|
||||
await _log.Write(admin.ID, AdminActions.SettingsThumbnail, AdminTargets.Settings, ServerSettings.Name, saved.Title, note: "removed", token: token);
|
||||
}
|
||||
return Ok(await View(saved, token));
|
||||
}
|
||||
|
||||
// a local persona whose login may act: the contact is shown to everyone as the one who runs the server
|
||||
static async Task<bool> IsContactable(string avatarId, CancellationToken token) =>
|
||||
await DB.Default.Find<Avatar>().Match(a => a.ID == avatarId && !a.DeletionAt.HasValue).ExecuteAnyAsync(token)
|
||||
&& !(await Counted.BarredPersonas(token)).Contains(avatarId);
|
||||
|
||||
static string Clean(string text) => string.IsNullOrWhiteSpace(text) ? default : text.Trim();
|
||||
|
||||
async Task<ViewServerSettings> View(ServerSettings settings, CancellationToken token)
|
||||
{
|
||||
var services = HttpContext.RequestServices;
|
||||
var configured = services.GetRequiredService<IConfiguration>().GetSection("Media").Get<MediaOptions>() ?? new MediaOptions();
|
||||
var effective = services.GetRequiredService<IOptionsMonitor<MediaOptions>>().CurrentValue;
|
||||
var contact = settings.ContactAccountId == default ? default
|
||||
: await DB.Default.Find<Avatar>().Match(a => a.ID == settings.ContactAccountId && !a.DeletionAt.HasValue).ExecuteFirstAsync(token);
|
||||
var updatedBy = settings.UpdatedBy == default || settings.UpdatedBy == AdminAction.Cli ? settings.UpdatedBy
|
||||
: (await DB.Default.Find<RootUser>().MatchID(settings.UpdatedBy).ExecuteFirstAsync(token))?.UserName;
|
||||
var limits = settings.Media ?? new();
|
||||
return new ViewServerSettings
|
||||
{
|
||||
Title = settings.Title,
|
||||
ShortDescription = settings.ShortDescription,
|
||||
Description = settings.Description,
|
||||
DescriptionUpdatedAt = settings.DescriptionUpdatedAt,
|
||||
ContactEmail = settings.ContactEmail,
|
||||
ContactAccountId = settings.ContactAccountId,
|
||||
ContactAccount = contact == default ? default : new ViewContactAccount
|
||||
{
|
||||
Id = contact.ID,
|
||||
UserName = contact.UserName,
|
||||
Name = contact.Name,
|
||||
Handle = _localActors.FromAvatar(contact).Handle
|
||||
},
|
||||
ThumbnailUrl = settings.ThumbnailUrl ?? $"{_localActors.BaseAddress}{ServerSettings.DefaultThumbnailPath}",
|
||||
ThumbnailIsDefault = settings.ThumbnailUrl == default,
|
||||
PrivacyPolicy = settings.PrivacyPolicy,
|
||||
PrivacyPolicyUpdatedAt = settings.PrivacyPolicyUpdatedAt,
|
||||
TermsOfService = settings.TermsOfService,
|
||||
TermsEffectiveAt = settings.TermsEffectiveAt,
|
||||
Rules = settings.Rules.OrderBy(r => r.DeletedAt != default).ThenBy(r => r.Position).Select(r => new ViewServerRule
|
||||
{
|
||||
Id = r.Id,
|
||||
Text = r.Text,
|
||||
Hint = r.Hint,
|
||||
Position = r.Position,
|
||||
IsDeleted = r.DeletedAt != default,
|
||||
DeletedAt = r.DeletedAt
|
||||
}).ToList(),
|
||||
Relays = await RelayViews(settings, token),
|
||||
Media = new ViewMediaLimits
|
||||
{
|
||||
QuotaBytesPerRoot = limits.QuotaBytesPerRoot,
|
||||
MaxImageBytes = limits.MaxImageBytes,
|
||||
MaxVideoBytes = limits.MaxVideoBytes,
|
||||
MaxSeconds = limits.MaxSeconds,
|
||||
ConfiguredQuotaBytesPerRoot = configured.QuotaBytesPerRoot,
|
||||
ConfiguredMaxImageBytes = configured.MaxImageBytes,
|
||||
ConfiguredMaxVideoBytes = configured.MaxVideoBytes,
|
||||
ConfiguredMaxSeconds = configured.MaxSeconds,
|
||||
EffectiveQuotaBytesPerRoot = effective.QuotaBytesPerRoot,
|
||||
EffectiveMaxImageBytes = effective.MaxImageBytes,
|
||||
EffectiveMaxVideoBytes = effective.MaxVideoBytes,
|
||||
EffectiveMaxSeconds = effective.MaxSeconds
|
||||
},
|
||||
RegistrationMode = services.GetRequiredService<IOptionsMonitor<RegistrationOptions>>().CurrentValue.Mode.ToString(),
|
||||
PublishActivity = settings.PublishActivity,
|
||||
UpdatedAt = settings.UpdatedAt,
|
||||
UpdatedBy = updatedBy
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region announcements
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/announcements/list")]
|
||||
public async Task<IActionResult> Announcements([FromServices] IAnnouncements announcements, CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
var views = new List<ViewAnnouncement>();
|
||||
foreach (var announcement in await announcements.All(token))
|
||||
views.Add(await View(announcement, token));
|
||||
return Ok(views);
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/announcements/insert")]
|
||||
public async Task<IActionResult> InsertAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
if (Invalid(form) is { } refused)
|
||||
return refused;
|
||||
var made = await announcements.Insert(Draft(form), token);
|
||||
await _log.Write(admin.ID, AdminActions.AnnouncementInsert, AdminTargets.Announcement, made.ID, made.Text,
|
||||
note: made.IsPublished ? "published" : "draft", token: token);
|
||||
return Ok(await View(made, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/announcements/update")]
|
||||
public async Task<IActionResult> UpdateAnnouncement([FromServices] IAnnouncements announcements, AnnouncementForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
if (Invalid(form) is { } refused)
|
||||
return refused;
|
||||
var updated = await announcements.Update(form.Id, Draft(form), token);
|
||||
if (updated == default)
|
||||
return NotFound();
|
||||
await _log.Write(admin.ID, AdminActions.AnnouncementUpdate, AdminTargets.Announcement, updated.ID, updated.Text,
|
||||
note: updated.IsPublished ? "published" : "draft", token: token);
|
||||
return Ok(await View(updated, token));
|
||||
}
|
||||
|
||||
[HttpPost, Route("/clientapi/admin/announcements/delete")]
|
||||
public async Task<IActionResult> DeleteAnnouncement([FromServices] IAnnouncements announcements, [FromQuery] string id, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
var deleted = await announcements.Delete(id, token);
|
||||
if (deleted == default)
|
||||
return NotFound();
|
||||
await _log.Write(admin.ID, AdminActions.AnnouncementDelete, AdminTargets.Announcement, deleted.ID, deleted.Text, token: token);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
IActionResult Invalid(AnnouncementForm form) =>
|
||||
string.IsNullOrWhiteSpace(form.Text)
|
||||
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement needs its text.")
|
||||
: form.StartsAt != default && form.EndsAt != default && form.EndsAt < form.StartsAt
|
||||
? Refused(StatusCodes.Status422UnprocessableEntity, "An announcement can't end before it starts.")
|
||||
: default;
|
||||
|
||||
static AnnouncementDraft Draft(AnnouncementForm form) => new(form.Text, form.StartsAt, form.EndsAt, form.AllDay, form.IsPublished);
|
||||
|
||||
static async Task<ViewAnnouncement> View(Announcement announcement, CancellationToken token)
|
||||
{
|
||||
var reactions = await DB.Default.Find<AnnouncementReaction>().Match(r => r.AnnouncementId == announcement.ID).ExecuteAsync(token);
|
||||
return new ViewAnnouncement
|
||||
{
|
||||
Id = announcement.ID,
|
||||
Text = announcement.Text,
|
||||
Html = announcement.Html,
|
||||
StartsAt = announcement.StartsAt,
|
||||
EndsAt = announcement.EndsAt,
|
||||
AllDay = announcement.AllDay,
|
||||
IsPublished = announcement.IsPublished,
|
||||
IsShown = announcement.IsPublished && (announcement.EndsAt == default || announcement.EndsAt > DateTime.UtcNow),
|
||||
PublishedAt = announcement.PublishedAt,
|
||||
CreatedAt = announcement.CreatedAt,
|
||||
UpdatedAt = announcement.UpdatedAt,
|
||||
Reads = await DB.Default.CountAsync<AnnouncementRead>(r => r.AnnouncementId == announcement.ID, token),
|
||||
Reactions = reactions.GroupBy(r => r.Name).Select(g => new ViewAnnouncementReaction { Name = g.Key, Count = g.Count() }).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region relays
|
||||
|
||||
[HttpGet, Route("/clientapi/admin/relays")]
|
||||
public async Task<IActionResult> Relays(CancellationToken token)
|
||||
{
|
||||
if (await Administrator(token) == default)
|
||||
return Forbid();
|
||||
return Ok(await RelayViews(_settings.Current, token));
|
||||
}
|
||||
|
||||
// a relay named by the settings: subscribed to at once (in the background: resolving it is a fetch), the page polls
|
||||
[HttpPost, Route("/clientapi/admin/relays/insert")]
|
||||
public async Task<IActionResult> InsertRelay([FromServices] IRelays relays, RelayForm form, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
var address = form.Address?.Trim();
|
||||
if (!Uri.TryCreate(address, UriKind.Absolute, out var uri) || uri.Scheme != Uri.UriSchemeHttps || !string.IsNullOrEmpty(uri.UserInfo)
|
||||
|| !string.IsNullOrEmpty(uri.Fragment))
|
||||
return Refused(StatusCodes.Status422UnprocessableEntity, "A relay is named by the https address of its actor or its inbox.");
|
||||
if (relays.Configured.Contains(address))
|
||||
return Refused(StatusCodes.Status409Conflict, "The configuration names this relay already.");
|
||||
if (_settings.Current.Relays.Contains(address))
|
||||
return Ok(await RelayViews(_settings.Current, token));
|
||||
if (_settings.Current.Relays.Count >= MaxSettingsRelays)
|
||||
return Refused(StatusCodes.Status422UnprocessableEntity, "The settings name 20 relays at most.");
|
||||
var saved = await _settings.Update(settings =>
|
||||
{
|
||||
if (!settings.Relays.Contains(address))
|
||||
settings.Relays.Add(address);
|
||||
}, admin.ID, token);
|
||||
await _log.Write(admin.ID, AdminActions.RelayInsert, AdminTargets.Relay, address, address, token: token);
|
||||
Reconcile(relays);
|
||||
return Accepted(await RelayViews(saved, token));
|
||||
}
|
||||
|
||||
// a relay the settings named: unsubscribed at once (its Undo is queued); the configuration's are removed only there
|
||||
[HttpPost, Route("/clientapi/admin/relays/delete")]
|
||||
public async Task<IActionResult> DeleteRelay([FromServices] IRelays relays, [FromQuery] string address, CancellationToken token)
|
||||
{
|
||||
var admin = await Administrator(token);
|
||||
if (admin == default)
|
||||
return Forbid();
|
||||
address = address?.Trim();
|
||||
if (relays.Configured.Contains(address))
|
||||
return Refused(StatusCodes.Status409Conflict, "This relay is named by the configuration (Federation:Relays): remove it there.");
|
||||
if (string.IsNullOrEmpty(address) || !_settings.Current.Relays.Contains(address))
|
||||
return NotFound();
|
||||
var saved = await _settings.Update(settings => settings.Relays.Remove(address), admin.ID, token);
|
||||
await _log.Write(admin.ID, AdminActions.RelayDelete, AdminTargets.Relay, address, address, token: token);
|
||||
Reconcile(relays);
|
||||
return Accepted(await RelayViews(saved, token));
|
||||
}
|
||||
|
||||
void Reconcile(IRelays relays) => _ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await relays.Reconcile(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Relay subscriptions could not be brought up to date");
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
|
||||
async Task<List<ViewRelay>> RelayViews(ServerSettings settings, CancellationToken token)
|
||||
{
|
||||
var configured = HttpContext.RequestServices.GetRequiredService<IRelays>().Configured;
|
||||
var subscriptions = (await DB.Default.Find<RelaySubscription>().ExecuteAsync(token)).GroupBy(s => s.Configured).ToDictionary(g => g.Key, g => g.First());
|
||||
ViewRelay View(string address, string source)
|
||||
{
|
||||
var subscription = subscriptions.GetValueOrDefault(address);
|
||||
return new ViewRelay
|
||||
{
|
||||
Address = address,
|
||||
Source = source,
|
||||
State = subscription == default ? "none" : subscription.State.ToString().ToLowerInvariant(),
|
||||
ActorURI = subscription?.ActorURI,
|
||||
RequestedAt = subscription?.RequestedAt,
|
||||
AnsweredAt = subscription?.AnsweredAt
|
||||
};
|
||||
}
|
||||
return configured.Select(a => View(a, "configuration"))
|
||||
.Concat((settings.Relays ?? new()).Where(a => !configured.Contains(a)).Select(a => View(a, "settings")))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user