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); } } }