using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Domain.Privacy; using PrivaPub.Models.User; namespace PrivaPub.Domain.Moderation { // Who the staff's moderation holds back right now, in memory (completion program R6), for the places that decide one // item at a time and must not ask the database each time: a token's persona, a delivery's signer and inbox, a post // shown or not, a stream. The posts of a suspended or silenced account carry their own flags (Post.AuthorSuspended, // AuthorLimited) for queries; a banned login's personas never do, since flagging all of them at once would pair them // in stored data, so they are suspended here alone (Counted.HeldBackPersonas). // // Read at start, again after every act that changes it (AccountModeration, a ban, a removal) and when five minutes old; // a process that never loaded it (the CLI) starts with nobody held back and loads it on first use. public static class Restrictions { static readonly TimeSpan Staleness = TimeSpan.FromMinutes(5); public sealed class Set { public static readonly Set Empty = new(new HashSet(), new HashSet(), new HashSet(), new HashSet()); public Set(IReadOnlySet suspendedPersonas, IReadOnlySet silencedPersonas, IReadOnlySet suspendedActors, IReadOnlySet suspendedInboxes) { SuspendedPersonas = suspendedPersonas; SilencedPersonas = silencedPersonas; SuspendedActors = suspendedActors; SuspendedInboxes = suspendedInboxes; } // suspended themselves, or held back with their login (banned or deleted) public IReadOnlySet SuspendedPersonas { get; } public IReadOnlySet SilencedPersonas { get; } // accounts elsewhere the staff suspended: their actors' ids, and their own inboxes (never a shared one, which // serves the rest of their server) public IReadOnlySet SuspendedActors { get; } public IReadOnlySet SuspendedInboxes { get; } } static volatile Set _current = Set.Empty; static DateTime _loadedAt = DateTime.MinValue; static int _reloading; public static Set Current { get { RefreshIfStale(); return _current; } } public static bool IsSuspended(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SuspendedPersonas.Contains(personaId); public static bool IsSilenced(string personaId) => !string.IsNullOrEmpty(personaId) && Current.SilencedPersonas.Contains(personaId); public static bool IsSuspendedActor(string actorUri) => !string.IsNullOrEmpty(actorUri) && Current.SuspendedActors.Contains(actorUri); public static bool IsSuspendedInbox(string inbox) => !string.IsNullOrEmpty(inbox) && Current.SuspendedInboxes.Contains(inbox); public static async Task Reload(CancellationToken token = default) { var suspended = (await Counted.HeldBackPersonas(token)).ToHashSet(StringComparer.Ordinal); var silenced = (await DB.Default.Find().Match(a => a.SilencedAt != null && a.DeletionAt == null) .Project(a => a.Include(x => x.ID)).ExecuteAsync(token)).Select(a => a.ID).ToHashSet(StringComparer.Ordinal); var actors = await DB.Default.Find().Match(f => f.SuspendedAt != null) .Project(f => f.Include(x => x.ActorURI).Include(x => x.InboxURL).Include(x => x.SharedInboxURL)).ExecuteAsync(token); // a single-user server's actor may name its server's shared inbox as its own: that one still serves the others there var inboxes = actors.Where(a => !string.IsNullOrEmpty(a.InboxURL) && a.InboxURL != a.SharedInboxURL) .Select(a => a.InboxURL).ToHashSet(StringComparer.Ordinal); if (inboxes.Count > 0) { var candidates = inboxes.ToList(); var shared = await DB.Default.Find().Match(f => candidates.Contains(f.SharedInboxURL)) .Project(f => f.Include(x => x.SharedInboxURL)).ExecuteAsync(token); inboxes.ExceptWith(shared.Select(f => f.SharedInboxURL)); } _current = new Set(suspended, silenced, actors.Select(a => a.ActorURI).Where(u => !string.IsNullOrEmpty(u)).ToHashSet(StringComparer.Ordinal), inboxes); _loadedAt = DateTime.UtcNow; } // what a reload says, without waiting for it where waiting is not possible public static void ReloadSoon() => _loadedAt = DateTime.MinValue; static void RefreshIfStale() { if (DateTime.UtcNow - _loadedAt < Staleness || Interlocked.Exchange(ref _reloading, 1) == 1) return; _ = Task.Run(async () => { try { await Reload(CancellationToken.None); } catch (Exception ex) { Serilog.Log.ForContext(typeof(Restrictions)).Warning(ex, "The moderation's restrictions could not be reloaded"); } finally { Interlocked.Exchange(ref _reloading, 0); } }); } } }