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:
thepraandClaude Opus 5.5 committed 2026-10-18 09:18:26 +02:00
1 parent 87529bca49
commit 9b792042e6
43 files changed
+2079 -49

No files matched your search

+156
View File
@@ -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));
}
}
}
}