Files
SocialPub/PrivaPub/Domain/Instance/InstanceSettings.cs
T
thepraandClaude Opus 5.5 70d1ffe9a2
Build / Build (push) Successful in 1m3s
R5 the server's own settings: instance, rules, announcements, relays, media limits, the staff's log
A singleton ServerSettings (title, descriptions, contact, thumbnail, privacy policy, terms, rules, relays, media
overrides, PublishActivity), cached by InstanceSettings and read by the instance API, NodeInfo, the instance actor, the
web pages and the mailer; its defaults are the old literals, so nothing changes until an administrator edits.
/clientapi/admin/settings (and /thumbnail), /announcements, /relays for administrators; /clientapi/moderator/log, the
staff's log (AdminAction, written by domain blocks, backups, settings and the CLI; no IP, never a root paired with its
personas). The Mastodon API's rules, extended description, privacy policy, terms of service, domain blocks (Published,
Obfuscate), announcements (dismiss, reactions, streaming) and activity (only when published). Reports carry rule ids.
Media overrides clamped to hard caps. Relays named by the settings reconcile next to the configured ones.

Verified: interop.sh instance accounts relay with mastodon, relay, aoderelay and mailpit, 119 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 04:02:47 +02:00

147 lines
5.4 KiB
C#

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();
}
}