Files
SocialPub/PrivaPub/Domain/Instance/InstanceSettings.cs
T

146 lines
5.4 KiB
C#
Raw Normal View History

using Microsoft.Extensions.Options;
using Microsoft.Extensions.Primitives;
using MongoDB.Bson.Serialization;
using MongoDB.Entities;
using PrivaPub.Domain.Media;
using PrivaPub.Models;
namespace PrivaPub.Domain.Instance
{
public interface IInstanceSettings
{
/// <summary>The settings as they are now, never null: the defaults (today's literals) until an administrator saves.
/// Never change it: Update does.</summary>
ServerSettings Current { get; }
/// <summary>Reads the document again; tells whoever listens (ChangeToken) when it changed.</summary>
Task Reload(CancellationToken token);
/// <summary>Changes the settings as stored now (or the defaults when none are), saves them and makes them current.</summary>
Task<ServerSettings> Update(Action<ServerSettings> change, string byRootId, CancellationToken token);
/// <summary>Fires once the settings change (a save here, or a reload that found another version).</summary>
IChangeToken ChangeToken();
}
// The server's own settings (Models/ServerSettings.cs) in memory: read at start, saved from the administrator's page, and
// read again every five minutes (InstanceSettingsRefresher), so an edit made elsewhere takes too. What reads them reads
// Current, which is never null and never changed in place.
public class InstanceSettings : IInstanceSettings
{
readonly ILogger<InstanceSettings> _logger;
readonly SemaphoreSlim _saving = new(1, 1);
volatile ServerSettings _current = ServerSettings.Defaults();
volatile bool _stored;//whether Current came from the document (or is the defaults)
CancellationTokenSource _changed = new();
public InstanceSettings(ILogger<InstanceSettings> logger)
{
_logger = logger;
}
public ServerSettings Current => _current;
public IChangeToken ChangeToken() => new CancellationChangeToken(Volatile.Read(ref _changed).Token);
public async Task Reload(CancellationToken token)
{
var stored = await Find(token);
if ((stored != default) == _stored && stored?.UpdatedAt == _current.UpdatedAt)
return;
Swap(stored ?? ServerSettings.Defaults(), stored != default);
}
public async Task<ServerSettings> Update(Action<ServerSettings> change, string byRootId, CancellationToken token)
{
await _saving.WaitAsync(token);
try
{
var settings = await Find(token) ?? ServerSettings.Defaults();
change(settings);
settings.ID = ServerSettings.Name;
settings.UpdatedAt = DateTime.UtcNow;
settings.UpdatedBy = byRootId;
await DB.Default.SaveAsync(settings, token);
// what is kept in memory is a copy nobody else holds
Swap(BsonSerializer.Deserialize<ServerSettings>(MongoDB.Bson.BsonExtensionMethods.ToBson(settings)), stored: true);
return settings;
}
finally
{
_saving.Release();
}
}
static Task<ServerSettings> Find(CancellationToken token) =>
DB.Default.Find<ServerSettings>().Match(s => s.ID == ServerSettings.Name).ExecuteFirstAsync(token);
void Swap(ServerSettings settings, bool stored)
{
_current = settings;
_stored = stored;
var previous = Interlocked.Exchange(ref _changed, new CancellationTokenSource());
try
{
previous.Cancel();
}
catch (AggregateException ex)
{
_logger.LogWarning(ex, "A listener to the server's settings failed");
}
//(not disposed: a reader may still be registering on its token)
}
}
// reads the settings again every five minutes, so an edit made elsewhere (another process, the database) takes
public sealed class InstanceSettingsRefresher(IInstanceSettings settings, ILogger<InstanceSettingsRefresher> logger) : BackgroundService
{
static readonly TimeSpan Every = TimeSpan.FromMinutes(5);
protected override async Task ExecuteAsync(CancellationToken token)
{
using var timer = new PeriodicTimer(Every);
while (await timer.WaitForNextTickAsync(token))
{
try
{
await settings.Reload(token);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogWarning(ex, "The server's settings could not be read again");
}
}
}
}
// Media:* as the administrator changed it (ServerSettings.Media), within hard caps: images up to 40 MB, video up to
// 99 MB (nginx takes 100 MB, as does MediaController's request limit), a quota of 0 or more, audio and video up to four
// hours. Every IOptionsMonitor<MediaOptions> reader sees it, since the settings' change token is one of its sources.
public sealed class MediaLimitsFromSettings(IInstanceSettings settings) : IPostConfigureOptions<MediaOptions>
{
public const long MaxImageBytes = 40L * 1024 * 1024;
public const long MaxVideoBytes = 99L * 1024 * 1024;
public const int MaxSeconds = 4 * 3600;
public void PostConfigure(string name, MediaOptions options)
{
if (name != Options.DefaultName || settings.Current.Media is not { } media)
return;
if (media.QuotaBytesPerRoot is { } quota)
options.QuotaBytesPerRoot = Math.Max(0, quota);
if (media.MaxImageBytes is { } image)
options.MaxImageBytes = Math.Clamp(image, 1, MaxImageBytes);
if (media.MaxVideoBytes is { } video)
options.MaxVideoBytes = Math.Clamp(video, 1, MaxVideoBytes);
if (media.MaxSeconds is { } seconds)
options.MaxSeconds = Math.Clamp(seconds, 1, MaxSeconds);
}
}
public sealed class MediaLimitsChangeSource(IInstanceSettings settings) : IOptionsChangeTokenSource<MediaOptions>
{
public string Name => Options.DefaultName;
public IChangeToken GetChangeToken() => settings.ChangeToken();
}
}