481 lines
21 KiB
C#
481 lines
21 KiB
C#
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
|
||
|
|
}
|
||
|
|
}
|