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 { /// The settings as they are now, never null: the defaults (today's literals) until an administrator saves. /// Never change it: Update does. ServerSettings Current { get; } /// Reads the document again; tells whoever listens (ChangeToken) when it changed. Task Reload(CancellationToken token); /// Changes the settings as stored now (or the defaults when none are), saves them and makes them current. Task Update(Action change, string byRootId, CancellationToken token); /// Fires once the settings change (a save here, or a reload that found another version). 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 _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 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 Update(Action 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(MongoDB.Bson.BsonExtensionMethods.ToBson(settings)), stored: true); return settings; } finally { _saving.Release(); } } static Task Find(CancellationToken token) => DB.Default.Find().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 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 reader sees it, since the settings' change token is one of its sources. public sealed class MediaLimitsFromSettings(IInstanceSettings settings) : IPostConfigureOptions { 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 { public string Name => Options.DefaultName; public IChangeToken GetChangeToken() => settings.ChangeToken(); } }