using MongoDB.Driver; using MongoDB.Entities; using PrivaPub.Models.Federation; using PrivaPub.Models.Post; using PrivaPub.Models.Social; using PrivaPub.Models.User; using PostEntity = PrivaPub.Models.Post.Post; namespace PrivaPub.Domain.Social { // A persona's notification policy (Mastodon 4.3), applied as each notification is made: accepted, filtered into a // request per account, or dropped. Polls ending and edits of posts the persona answered are never filtered. public static class NotificationPolicies { static readonly TimeSpan NewAccount = TimeSpan.FromDays(30); // someone who followed the persona lately is not yet a follower for the policy: following, then writing, is how // a newcomer gets past it static readonly TimeSpan NewFollower = TimeSpan.FromDays(3); public static async Task Of(string avatarId, CancellationToken token) => await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token) ?? new NotificationPolicy { AvatarId = avatarId }; public static async Task Save(NotificationPolicy policy, CancellationToken token) => await DB.Default.Update().Match(p => p.AvatarId == policy.AvatarId) .Modify(p => p.ForNotFollowing, policy.ForNotFollowing) .Modify(p => p.ForNotFollowers, policy.ForNotFollowers) .Modify(p => p.ForNewAccounts, policy.ForNewAccounts) .Modify(p => p.ForPrivateMentions, policy.ForPrivateMentions) .Modify(p => p.ForLimitedAccounts, policy.ForLimitedAccounts) .Option(o => o.IsUpsert = true) .ExecuteAsync(token); public static async Task Judge(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId, CancellationToken token) { if (type is NotificationType.Poll or NotificationType.Update || string.IsNullOrEmpty(fromActorUri)) return NotificationAction.Accept; var policy = await DB.Default.Find().Match(p => p.AvatarId == avatarId).ExecuteFirstAsync(token); if (policy is null or { ForNotFollowing: NotificationAction.Accept, ForNotFollowers: NotificationAction.Accept, ForNewAccounts: NotificationAction.Accept, ForPrivateMentions: NotificationAction.Accept, ForLimitedAccounts: NotificationAction.Accept }) return NotificationAction.Accept; if (await DB.Default.Find().Match(p => p.AvatarId == avatarId && p.FromActorURI == fromActorUri).ExecuteAnyAsync(token)) return NotificationAction.Accept; var actions = new List(); var following = await DB.Default.Find() .Match(f => f.AvatarId == avatarId && f.TargetActorURI == fromActorUri && f.State == FollowState.Accepted).ExecuteAnyAsync(token); if (!following) actions.Add(policy.ForNotFollowing); if (policy.ForNotFollowers != NotificationAction.Accept) { var since = DateTime.UtcNow - NewFollower; if (!await DB.Default.Find() .Match(f => f.LocalActorId == avatarId && f.ActorURI == fromActorUri && f.IsAccepted && f.CreationDate <= since).ExecuteAnyAsync(token)) actions.Add(policy.ForNotFollowers); } if (policy.ForNewAccounts != NotificationAction.Accept || policy.ForLimitedAccounts != NotificationAction.Accept) { var (since, silenced) = await Sender(fromAccountId, fromActorUri, token); if (since > DateTime.UtcNow - NewAccount) actions.Add(policy.ForNewAccounts); if (silenced) actions.Add(policy.ForLimitedAccounts); } // a private mention it did not ask for: from someone it does not follow, answering nothing of the persona's if (policy.ForPrivateMentions != NotificationAction.Accept && type == NotificationType.Mention && !following && !string.IsNullOrEmpty(postId) && await DB.Default.Find().Match(p => p.ID == postId && p.Visibility == PostVisibility.Direct && p.InReplyToAccountId != avatarId) .ExecuteAnyAsync(token)) actions.Add(policy.ForPrivateMentions); return actions.Contains(NotificationAction.Drop) ? NotificationAction.Drop : actions.Contains(NotificationAction.Filter) ? NotificationAction.Filter : NotificationAction.Accept; } // since when the sender has been on the fediverse (its actor's published, else when this server first met it), and // whether it is silenced here, itself or its server static async Task<(DateTime Since, bool Silenced)> Sender(string accountId, string actorUri, CancellationToken token) { var host = Uri.TryCreate(actorUri, UriKind.Absolute, out var uri) ? uri.Host : default; var silencedHere = host != default && (await DB.Default.Find().Match(b => b.Severity == DomainBlockSeverity.Silence).ExecuteAsync(token)) .Any(b => host.Equals(b.Domain, StringComparison.OrdinalIgnoreCase) || host.EndsWith("." + b.Domain, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrEmpty(accountId) && await DB.Default.Find().Match(a => a.ID == accountId).ExecuteFirstAsync(token) is { } local) return (local.CreatedAt, local.SilencedAt.HasValue); var foreign = await DB.Default.Find().Match(a => a.ActorURI == actorUri).ExecuteFirstAsync(token); return foreign == default ? (DateTime.UtcNow, silencedHere) : (foreign.Published ?? foreign.CreatedAt, silencedHere || foreign.SilencedAt.HasValue); } // a notification filtered from an account: its request gathers it public static async Task Requested(Notification notification, CancellationToken token) { var now = DateTime.UtcNow; var update = DB.Default.Update() .Match(r => r.AvatarId == notification.AvatarId && r.FromActorURI == notification.FromActorURI) .Modify(b => b.Inc(r => r.NotificationsCount, 1)) .Modify(r => r.FromAccountId, notification.FromAccountId) .Modify(r => r.UpdatedAt, now) .Modify(b => b.SetOnInsert(r => r.CreatedAt, now)) .Option(o => o.IsUpsert = true); if (!string.IsNullOrEmpty(notification.PostId)) update.Modify(r => r.LastPostId, notification.PostId); try { await update.ExecuteAsync(token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { // (two filtered at once from one account: the other made the request) await update.Option(o => o.IsUpsert = false).ExecuteAsync(token); } } // the persona lets an account's notifications in, now and from then on public static async Task Accept(NotificationRequest request, CancellationToken token) { try { await DB.Default.SaveAsync(new NotificationPermission { AvatarId = request.AvatarId, FromActorURI = request.FromActorURI }, token); } catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey) { } await DB.Default.Update().Match(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered) .Modify(n => n.Filtered, false).ExecuteAsync(token); await DB.Default.DeleteAsync(request.ID); } // or dismisses them public static async Task Dismiss(NotificationRequest request, CancellationToken token) { await DB.Default.DeleteAsync(n => n.AvatarId == request.AvatarId && n.FromActorURI == request.FromActorURI && n.Filtered); await DB.Default.DeleteAsync(request.ID); } } }