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

@@ -0,0 +1,169 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using MongoDB.Driver;
using MongoDB.Entities;
using OpenIddict.Abstractions;
using OpenIddict.Validation.AspNetCore;
using PrivaPub.Api.Mastodon.Infrastructure;
using PrivaPub.Domain.Social;
using PrivaPub.Infrastructure.Http;
using PrivaPub.Infrastructure.Push;
using PrivaPub.Models.Social;
namespace PrivaPub.Api.Mastodon.Controllers
{
// Mastodon's Web Push API (completion program R9): one subscription per access token, so per persona, made, read, changed
// and deleted with the token itself (the `push` scope). Its parameters as Mastodon takes them, as a form or JSON:
// subscription[endpoint], subscription[keys][p256dh], subscription[keys][auth], subscription[standard] (4.4: RFC 8291
// and RFC 8292, else the aesgcm draft), data[alerts][<type>] and data[policy] (all, followed, follower, none). Its answer is
// Mastodon's WebPushSubscription. What is pushed, and how, is WebPushDispatcher's and WebPushJob's.
public class PushController : MastodonController
{
const int MaxEndpointLength = 2048;
readonly IVapid _vapid;
readonly IOptionsMonitor<FederationOptions> _federation;
public PushController(IVapid vapid, IOptionsMonitor<FederationOptions> federation)
{
_vapid = vapid;
_federation = federation;
}
string TokenId => User.GetTokenId();
// a new subscription replaces the one this token had, as on Mastodon
[HttpPost("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Create(CancellationToken token)
{
var endpoint = Params.Get("subscription[endpoint]")?.Trim();
if (string.IsNullOrEmpty(endpoint))
return Error(StatusCodes.Status400BadRequest, "Param is missing or the value is empty: subscription");
if (endpoint.Length > MaxEndpointLength || !Uri.TryCreate(endpoint, UriKind.Absolute, out var uri) || !WebPushJob.IsAllowed(uri, _federation.CurrentValue))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Endpoint is not a valid URL");
var p256dh = Params.Get("subscription[keys][p256dh]")?.Trim();
var auth = Params.Get("subscription[keys][auth]")?.Trim();
if (string.IsNullOrEmpty(p256dh) || string.IsNullOrEmpty(auth))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Key p256dh can't be blank, Key auth can't be blank");
if (!WebPushCrypto.AreUsable(WebPushCrypto.FromBase64Url(p256dh), WebPushCrypto.FromBase64Url(auth)))
return Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Keys are not valid Web Push keys");
var (alerts, policy, invalid) = Data();
if (invalid != default)
return invalid;
var tokenId = TokenId;
var accessToken = await AccessToken();
if (string.IsNullOrEmpty(tokenId) || string.IsNullOrEmpty(accessToken))
return Error(StatusCodes.Status401Unauthorized, "The access token is invalid");
var subscription = new PushSubscription
{
AvatarId = MyId,
TokenId = tokenId,
AccessToken = accessToken,
Endpoint = endpoint,
P256dh = p256dh,
Auth = auth,
Standard = Params.Bool("subscription[standard]") == true,
Alerts = alerts,
Policy = policy
};
// (two at once with one token: the unique index on the token keeps one, and the later replaces it)
for (var attempt = 0; ; attempt++)
{
await WebPush.ForgetToken(tokenId);
try
{
subscription.ID = default;
await DB.Default.SaveAsync(subscription, token);
break;
}
catch (MongoWriteException ex) when (ex.WriteError?.Category == ServerErrorCategory.DuplicateKey && attempt < 2)
{
}
}
return Json(await View(subscription, token));
}
[HttpGet("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Show(CancellationToken token) =>
await Mine(token) is { } subscription ? Json(await View(subscription, token)) : NotFoundError();
// Mastodon's update: the data given replaces what was there (alerts left out are off, the policy left out is all)
[HttpPut("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Update(CancellationToken token)
{
var subscription = await Mine(token);
if (subscription == default)
return NotFoundError();
var (alerts, policy, invalid) = Data();
if (invalid != default)
return invalid;
subscription.Alerts = alerts;
subscription.Policy = policy;
subscription.UpdatedAt = DateTime.UtcNow;
await DB.Default.Update<PushSubscription>().MatchID(subscription.ID)
.Modify(s => s.Alerts, alerts)
.Modify(s => s.Policy, policy)
.Modify(s => s.UpdatedAt, subscription.UpdatedAt)
.ExecuteAsync(token);
return Json(await View(subscription, token));
}
[HttpDelete("/api/v1/push/subscription"), Scope("push")]
public async Task<IActionResult> Delete(CancellationToken token)
{
await WebPush.ForgetToken(TokenId);
return Json(new { });
}
async Task<PushSubscription> Mine(CancellationToken token)
{
var tokenId = TokenId;
var avatarId = MyId;
return string.IsNullOrEmpty(tokenId) ? default
: await DB.Default.Find<PushSubscription>().Match(s => s.TokenId == tokenId && s.AvatarId == avatarId).ExecuteFirstAsync(token);
}
// data[alerts][<type>] (each true or false; unknown types are ignored, as Mastodon permits only its own) and
// data[policy]
(List<string> Alerts, PushPolicy Policy, IActionResult Invalid) Data()
{
var alerts = WebPush.Alerts.Where(alert => Params.Bool($"data[alerts][{alert}]") == true).ToList();
var given = Params.Get("data[policy]");
if (string.IsNullOrWhiteSpace(given))
return (alerts, PushPolicy.All, default);
return WebPush.ParsePolicy(given) is { } policy
? (alerts, policy, default)
: (alerts, PushPolicy.All, Error(StatusCodes.Status422UnprocessableEntity, "Validation failed: Policy is not included in the list"));
}
// the bearer token of this request, wherever the client put it (OpenIddict takes the header, a form field or the
// query): the payload carries it back, as Mastodon's does
async Task<string> AccessToken()
{
var stored = await HttpContext.GetTokenAsync(OpenIddictValidationAspNetCoreDefaults.AuthenticationScheme,
OpenIddictValidationAspNetCoreConstants.Tokens.AccessToken);
if (!string.IsNullOrEmpty(stored))
return stored;
var header = Request.Headers.Authorization.ToString();
if (header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
return header["Bearer ".Length..].Trim();
return Params.Get("access_token");
}
// Mastodon's WebPushSubscription
async Task<object> View(PushSubscription subscription, CancellationToken token) => new
{
id = subscription.ID,
endpoint = subscription.Endpoint,
standard = subscription.Standard,
alerts = WebPush.Alerts.ToDictionary(alert => alert, alert => subscription.Alerts.Contains(alert)),
server_key = (await _vapid.Key(token)).ForApi,
policy = WebPush.Name(subscription.Policy)
};
}
}