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
170 lines
6.9 KiB
C#
170 lines
6.9 KiB
C#
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)
|
|
};
|
|
}
|
|
}
|