Files
SocialPub/PrivaPub/Controllers/ClientToServer/InstanceAdminController.cs
T

481 lines
21 KiB
C#
Raw Normal View History

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
}
}