111 lines
4.7 KiB
C#
111 lines
4.7 KiB
C#
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<string>(), new HashSet<string>(), new HashSet<string>(), new HashSet<string>());
|
||
|
|
|
||
|
|
public Set(IReadOnlySet<string> suspendedPersonas, IReadOnlySet<string> silencedPersonas, IReadOnlySet<string> suspendedActors,
|
||
|
|
IReadOnlySet<string> suspendedInboxes)
|
||
|
|
{
|
||
|
|
SuspendedPersonas = suspendedPersonas;
|
||
|
|
SilencedPersonas = silencedPersonas;
|
||
|
|
SuspendedActors = suspendedActors;
|
||
|
|
SuspendedInboxes = suspendedInboxes;
|
||
|
|
}
|
||
|
|
|
||
|
|
// suspended themselves, or held back with their login (banned or deleted)
|
||
|
|
public IReadOnlySet<string> SuspendedPersonas { get; }
|
||
|
|
public IReadOnlySet<string> 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<string> SuspendedActors { get; }
|
||
|
|
public IReadOnlySet<string> 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<Avatar>().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<ForeignAvatar>().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<ForeignAvatar>().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);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|