using MongoDB.Entities; using PrivaPub.Api.Mastodon.Controllers; using PrivaPub.Infrastructure.Jobs; using PrivaPub.Models.Federation; using PrivaPub.Models.Jobs; using PrivaPub.Models.Social; using System.Text.Json; namespace PrivaPub.Domain.Social { public sealed record PushPayload(string SubscriptionId, string NotificationId); // What Web Push sends, and to whom (completion program R9). A notification is pushed to each subscription of its persona // whose alert for its type is on and whose policy admits its sender, as Mastodon's Web::PushSubscription#pushable? says; // a filtered one never is, nor anything for a persona held back (suspended, or its login banned). public static class WebPush { // Mastodon 4.5's alerts (its Notification::TYPES, which data[alerts] takes), then Pleroma's for emoji reactions. // admin.sign_up and admin.report are kept but never fire: moderation is on /clientapi, and it notifies nobody. public static readonly IReadOnlyList Alerts = new[] { "mention", "quote", "status", "reblog", "follow", "follow_request", "favourite", "poll", "update", "quoted_update", "severed_relationships", "moderation_warning", "annual_report", "admin.sign_up", "admin.report", "pleroma:emoji_reaction" }; /// The alert a notification's type answers to: its Mastodon name. public static string AlertOf(NotificationType type) => NotificationsController.Names.TryGetValue(type, out var name) ? name : default; public static string Name(PushPolicy policy) => policy switch { PushPolicy.Followed => "followed", PushPolicy.Follower => "follower", PushPolicy.None => "none", _ => "all" }; public static PushPolicy? ParsePolicy(string value) => value?.Trim().ToLowerInvariant() switch { "all" => PushPolicy.All, "followed" => PushPolicy.Followed, "follower" => PushPolicy.Follower, "none" => PushPolicy.None, _ => (PushPolicy?)null }; public static async Task Pushable(PushSubscription subscription, Notification notification, CancellationToken token) { if (notification.Filtered || notification.AvatarId != subscription.AvatarId) return false; var alert = AlertOf(notification.Type); if (alert == default || !subscription.Alerts.Contains(alert)) return false; return await Admits(subscription, notification, token); } // Mastodon's policy_allows_notification?: everyone, nobody, the accounts the persona follows, those that follow it. What // the persona tells itself (its own poll ended, a warning from the staff, which Mastodon sends from the account itself) // passes every policy but none. static async Task Admits(PushSubscription subscription, Notification notification, CancellationToken token) { if (subscription.Policy == PushPolicy.None) return false; if (subscription.Policy == PushPolicy.All || notification.FromAccountId == subscription.AvatarId) return true; var avatarId = subscription.AvatarId; var fromId = notification.FromAccountId; var fromUri = notification.FromActorURI; if (subscription.Policy == PushPolicy.Followed) { if (!string.IsNullOrEmpty(fromId) && await DB.Default.Find() .Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == fromId).ExecuteAnyAsync(token)) return true; return !string.IsNullOrEmpty(fromUri) && await DB.Default.Find() .Match(f => f.AvatarId == avatarId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetActorURI == fromUri).ExecuteAnyAsync(token); } if (!string.IsNullOrEmpty(fromUri) && await DB.Default.Find() .Match(f => f.LocalActorId == avatarId && f.LocalActorKind == LocalActorKind.Person && f.IsAccepted && f.ActorURI == fromUri).ExecuteAnyAsync(token)) return true; return !string.IsNullOrEmpty(fromId) && await DB.Default.Find() .Match(f => f.AvatarId == fromId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == avatarId).ExecuteAnyAsync(token); } /// Every subscription of these personas goes (their tokens ended, or they did). public static async Task Forget(IReadOnlyCollection avatarIds) { var ids = avatarIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List(); if (ids.Count > 0) await DB.Default.DeleteAsync(s => ids.Contains(s.AvatarId));//a delete takes no token outside a transaction } /// The subscription made with this token goes (the token was revoked). public static async Task ForgetToken(string tokenId) { if (!string.IsNullOrEmpty(tokenId)) await DB.Default.DeleteAsync(s => s.TokenId == tokenId); } } // Hangs on Notifications.Told while the host runs and queues one SendPush job per subscription the notification goes to // (WebPushJob sends it, encrypted). Queueing is a few indexed reads and inserts; nothing leaves the server here. public class WebPushDispatcher : IHostedService { readonly IJobQueue _jobs; readonly ILogger _logger; public WebPushDispatcher(IJobQueue jobs, ILogger logger) { _jobs = jobs; _logger = logger; } public Task StartAsync(CancellationToken cancellationToken) { Notifications.Told = Dispatch; return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken) { Notifications.Told = default; return Task.CompletedTask; } public async Task Dispatch(Notification notification, CancellationToken token) { try { if (notification == default || notification.Filtered || Moderation.Restrictions.IsSuspended(notification.AvatarId)) return; var alert = WebPush.AlertOf(notification.Type); if (alert == default) return; var avatarId = notification.AvatarId; var subscriptions = await DB.Default.Find().Match(s => s.AvatarId == avatarId && s.Alerts.Contains(alert)).ExecuteAsync(token); var jobs = new List(); foreach (var subscription in subscriptions) if (await WebPush.Pushable(subscription, notification, token)) jobs.Add(new Job { Kind = JobKind.SendPush, Payload = JsonSerializer.Serialize(new PushPayload(subscription.ID, notification.ID)), Host = Uri.TryCreate(subscription.Endpoint, UriKind.Absolute, out var endpoint) ? endpoint.Host : default, DedupeKey = $"push|{subscription.ID}|{notification.ID}" }); if (jobs.Count > 0) await _jobs.EnqueueMany(jobs, token); } catch (Exception ex) when (ex is not OperationCanceledException || !token.IsCancellationRequested) { _logger.LogWarning(ex, "{Service} could not queue the pushes of a notification", nameof(WebPushDispatcher)); } } } }