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][] 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 _federation; public PushController(IVapid vapid, IOptionsMonitor 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 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 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 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().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 Delete(CancellationToken token) { await WebPush.ForgetToken(TokenId); return Json(new { }); } async Task Mine(CancellationToken token) { var tokenId = TokenId; var avatarId = MyId; return string.IsNullOrEmpty(tokenId) ? default : await DB.Default.Find().Match(s => s.TokenId == tokenId && s.AvatarId == avatarId).ExecuteFirstAsync(token); } // data[alerts][] (each true or false; unknown types are ignored, as Mastodon permits only its own) and // data[policy] (List 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 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 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) }; } }