using System.Net;
using System.Security.Cryptography;
using Blazored.LocalStorage;
using decePubClient.Extensions;
using decePubClient.Helpers;
using decePubClient.Models;
using decePubClient.Models.Mastodon;
using decePubClient.Models.Types;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.JSInterop;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
namespace decePubClient.Services
{
///
/// Web Push on this device (Mastodon's /api/v1/push/subscription, PrivaPub's R9), through wwwroot/js/push.js.
///
/// PrivaPub keeps one subscription per access token, so per persona, and pushes it that persona's notifications alone.
/// Each persona chosen here gets a service worker registration of its own in this browser (wwwroot/push-worker.js at
/// push/{slot}/, a random slot this device keeps in PushDevice), so its own endpoint and keys: PrivaPub stores nothing
/// that ties two personas of one login together, and the worker knows the persona from its scope without reading the
/// token Mastodon's payload carries. Each persona's subscription is made, read, changed and deleted with that
/// persona's own token (IHttpService's *As), never another's, whichever persona the session acts as.
///
///
/// A subscription ends with its token on PrivaPub (a revocation, a password change, two-factor sign-in turned on,
/// sessions ended, a suspension), so a persona whose token changes is subscribed again with the new one
/// (TokenAuthStateProvider.PersonaTokenChanged), and every start of the session checks them all (Reconcile). Signing
/// out ends them first (TurnOff); a deleted or suspended persona is dropped here (Drop). Failures come back as invalid
/// WebResults; nothing throws.
///
///
public class PushService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AuthService authService, AboutService aboutService,
ILocalStorageService localStorage, IJSRuntime jsRuntime, ILoggingService logger, CoalescingStringLocalizer localizer) : IAsyncDisposable
{
/// How long a changed token waits before its persona is subscribed again: one change often brings others.
static readonly TimeSpan RenewDelay = TimeSpan.FromSeconds(2);
readonly SemaphoreSlim gate = new(1, 1);
readonly CancellationTokenSource stopping = new();
/// The personas whose tokens changed since their subscriptions were last made sure of.
readonly HashSet stale = [];
/// The personas subscribed on this device, as last read or saved.
HashSet subscribed = [];
IJSObjectReference module;
bool moduleFailed;
bool listening;
bool renewing;
/// The persona a request of this service acts as: the token changes its own exchanges make need no renewal.
string actingAs;
/// Whether this browser can be pushed to, and what the reader allowed it.
public async Task Support(CancellationToken cancellationToken = default) =>
await Js("support", new PushSupport(), [], cancellationToken) ?? new();
/// The browser's own question, asked once; what it knows already is answered at once (granted, denied or
/// default). Call it from the reader's click.
public async Task RequestPermission(CancellationToken cancellationToken = default) =>
await Js("requestPermission", "default", [], cancellationToken) ?? "default";
/// Whether the server gives a key to push with (the instance API's configuration.vapid): false for a server
/// that pushes nothing, null while the instance can't be read.
public async Task ServerPushes(CancellationToken cancellationToken = default)
{
var instance = await aboutService.Instance(cancellationToken: cancellationToken);
if (instance is null)
return null;
return instance.Configuration?.Vapid?.PublicKey is { Length: > 0 };
}
/// This device's choices: the personas subscribed, the alerts and the policy.
public async Task Device(CancellationToken cancellationToken = default)
{
var device = await localStorage.GetItemAsync(nameof(PushDevice), cancellationToken) ?? new();
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
return device;
}
/// The persona a registration's slot is for on this device (a notification's click), or null.
public async Task PersonaOf(string slot, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(slot))
return null;
return (await Device(cancellationToken)).Personas.FirstOrDefault(p => p.Slot == slot)?.PersonaId;
}
///
/// Subscribes each persona chosen, with its own token, to the alerts and the policy chosen, and unsubscribes the others;
/// none chosen is push off on this device. The browser's permission must be granted already (RequestPermission). The
/// first failure comes back; the personas that succeeded stay subscribed.
///
public async Task Apply(List personaIds, List alerts, PushPolicy policy, CancellationToken cancellationToken = default)
{
await gate.WaitAsync(cancellationToken);
try
{
Listen();
var device = await Device(cancellationToken);
device.Alerts = [.. alerts.Where(alert => PushAlerts.Offered.Contains(alert))];
device.Policy = policy;
foreach (var dropped in device.Personas.Where(p => !personaIds.Contains(p.PersonaId)).ToList())
await Unsubscribe(device, dropped, true, cancellationToken);
var result = new WebResult();
var key = personaIds.Count == 0 ? null : await ServerKey(cancellationToken);
if (personaIds.Count > 0 && key is null)
{
await Save(device, cancellationToken);
return result.Invalidate(localizer["This server does not offer push notifications."], (int)HttpStatusCode.NotImplemented);
}
foreach (var personaId in personaIds)
{
var pushed = await Push(device, personaId, key, cancellationToken);
if (pushed.IsInvalid && !result.IsInvalid)
result = pushed;
}
await Save(device, cancellationToken);
return result;
}
finally
{
gate.Release();
}
}
/// Push off on this device: PrivaPub forgets each persona's subscription (asked with that persona's token, while
/// the session holds one), then the browser forgets the subscriptions and their registrations. Signing out does it first.
public Task TurnOff(CancellationToken cancellationToken = default) => Clear(true, cancellationToken);
/// The browser forgets every subscription and registration, PrivaPub is not asked: its subscriptions ended
/// already with the tokens (every session ended, the login deleted, a session that expired).
public Task Forget(CancellationToken cancellationToken = default) => Clear(false, cancellationToken);
/// A persona deleted, or suspended by the staff: PrivaPub ended its subscription with it, and this device drops
/// its own.
public async Task Drop(string personaId, CancellationToken cancellationToken = default)
{
await gate.WaitAsync(cancellationToken);
try
{
var device = await Device(cancellationToken);
if (device.Personas.FirstOrDefault(p => p.PersonaId == personaId) is not { } entry)
return new WebResult();
await Unsubscribe(device, entry, false, cancellationToken);
await Save(device, cancellationToken);
return new WebResult();
}
finally
{
gate.Release();
}
}
///
/// Brings this device in line once the session is read (CascadingState.LoadSession), and when Settings shows it:
/// registrations it keeps no persona for go; with the permission withdrawn, or push gone from the browser, every
/// subscription goes; a persona deleted, or suspended by the staff, is dropped (PrivaPub ended its subscription); each
/// other one is made sure of (Push), so a token renewed since, or a new endpoint, subscribes it again.
///
public async Task Reconcile(List personas, CancellationToken cancellationToken = default)
{
await gate.WaitAsync(cancellationToken);
try
{
Listen();
var device = await Device(cancellationToken);
await DropStrays(device, cancellationToken);
if (device.Personas.Count == 0)
return new WebResult();
var support = await Support(cancellationToken);
if (!support.Supported || !support.IsGranted)
{
foreach (var entry in device.Personas.ToList())
await Unsubscribe(device, entry, true, cancellationToken);
await Save(device, cancellationToken);
return new WebResult();
}
// (a list that couldn't be read drops nothing)
if (personas.Count > 0)
foreach (var gone in device.Personas.Where(p => personas.FirstOrDefault(a => a.Id == p.PersonaId) is not { AccountState: not ViewAvatarState.Suspended }).ToList())
await Unsubscribe(device, gone, false, cancellationToken);
var key = await ServerKey(cancellationToken);
if (key is not null)
foreach (var entry in device.Personas.ToList())
await Warn(await Push(device, entry.PersonaId, key, cancellationToken), nameof(Reconcile), cancellationToken);
await Save(device, cancellationToken);
return new WebResult();
}
finally
{
gate.Release();
}
}
/// A notification shown by the persona's registration alone (the first subscribed one's, when it has none):
/// nothing goes to any server. False when there is none, or the browser refused.
public async Task Test(string personaId, string title, string body, CancellationToken cancellationToken = default)
{
var device = await Device(cancellationToken);
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId) ?? device.Personas.FirstOrDefault();
if (entry is null)
return false;
return await Js("test", false, [entry.Slot, title, body], cancellationToken);
}
async Task Clear(bool tellServer, CancellationToken cancellationToken)
{
await gate.WaitAsync(cancellationToken);
try
{
var device = await Device(cancellationToken);
foreach (var entry in device.Personas.ToList())
await Unsubscribe(device, entry, tellServer, cancellationToken);
await DropStrays(device, cancellationToken);
await Save(device, cancellationToken);
return new WebResult();
}
finally
{
gate.Release();
}
}
///
/// Makes sure PrivaPub pushes the persona's notifications to its subscription in this browser as the device's alerts
/// and policy say. The browser's subscription is made when missing. PrivaPub's is read: left as it is when it matches,
/// given the device's alerts and policy (PUT) when only they differ, and made again (POST) when it is missing or holds
/// another endpoint (its token changed, or the push service gave a new endpoint). A persona PrivaPub refuses (suspended,
/// or gone) is dropped; a new one that fails leaves nothing behind.
///
async Task Push(PushDevice device, string personaId, string key, CancellationToken cancellationToken)
{
var entry = device.Personas.FirstOrDefault(p => p.PersonaId == personaId);
var isNew = entry is null;
entry ??= new() { PersonaId = personaId, Slot = NewSlot() };
actingAs = personaId;
try
{
var browser = await Js("subscribe", null, [entry.Slot, key], cancellationToken);
if (browser is not { Endpoint.Length: > 0, P256dh.Length: > 0, Auth.Length: > 0 })
{
if (isNew)
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
return new WebResult().Invalidate(localizer["This browser did not subscribe: {0}", browser?.Error ?? "?"], (int)HttpStatusCode.ServiceUnavailable);
}
var result = await Tell(device, entry.PersonaId, browser, cancellationToken);
if (result.IsInvalid && (isNew || result.ErrorCode is FailureCodes.PersonaRefused))
{
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
device.Personas.Remove(entry);
return result;
}
if (result.IsInvalid)
return result;
entry.SubscriptionId = (result.Data as WebPushSubscription)?.Id ?? entry.SubscriptionId;
if (isNew)
device.Personas.Add(entry);
return result;
}
finally
{
actingAs = null;
}
}
/// PrivaPub's side of Push; Data is the WebPushSubscription it holds now.
async Task Tell(PushDevice device, string personaId, BrowserSubscription browser, CancellationToken cancellationToken)
{
var data = Data(device);
var held = await Held(personaId, cancellationToken);
if (held.IsInvalid)
return held;
if (held.Data is WebPushSubscription current && current.Endpoint == browser.Endpoint)
{
if (Matches(current, data))
return held;
var updated = await Send(personaId, () => httpService.PutAs(personaId, APIs.Mastodon.PUT_PushSubscription, new PushDataForm { Data = data },
cancellationToken: cancellationToken), cancellationToken);
if (!updated.IsInvalid || updated.StatusCode is not (int)HttpStatusCode.NotFound)
return updated;
}
return await Send(personaId, () => httpService.PostAs(personaId, APIs.Mastodon.POST_PushSubscription, new PushSubscriptionForm
{
Subscription = new()
{
Endpoint = browser.Endpoint,
Keys = new() { P256dh = browser.P256dh, Auth = browser.Auth },
Standard = browser.Standard
},
Data = data
}, cancellationToken: cancellationToken), cancellationToken);
}
/// The subscription PrivaPub holds for the persona's token as Data, or no Data when it holds none (its 404).
async Task Held(string personaId, CancellationToken cancellationToken)
{
using var response = await AsPersona(personaId, () => httpService.GetAs(personaId, APIs.Mastodon.GET_PushSubscription,
cancellationToken: cancellationToken), cancellationToken);
if (response.StatusCode is HttpStatusCode.NotFound)
return new WebResult();
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) };
}
/// A POST or PUT of the persona's subscription; Data is what PrivaPub holds then.
async Task Send(string personaId, Func> send, CancellationToken cancellationToken)
{
using var response = await AsPersona(personaId, send, cancellationToken);
if (!response.IsSuccessStatusCode)
return await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
return new WebResult { Data = await response.MastodonReadFromJsonAsync(cancellationToken) };
}
/// A request as the persona. A token PrivaPub refuses for want of the push scope (one exchanged before decePub
/// asked for it) is revoked and forgotten, and the request sent once more with the new one the exchange gives.
async Task AsPersona(string personaId, Func> send, CancellationToken cancellationToken)
{
var response = await send();
if (response.StatusCode is not HttpStatusCode.Forbidden || response.Content.Headers.Contains(SUtility.FailureHeader))
return response;
// (read once, the content stays buffered for ReadWebResult)
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!body.Contains("authorized scopes", StringComparison.OrdinalIgnoreCase))
return response;
response.Dispose();
await authService.RenewPersonaToken(personaId, cancellationToken);
return await send();
}
/// The persona goes from this device; PrivaPub is asked to forget its subscription first when tellServer and the
/// persona still holds a token (one forgotten took its subscription with it).
async Task Unsubscribe(PushDevice device, PushPersona entry, bool tellServer, CancellationToken cancellationToken)
{
if (tellServer && !authStateProvider.IsRefused(entry.PersonaId) && await authStateProvider.IsAuthenticatedAsync(cancellationToken)
&& await authStateProvider.GetPersonaToken(entry.PersonaId, cancellationToken) is not null)
{
actingAs = entry.PersonaId;
try
{
using var response = await httpService.DeleteAs(entry.PersonaId, APIs.Mastodon.DELETE_PushSubscription, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode)
await Warn(await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken), nameof(Unsubscribe), cancellationToken);
}
finally
{
actingAs = null;
}
}
await Js("unsubscribe", false, [entry.Slot], cancellationToken);
device.Personas.Remove(entry);
}
/// Registrations at push/{slot}/ this device keeps no persona for (its storage cleared, or a failure half-way) go
/// with their subscriptions, so nothing PrivaPub still pushes to them is shown here.
async Task DropStrays(PushDevice device, CancellationToken cancellationToken)
{
var known = device.Personas.Select(p => p.Slot).ToHashSet();
foreach (var slot in await Js>("slots", [], [], cancellationToken) ?? [])
if (!known.Contains(slot))
await Js("unsubscribe", false, [slot], cancellationToken);
}
/// The server's VAPID key (the instance API's configuration.vapid.public_key), or null when it pushes nothing.
async Task ServerKey(CancellationToken cancellationToken) =>
(await aboutService.Instance(cancellationToken: cancellationToken))?.Configuration?.Vapid?.PublicKey is { Length: > 0 } key ? key : null;
/// Every alert offered, on as the device has it; those PrivaPub never fires are left out, which is off.
static PushData Data(PushDevice device) => new()
{
Alerts = PushAlerts.Offered.ToDictionary(alert => alert, alert => device.Alerts.Contains(alert)),
Policy = PushPolicies.Name(device.Policy)
};
static bool Matches(WebPushSubscription held, PushData data) =>
PushPolicies.Of(held.Policy) == PushPolicies.Of(data.Policy)
&& PushAlerts.Offered.All(alert => held.Alerts.GetValueOrDefault(alert) == data.Alerts.GetValueOrDefault(alert));
static string NewSlot() => Convert.ToHexString(RandomNumberGenerator.GetBytes(8)).ToLowerInvariant();
async Task Save(PushDevice device, CancellationToken cancellationToken)
{
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
await localStorage.SetItemAsync(nameof(PushDevice), device, cancellationToken);
}
/// From the first Apply or Reconcile on: a persona whose token changes is subscribed again with the new one,
/// and a session that ends on its own (an expired or refused JWT) takes this device's subscriptions with it.
void Listen()
{
if (listening)
return;
listening = true;
authStateProvider.PersonaTokenChanged += OnTokenChanged;
authStateProvider.AuthenticationStateChanged += OnAuthenticationChanged;
}
void OnTokenChanged(string personaId)
{
if (personaId == actingAs || !subscribed.Contains(personaId) || !stale.Add(personaId) || renewing)
return;
_ = RenewSoon(stopping.Token);
}
void OnAuthenticationChanged(Task state) => _ = ForgetWhenSignedOut(state, stopping.Token);
async Task RenewSoon(CancellationToken cancellationToken)
{
renewing = true;
try
{
await Task.Delay(RenewDelay, cancellationToken);
await gate.WaitAsync(cancellationToken);
try
{
var device = await Device(cancellationToken);
var key = await ServerKey(cancellationToken);
while (stale.Count > 0)
{
var personaIds = stale.ToList();
stale.Clear();
if (key is null || !await authStateProvider.IsAuthenticatedAsync(cancellationToken))
continue;
foreach (var personaId in personaIds.Where(id => device.Personas.Any(p => p.PersonaId == id)))
await Warn(await Push(device, personaId, key, cancellationToken), nameof(RenewSoon), cancellationToken);
}
await Save(device, cancellationToken);
}
finally
{
gate.Release();
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(RenewSoon)}", cancellationToken);
}
finally
{
renewing = false;
}
}
async Task ForgetWhenSignedOut(Task state, CancellationToken cancellationToken)
{
try
{
if ((await state).User.Identity?.IsAuthenticated == true || subscribed.Count == 0)
return;
await Forget(cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(ForgetWhenSignedOut)}", cancellationToken);
}
}
async Task Warn(WebResult result, string caller, CancellationToken cancellationToken)
{
if (result.IsInvalid && result.StatusCode is not (int)HttpStatusCode.Gone)
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(PushService)}.{caller}", cancellationToken);
}
/// A push.js function; its fallback when the module can't be loaded or the call fails (logged).
async Task Js(string function, T fallback, object[] args, CancellationToken cancellationToken)
{
var push = await Module(cancellationToken);
if (push is null)
return fallback;
try
{
return await push.InvokeAsync(function, cancellationToken, args);
}
catch (JSException ex)
{
await logger.ProcessError(ex, $"{nameof(PushService)}.{function}", cancellationToken);
return fallback;
}
}
async Task Module(CancellationToken cancellationToken)
{
if (module is not null || moduleFailed)
return module;
try
{
return module = await jsRuntime.InvokeAsync("import", cancellationToken, "./js/push.js");
}
catch (JSException ex)
{
moduleFailed = true;
await logger.ProcessError(ex, $"{nameof(PushService)}.{nameof(Module)}", cancellationToken);
return null;
}
}
public async ValueTask DisposeAsync()
{
authStateProvider.PersonaTokenChanged -= OnTokenChanged;
authStateProvider.AuthenticationStateChanged -= OnAuthenticationChanged;
await stopping.CancelAsync();
stopping.Dispose();
if (module is not null)
await module.DisposeAsync();
GC.SuppressFinalize(this);
}
}
}