R9 Web Push, then the honest version: VAPID key, push subscriptions, encrypted pushes, 4.5.0 advertised
The server's VAPID key (PushKeys, made on first need, in Mongo) is the instance API's vapid.public_key and every app's vapid_key. /api/v1/push/subscription (POST, GET, PUT, DELETE) keeps one subscription per access token, so per persona; every notification kept (never a filtered one) is queued as a SendPush job for the subscriptions whose alert and policy admit it, and sent through the `push` client (the safe handler, https only) encrypted as RFC 8291 aes128gcm or the aesgcm draft with an ES256 VAPID JWT. 404/410 delete the subscription, 429/5xx/timeouts retry, other answers drop the push. Subscriptions end with their tokens (revocation, sessions ended, suspension, deletion, a restore); backups hold neither keys nor subscriptions. The walk of every Mastodon 4.3-4.5 client route (docs/MASTODON-API.md) added conversations/:id/unread, domain_blocks/preview, v2 notifications clear and PUT policy, /oauth/userinfo and stubs, so both instance APIs now say 4.5.0 (compatible; PrivaPub <release>). The pasture gets a push sink (pushsink.test) and scenarios/push.sh in batch A. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
This commit is contained in:
1 parent
87529bca49
commit
9b792042e6
43 files changed
+2079
-49
No files matched your search
@@ -7,6 +7,25 @@ namespace PrivaPub.Domain.Social
|
||||
{
|
||||
public static class Notifications
|
||||
{
|
||||
// told of every notification once it is kept for the persona's lists and streams: never one the notification policy
|
||||
// filtered or dropped, nor one its blocks and mutes hid. Web Push hangs here (R9, WebPushDispatcher, set while the
|
||||
// host runs; the command line sets nothing). Whatever it does never fails the notification.
|
||||
public static Func<Notification, CancellationToken, Task> Told { get; set; }
|
||||
|
||||
static async Task Tell(Notification notification, CancellationToken token)
|
||||
{
|
||||
if (Told is not { } told)
|
||||
return;
|
||||
try
|
||||
{
|
||||
await told(notification, token);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
//the hook logs its own failures
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task Add(string avatarId, NotificationType type, string fromAccountId, string fromActorUri, string postId,
|
||||
CancellationToken token, string emoji = default, string emojiUrl = default, string text = default)
|
||||
{
|
||||
@@ -42,6 +61,7 @@ namespace PrivaPub.Domain.Social
|
||||
var told = new Timelines.Streams.Event("notification", notification.ID);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
|
||||
await Tell(notification, token);
|
||||
}
|
||||
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey)
|
||||
{
|
||||
@@ -73,6 +93,7 @@ namespace PrivaPub.Domain.Social
|
||||
var told = new Timelines.Streams.Event("notification", notification.ID);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.User), told);
|
||||
Timelines.Streams.ToPersona(avatarId, new Timelines.Streams.Key(Timelines.Streams.UserNotification), told);
|
||||
await Tell(notification, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user