156 lines
6.7 KiB
C#
156 lines
6.7 KiB
C#
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<string> 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"
|
||
|
|
};
|
||
|
|
|
||
|
|
/// <summary>The alert a notification's type answers to: its Mastodon name.</summary>
|
||
|
|
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<bool> 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<bool> 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<Following>()
|
||
|
|
.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<Following>()
|
||
|
|
.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<Follower>()
|
||
|
|
.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<Following>()
|
||
|
|
.Match(f => f.AvatarId == fromId && f.FollowerKind == null && f.State == FollowState.Accepted && f.TargetAccountId == avatarId).ExecuteAnyAsync(token);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>Every subscription of these personas goes (their tokens ended, or they did).</summary>
|
||
|
|
public static async Task Forget(IReadOnlyCollection<string> avatarIds)
|
||
|
|
{
|
||
|
|
var ids = avatarIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List<string>();
|
||
|
|
if (ids.Count > 0)
|
||
|
|
await DB.Default.DeleteAsync<PushSubscription>(s => ids.Contains(s.AvatarId));//a delete takes no token outside a transaction
|
||
|
|
}
|
||
|
|
|
||
|
|
/// <summary>The subscription made with this token goes (the token was revoked).</summary>
|
||
|
|
public static async Task ForgetToken(string tokenId)
|
||
|
|
{
|
||
|
|
if (!string.IsNullOrEmpty(tokenId))
|
||
|
|
await DB.Default.DeleteAsync<PushSubscription>(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<WebPushDispatcher> _logger;
|
||
|
|
|
||
|
|
public WebPushDispatcher(IJobQueue jobs, ILogger<WebPushDispatcher> 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<PushSubscription>().Match(s => s.AvatarId == avatarId && s.Alerts.Contains(alert)).ExecuteAsync(token);
|
||
|
|
var jobs = new List<Job>();
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|