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
100 lines
4.2 KiB
C#
100 lines
4.2 KiB
C#
using MongoDB.Driver;
|
|
using MongoDB.Entities;
|
|
|
|
using PrivaPub.Models.Social;
|
|
|
|
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)
|
|
{
|
|
if (string.IsNullOrEmpty(avatarId) || avatarId == fromAccountId && type != NotificationType.Poll)
|
|
return;
|
|
if ((await Relationships.Hidden.AuthorsHiddenFrom(avatarId, new[] { fromActorUri }, forNotifications: true, token)).Count > 0)
|
|
return;
|
|
var action = await NotificationPolicies.Judge(avatarId, type, fromAccountId, fromActorUri, postId, token);
|
|
if (action == NotificationAction.Drop)
|
|
return;
|
|
try
|
|
{
|
|
var notification = new Notification
|
|
{
|
|
AvatarId = avatarId,
|
|
Type = type,
|
|
FromAccountId = fromAccountId,
|
|
FromActorURI = fromActorUri,
|
|
PostId = postId,
|
|
Emoji = emoji,
|
|
EmojiURL = emojiUrl,
|
|
Text = text,
|
|
DedupeKey = emoji == default ? $"{type}|{avatarId}|{fromActorUri}|{postId}" : $"{type}|{avatarId}|{fromActorUri}|{postId}|{emoji}",
|
|
Filtered = action == NotificationAction.Filter
|
|
};
|
|
await DB.Default.SaveAsync(notification, token);
|
|
// filtered: in its account's request, told to no stream
|
|
if (notification.Filtered)
|
|
{
|
|
await NotificationPolicies.Requested(notification, token);
|
|
return;
|
|
}
|
|
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)
|
|
{
|
|
}
|
|
}
|
|
|
|
// A warning from this server's staff (completion program R6): Mastodon's moderation_warning, from the persona itself as
|
|
// Mastodon's are (an AccountWarning has no account behind it), never judged by the notification policy, one for each
|
|
// act. A suspended persona reads it once its suspension is lifted; until then its login sees it suspended in its list
|
|
// of personas (/clientapi/avatar/private/list).
|
|
public static async Task Warn(string avatarId, string personaUri, string action, string text, IReadOnlyList<string> postIds, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(avatarId))
|
|
return;
|
|
var ids = postIds?.Where(id => !string.IsNullOrEmpty(id)).Distinct().ToList() ?? new List<string>();
|
|
var notification = new Notification
|
|
{
|
|
AvatarId = avatarId,
|
|
Type = NotificationType.ModerationWarning,
|
|
FromAccountId = avatarId,
|
|
FromActorURI = personaUri,
|
|
PostId = ids.FirstOrDefault(),
|
|
StatusIds = ids.Count > 1 ? ids : null,
|
|
Text = string.IsNullOrWhiteSpace(text) ? default : text.Trim(),
|
|
WarningAction = action,
|
|
DedupeKey = $"{NotificationType.ModerationWarning}|{avatarId}|staff|{MongoDB.Bson.ObjectId.GenerateNewId()}"
|
|
};
|
|
await DB.Default.SaveAsync(notification, CancellationToken.None);//an act that was done is told even when its request went away
|
|
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);
|
|
}
|
|
}
|
|
}
|