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 _logger; public InstanceAdminController(IInstanceSettings settings, IModerationLog log, IStringLocalizer localizer, ILocalActorService localActors, ILogger logger) { _settings = settings; _log = log; _localizer = localizer; _localActors = localActors; _logger = logger; } // the token says administrator; the database has the last word async Task Administrator(CancellationToken token) { var root = await DB.Default.Find().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 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 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(); 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 get, Action 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(); 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 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 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 IsContactable(string avatarId, CancellationToken token) => await DB.Default.Find().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 View(ServerSettings settings, CancellationToken token) { var services = HttpContext.RequestServices; var configured = services.GetRequiredService().GetSection("Media").Get() ?? new MediaOptions(); var effective = services.GetRequiredService>().CurrentValue; var contact = settings.ContactAccountId == default ? default : await DB.Default.Find().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().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>().CurrentValue.Mode.ToString(), PublishActivity = settings.PublishActivity, UpdatedAt = settings.UpdatedAt, UpdatedBy = updatedBy }; } #endregion #region announcements [HttpGet, Route("/clientapi/admin/announcements/list")] public async Task Announcements([FromServices] IAnnouncements announcements, CancellationToken token) { if (await Administrator(token) == default) return Forbid(); var views = new List(); 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 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 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 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 View(Announcement announcement, CancellationToken token) { var reactions = await DB.Default.Find().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(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 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 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 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> RelayViews(ServerSettings settings, CancellationToken token) { var configured = HttpContext.RequestServices.GetRequiredService().Configured; var subscriptions = (await DB.Default.Find().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 } }