R9 Web Push in decePub: one subscription per persona, each with its own token and registration
Build / Build (push) Successful in 56s

Settings gets "Notifications on this device" (PushEditor): personas, alerts in four groups, policy, a test
notification, after a one-time notice of what the push service learns. Each persona ticked gets its own service
worker registration (push-worker.js at push/{slot}/), so its own endpoint and keys, subscribed with its own token
(IHttpService's *As); nothing PrivaPub stores ties two personas together and the worker never reads the payload's
token. PushService follows the tokens (re-subscribing after a renewal), reconciles at every session start, turns
off before sign-out and drops deleted or suspended personas. A click opens that persona's notifications; the app
badge counts the unread ones. Exchanges now ask for the push scope.

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:50:46 +02:00
1 parent d33697e3fc
commit ab17959ed4
29 files changed
+2046 -66

No files matched your search

+83 -9
View File
@@ -225,7 +225,11 @@ all of them in every theme. When you add or change one:
answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again) answered `FailureCodes.PersonaRefused` without asking PrivaPub, until it is picked anew or the session is read again)
and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once; and `CascadingState` reads the personas again and moves to another usable one, or to none, saying why once;
- `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast; - `ProcessError` / `ProcessWarning`, which log through `ILoggingService` and show a toast;
- `LogFromJs`, which JS calls back into through a `DotNetObjectReference`. - `LogFromJs`, which JS calls back into through a `DotNetObjectReference`;
- Web Push's bookkeeping (see **Web Push**): `PushService.Reconcile` once the session is read (`Forget` when it isn't
signed in), `TurnOff` before `SignOut`, `Drop` for a persona deleted or refused, the app's badge cleared with the session,
and a notification's click: `?push={slot}` read as the app starts (its persona is acted as) and `PushOpened`, which
`main.js` calls when the app is open.
**Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It **Components and pages** inherit `LocalizableComponentBase`, the equivalent of b2b.next's `WidmannComponentBase`. It
supplies: supplies:
@@ -256,6 +260,10 @@ Never redeclare the base's members in a component.
- Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses - Auth levels: `Get` / `Post` / `Put` / `Patch` / `Delete` need credentials and end the session when PrivaPub refuses
them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must them; `GetAnon` adds them when there are some; `*TotallyAnon` send none (the login and the password recovery must
not carry a JWT: PrivaPub redirects them). not carry a JWT: PrivaPub redirects them).
- `GetAs` / `PostAs` / `PutAs` / `DeleteAs` send a Mastodon API request as a given persona of the login, whichever the
session acts as: with that persona's own token (`TokenAuthStateProvider.GetPersonaToken(personaId)`), exchanged when
missing and kept without making the persona current (`SetPersonaToken`), never another persona's. Web Push uses them.
`TokenAuthStateProvider.PersonaTokenChanged` tells when a persona's token is replaced or forgotten.
- `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes. - `retryOnError` backoff: 1, 2, 4, 8, 16, 29 s, then 60 s, for at most 10 minutes.
- Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the - Each request has its own timeout (`Measure`): 100 s, or 10 minutes for a multipart body (an upload); the
`HttpClient`'s own is off. `HttpClient`'s own is off.
@@ -273,7 +281,10 @@ Never redeclare the base's members in a component.
each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`, each persona the client uses gets its own Mastodon token in exchange for the JWT (PrivaPub's `PersonaExchange`,
RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the RFC 8693 on `/oauth/token`, first-party client id `AppConfiguration:ClientId`). `AuthData` keeps the JWT and the
persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then persona tokens; logout revokes every persona token, ends this device's session (`/clientapi/user/logout`), then
forgets the session. forgets the session. The exchange asks for `read write follow push` (`APIs.OAuth.Scopes`; PrivaPub grants `push` since
R9, and one from before R9 refuses the whole exchange, since its first-party client lacks the permission: PrivaPub's
R9 is deployed first); a token exchanged before that is refused a push subscription (403, "outside the authorized
scopes"), and `AuthService.RenewPersonaToken` revokes and forgets it, so the next request exchanges one with `push`.
- **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every - **Sessions:** "Sign out everywhere" (`AuthService.LogoutEverywhere`, `/clientapi/user/logout/everywhere`) ends every
session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets session of the login, this one too, and PrivaPub revokes every persona's token with them, so the client only forgets
its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every its own and signs out. A password change, and turning two-factor sign-in on, end every other session and revoke every
@@ -322,13 +333,16 @@ Never redeclare the base's members in a component.
`/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents `/clientapi/admin`), `AboutService` (what the server says of itself to anyone: the instance API, its documents
and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and and published domain blocks, never with credentials), `AnnouncementService` (`/api/v1/announcements`: dismissing and
reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and reacting), `EmojiService` (the server's custom emoji: `/api/v1/custom_emojis` for everyone, cached, and
`/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `GroupService`, `InstanceService` and `/clientapi/admin/emoji` for administrators; see **Custom emoji**), `MediaService` (uploads, and deleting those no post holds), `PushService` (this device's Web Push subscriptions, one per
persona with its own token; see **Web Push**), `GroupService`, `InstanceService` and
`ProvenanceService`. `ProvenanceService`.
- **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote - **Reactions and votes are public** (owner decision, PrivaPub's ROADMAP): before a persona's first reaction, poll vote
or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in or downvote, `PublicNoticeService` asks it once (`PublicNoticeHost` in `MainLayout`) and remembers the persona in
localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice, localStorage (`PublicActionNotices`). Anything else that makes a persona's act visible to others gets the same notice,
with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server with its own `PublicAct`: joining an event (owner decision 2026-10-05) is asked once more, since the event's server
shows who takes part. An event card shows the persona's participation (`privapub.event.participation`: pending, shows who takes part; this device's first Web Push subscription (`PublicAct.Push`, asked once for the device,
`PublicActionNotices.PushTold`) says what the browser's push service learns (see **Web Push**). `data-public-notice`
names which: `reaction`, `participation` or `push`. An event card shows the persona's participation (`privapub.event.participation`: pending,
accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`). accepted, rejected) with Join or Leave, except for events by invitation or taken on another site (`join_mode`).
- **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:` - **What a card shows of other platforms:** custom emoji (`Helpers/CustomEmojis.cs`: a post's or account's `:shortcode:`
in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own in its text, name, warning, bio and fields, as the picture the server listed, never inside a tag; this server's own
@@ -474,7 +488,8 @@ Never redeclare the base's members in a component.
(`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or (`ReadingPreferencesEditor`, the Reading section: `data-preference-expand-media`, a select of default, show_all or
hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST hide_all, `data-preference-expand-spoilers`, a toggle, `data-preferences-save`, `data-preferences-reset`; `GET`/`POST
/clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at /clientapi/avatar/private/preferences` with `PersonaPreferencesForm`, since Mastodon's API only reads them at
`/api/v1/preferences`; see **Reading preferences**), its mutes, `/api/v1/preferences`; see **Reading preferences**), this device's notifications (`PushEditor`, the "Notifications on
this device" section, `data-settings-push`; see **Web Push**), its mutes,
blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages blocks and blocked servers (`MutesAndBlocks`), and `DataTransfer`: follows (with their boosts, notify and languages
columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and columns, each imported only when the file has it), lists, blocks, mutes, blocked servers and bookmarks exported and
imported as the CSV files Mastodon writes, so a persona moves between servers. An import imported as the CSV files Mastodon writes, so a persona moves between servers. An import
@@ -649,6 +664,58 @@ Never redeclare the base's members in a component.
notification is dismissed from its line (`data-notification-dismiss`, notification is dismissed from its line (`data-notification-dismiss`,
`TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for `TimelineService.DismissNotification`: `/api/v2/notifications/:group_key/dismiss`, its whole group, `ungrouped-{id}` for
one alone), and the page takes that item out (`Content.OnDismissed`). one alone), and the page takes that item out (`Content.OnDismissed`).
- **Web Push** (PrivaPub's R9, Mastodon's `/api/v1/push/subscription`; `Services/PushService.cs`, `wwwroot/js/push.js`,
`wwwroot/push-worker.js`, `Components/Settings/PushEditor.razor`). Notifications reach the device while decePub is closed.
- **One subscription per persona, each in its own registration.** PrivaPub keeps one subscription per access token, so
per persona, and pushes it that persona's notifications alone. Each persona ticked on this device gets a service worker
registration of its own: `push-worker.js` at the scope `push/{slot}/`, the slot a random name this device keeps
(`PushDevice` in localStorage: the personas with their slots and PrivaPub's subscription ids, the alerts, the policy;
never a token). So each persona's subscription has its own endpoint and keys: PrivaPub stores nothing that ties two
personas of one login together (one endpoint shared by them would), and the worker knows its persona from its scope.
The app's own workers (`service-worker.js`, `service-worker.published.js`) receive no pushes; one `push-worker.js`
serves both builds and is never precached.
- **Each persona's own token.** Its subscription is made (`POST`), read (`GET`), changed (`PUT`, `data[alerts]` and
`data[policy]`) and deleted (`DELETE`) as that persona (`IHttpService`'s `*As`), whichever persona the session acts
as; JSON, which PrivaPub flattens to Mastodon's `subscription[endpoint]`, `subscription[keys][p256dh]`,
`subscription[keys][auth]`, `subscription[standard]` (true: RFC 8291's aes128gcm, unless the browser lists only
aesgcm), `data[alerts][…]`, `data[policy]`. The browser subscribes with the server's key, the instance API's
`configuration.vapid.public_key` (`InstanceV2.Configuration.Vapid`, read through `AboutService`).
- **Privacy.** The payload is encrypted to the persona's browser subscription alone, so the push service (Google's,
Mozilla's, Apple's…) learns only that something arrived, when and how big, never the words, the sender or the persona.
The payload is Mastodon's and carries the subscription's access token: `push-worker.js` never reads, keeps, shows or
passes it on; it reads `title`, `body`, `icon`, `notification_id` (the notification's `tag`, so a repeat replaces it),
`notification_type` and `preferred_locale`. Before this device's first subscription, the one-time notice says so
(`PublicAct.Push`).
- **Lifecycle.** A subscription ends with its token on PrivaPub, so `PushService` follows the tokens: a persona whose
token is replaced or forgotten (a 401 exchanged again, a password change, two-factor turned on, a scope renewal) is
subscribed again with the new one two seconds later (`PersonaTokenChanged`); every start of the session reconciles
(`Reconcile`: registrations it keeps no persona for are unregistered; permission withdrawn, or push gone from the
browser, unsubscribes everything; a persona deleted or suspended is dropped; each other one is checked with `GET` and
`PUT`/`POST` only when PrivaPub's differs, e.g. a new endpoint after the worker's `pushsubscriptionchange`). Signing out
`DELETE`s each persona's subscription with its own token, then unregisters (`TurnOff`, in `CascadingState.SignOut`);
"Sign out everywhere", deleting the login, and a session that ended on its own only unregister (`Forget`: PrivaPub
ended them with the tokens). A persona deleted (`CascadingState.PersonaDeleted`) or refused a token is `Drop`ped.
- **A click** closes the notification and opens decePub on that persona's Notifications tab: an open decePub tab is
focused and posted `{type: "decepub-push-open", slot}` (the worker can't navigate a tab another worker controls), which
`main.js` hands to `CascadingState.PushOpened` (it switches to the persona, then navigates to `?notifications=1`);
otherwise a new tab opens at `/?notifications=1&push={slot}` (CascadingState acts as the slot's persona when the session
is read; `Index.razor` opens the Notifications tab and takes the question out of the address with `replace`).
- **The badge.** `Index.razor` sets the installed app's badge to the persona's unread notifications whenever the count
changes, and clears it when they are read (`AppStatusService.SetAppBadge` / `ClearAppBadge`; `main.js` does nothing
where the Badging API is missing); a push sets a plain dot until the app counts; the session's end clears it.
- **Settings** (`PushEditor`, `data-push-editor`, `data-push-state` `subscribed|off|denied|unsupported`): on or off for
this device (`data-push-toggle="on|off"`: the notice, then the browser's permission asked from the click, then each
ticked persona subscribed; off `TurnOff`s); the personas of the login (`input[data-push-persona="{userName}"]`, a
suspended one disabled; the current one ticked while none is subscribed); the alerts in four groups (mentions and
posts: `mention`, `status`, `quote`, `update`, `poll`; reactions: `favourite`, `reblog`, `pleroma:emoji_reaction`;
follows: `follow`, `follow_request`; the staff: `moderation_warning`; each `input[data-push-alert="{alert}"]`; those
PrivaPub keeps but never fires, `quoted_update`, `severed_relationships`, `annual_report` and the admin ones, are not
offered and stay off); whose (`select[data-push-policy]`: all, followed, follower, none); a test notification shown by
the persona's registration alone, nothing sent to the server (`data-push-test`); `data-push-reset`; `data-push-save`
(while on: subscribes the personas newly ticked, unsubscribes the others, `PUT`s the alerts and policy). Unsupported
(`data-push-unsupported`: `server` without a VAPID key, `insecure`, `ios-install` for an iPhone or iPad whose decePub
isn't added to the Home Screen and opened from there, `unsupported`) says why; blocked notifications say how to allow
them (`data-push-denied`).
- **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a - **Scheduled posts** (`Pages/Scheduled.razor` at `/scheduled`): the composer's clock button gives a Mastodon API post a
time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them. time (`scheduled_at`, at least five minutes ahead; located and group posts have none); the page moves or drops them.
- **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts - **Explore** (`Pages/Explore.razor` at `/explore`): trending hashtags, posts and links (PrivaPub's, from public posts
@@ -750,7 +817,7 @@ Never redeclare the base's members in a component.
key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime. key needs its `public static string` property in `ErrorMessages.Designer.cs` too, or validation throws at runtime.
**Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`, **Client storage** is localStorage (Blazored.LocalStorage, camelCase JSON): `AuthData`, `PublicCacheData`, `FeedFilter`,
the server cache `ServerInfos` and the server's custom emoji `CustomEmojiCache`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the the server cache `ServerInfos`, the server's custom emoji `CustomEmojiCache` and this device's Web Push `PushDevice`, keyed by `nameof(Type)`. There is no IndexedDB: `ILoggingService` writes to the
browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser browser console only, and `main.js` deletes the database (`data`) where the client once kept its log, wherever a browser
still holds it. still holds it.
@@ -764,10 +831,17 @@ it: the root's name, its policies as claims, and the current persona. Pages that
- Each helper reports its errors back through `window.logFromJs`. - Each helper reports its errors back through `window.logFromJs`.
- The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in - The other scripts (`js/theme.js`, HeadElement's, `vendor/cally.js`, `js/blurhash.js`) are loaded by `<script>` tags in
`wwwroot/index.html`; the feed's `wwwroot/index.html`; the feed's
modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`. modules (`js/feed.js`, `js/globe.js`) are imported on demand through `IJSObjectReference`, as is Web Push's (`js/push.js`,
by `PushService`: support and permission, a persona's registration and subscription made, read and dropped, the slots
held, a test notification). `main.js` listens for the personas' push workers (`navigator.serviceWorker` messages, a
notification's click while the app is open) and calls `CascadingState.PushOpened`.
**PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The **PWA.** Published builds use `wwwroot/service-worker.published.js`, and `service-worker.js` is the dev version. The
published worker names its cache after the assets manifest's version, so each publish drops the previous cache. published worker names its cache after the assets manifest's version, so each publish drops the previous cache. Web Push
has workers of its own, one registration per persona subscribed (`wwwroot/push-worker.js` at `push/{slot}/`, the same file
in both builds, kept out of the precache; see **Web Push**): `push` shows the notification, `notificationclick` opens
the app on that persona's notifications, `pushsubscriptionchange` subscribes again with the same key. They control no
page and have no fetch handler.
## Development rules ## Development rules
@@ -900,7 +974,7 @@ host. The steps:
roll back to the snapshot. roll back to the snapshot.
The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and The nginx vhost and headers are in `deploy/nginx/` (it sends `X-Robots-Tag: noindex, nofollow`), and
`deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost. `deploy/max/setup.sh` is the one-time root setup on the server. If you add a file that must never be cached, add it to the `expires -1` locations in the vhost (the workers `service-worker.js` and `push-worker.js` are there).
## Conventions ## Conventions
+1
View File
@@ -27,6 +27,7 @@ namespace decePubClient;
[JsonSerializable(typeof(ServerInfo))] [JsonSerializable(typeof(ServerInfo))]
[JsonSerializable(typeof(List<ServerInfo>))] [JsonSerializable(typeof(List<ServerInfo>))]
[JsonSerializable(typeof(CustomEmojiCache))] [JsonSerializable(typeof(CustomEmojiCache))]
[JsonSerializable(typeof(PushDevice))]
[JsonSerializable(typeof(UploadMedia))] [JsonSerializable(typeof(UploadMedia))]
[JsonSerializable(typeof(ValidationErrors))] [JsonSerializable(typeof(ValidationErrors))]
[JsonSerializable(typeof(WebResult))] [JsonSerializable(typeof(WebResult))]
+26 -9
View File
@@ -1,17 +1,13 @@
@inherits LocalizableComponentBase @inherits LocalizableComponentBase
@implements IDisposable @implements IDisposable
@* The one-time notice before a persona's first reaction or poll vote, and before it first joins an event @* The one-time notice before a persona's first reaction or poll vote, before it first joins an event, and before this
(PublicNoticeService), in MainLayout. *@ device's first Web Push subscription (PublicNoticeService), in MainLayout. data-public-notice names which. *@
<DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]" <DModal Placement="DModalPlacement.ResponsiveBottom" IsOpen="Notices.IsAsking" IsOpenChanged="Closed" CloseOnBackdrop="false" CloseText="@Localizer["Close"]"
data-public-notice> data-public-notice="@ActName">
<Heading>@(Notices.Act == PublicAct.Participation ? Localizer["Joining an event is public"] : Localizer["Reactions and votes are public"])</Heading> <Heading>@HeadingText</Heading>
<ChildContent> <ChildContent>
<p class="text-sm"> <p class="text-sm">@NoticeText</p>
@(Notices.Act == PublicAct.Participation
? Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
: Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""])
</p>
</ChildContent> </ChildContent>
<Actions> <Actions>
<DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton> <DButton Size="DSize.Sm" OnClick="() => Notices.Answer(false)">@Localizer["Cancel"]</DButton>
@@ -22,6 +18,27 @@
@code { @code {
[Inject] PublicNoticeService Notices { get; set; } [Inject] PublicNoticeService Notices { get; set; }
string ActName => Notices.Act switch
{
PublicAct.Participation => "participation",
PublicAct.Push => "push",
_ => "reaction"
};
string HeadingText => Notices.Act switch
{
PublicAct.Participation => Localizer["Joining an event is public"],
PublicAct.Push => Localizer["Notifications go through your browser's push service"],
_ => Localizer["Reactions and votes are public"]
};
string NoticeText => Notices.Act switch
{
PublicAct.Participation => Localizer["The event's server shows who takes part, {0} included. Your other personas are never named.", CascadingState.Persona?.Name ?? ""],
PublicAct.Push => Localizer["To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together."],
_ => Localizer["Anyone who can see the post can see that {0} reacted to it or voted in it. Your other personas are never named.", CascadingState.Persona?.Name ?? ""]
};
protected override void OnInitialized() protected override void OnInitialized()
{ {
Notices.Changed += Refresh; Notices.Changed += Refresh;
+5
View File
@@ -148,6 +148,7 @@
@code { @code {
[Inject] AuthService AuthService { get; set; } [Inject] AuthService AuthService { get; set; }
[Inject] ConfirmService Confirm { get; set; } [Inject] ConfirmService Confirm { get; set; }
[Inject] PushService Push { get; set; }
/// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary> /// <summary>The login as PrivaPub describes it; null until read, or when it couldn't be (the email block stays empty).</summary>
ViewAccount account; ViewAccount account;
@@ -294,6 +295,8 @@
return; return;
} }
// PrivaPub ended every Web Push subscription with the tokens: this browser forgets its own
await Push.Forget();
await CascadingState.SignOut(); await CascadingState.SignOut();
Navigation.NavigateTo("login"); Navigation.NavigateTo("login");
} }
@@ -309,6 +312,8 @@
return; return;
} }
// the personas' Web Push subscriptions went with them: this browser forgets its own, asking no persona gone
await Push.Forget();
await CascadingState.SignOut(); await CascadingState.SignOut();
Navigation.NavigateTo("login"); Navigation.NavigateTo("login");
} }
+345
View File
@@ -0,0 +1,345 @@
@inherits LocalizableComponentBase
@* Notifications on this device (Web Push, PrivaPub's R9; Services/PushService.cs). Each persona ticked gets a push
subscription of its own in this browser, made with its own token, so PrivaPub keeps nothing that ties the personas
together; the alerts and the policy are this device's, the same for each persona. The toggle turns it on (after the
one-time notice of what the browser's push service learns, PublicAct.Push, and the browser's own permission, asked from
the click) or off; Save applies the choices while it is on. What can't be is said instead: an insecure page, a browser
without Web Push, an iPhone or iPad whose decePub isn't added to the Home Screen, a server that pushes nothing, or
notifications this browser blocks. *@
<div class="flex flex-col gap-4 w-full" data-push-editor data-push-state="@StateName">
@if (IsLoading)
{
<p class="flex w-full items-center justify-center gap-2">
<DLoading Variant="DLoadingVariant.Dots" LabelText="@Localizer["loading"]" />
@Localizer["loading"]
</p>
}
else if (State is PushState.Unsupported)
{
<DAlert Color="DAlertColor.Info" Variant="DAlertVariant.Soft" class="text-sm" data-push-unsupported="@UnsupportedReason">
<span>@UnsupportedText</span>
</DAlert>
}
else if (personas.Count == 0)
{
<p class="w-full text-center"><i class="ion-ios-remove-circle-outline"></i> @Localizer["No persona"]</p>
}
else
{
<p class="text-sm">@Localizer["Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here."]</p>
@if (State is PushState.Denied)
{
<DAlert Color="DAlertColor.Warning" Variant="DAlertVariant.Soft" class="text-sm" data-push-denied>
<span>@Localizer["This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here."]</span>
</DAlert>
}
<DToggle LabelText="@Localizer["Notifications on this device"]" Value="isOn" ValueChanged="Toggle" ValueExpression="() => isOn"
disabled="@(IsDefaultDisabled || isSaving || State is PushState.Denied)" data-push-toggle="@(isOn ? "on" : "off")" />
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2">
<legend class="text-sm font-semibold mb-1">@Localizer["Personas"]</legend>
@foreach (var choice in personas)
{
<DCheckbox @bind-Value="choice.Picked" LabelText="@choice.Shown" disabled="@(IsDefaultDisabled || isSaving || !choice.Usable)"
data-push-persona="@choice.UserName" />
}
</fieldset>
@foreach (var group in groups)
{
<fieldset class="flex flex-col gap-1 pointer-coarse:gap-2">
<legend class="text-sm font-semibold mb-1">@group.Heading</legend>
@foreach (var alert in group.Alerts)
{
<DCheckbox @bind-Value="alert.Picked" LabelText="@alert.Shown" disabled="@(IsDefaultDisabled || isSaving)" data-push-alert="@alert.Name" />
}
</fieldset>
}
<label class="flex flex-col gap-1 max-w-md">
<span class="text-sm">@Localizer["Whose notifications reach this device"]</span>
<DSelect TValue="PushPolicy" class="w-full" @bind-Value="policy" disabled="@(IsDefaultDisabled || isSaving)" data-push-policy>
<option value="@PushPolicy.All">@Localizer["Anyone"]</option>
<option value="@PushPolicy.Followed">@Localizer["People you follow"]</option>
<option value="@PushPolicy.Follower">@Localizer["Your followers"]</option>
<option value="@PushPolicy.None">@Localizer["Nobody"]</option>
</DSelect>
</label>
<div class="flex flex-wrap items-center gap-2 pointer-coarse:gap-2">
<p class="flex-1 min-w-48 text-xs opacity-70">
@Localizer["To try them, show a test notification: this browser shows it by itself, nothing goes to the server. A mention or a favourite from another account arrives the same way, even with decePub closed."]
</p>
<DButton Size="DSize.Sm" OnClick="Test" Disabled="@(IsDefaultDisabled || isSaving || !isOn)" data-push-test>
<i class="ion-md-notifications" aria-hidden="true"></i><span>@Localizer["Show a test notification"]</span>
</DButton>
</div>
<p class="text-xs opacity-70">@Localizer["Only this device: another device chooses for itself. The push service of this browser (Google's, Mozilla's, Apple's…) learns when a notification arrives, never what it says."]</p>
<div class="flex flex-wrap gap-2 pointer-coarse:gap-2 justify-end">
<DButton Size="DSize.Sm" OnClick="Reset" Disabled="@(IsDefaultDisabled || isSaving || !IsChanged)" data-push-reset>@Localizer["Reset"]</DButton>
<DButton Size="DSize.Sm" Color="DColor.Primary" OnClick="Save" Disabled="@(IsDefaultDisabled || isSaving || !isOn || !IsChanged)" data-push-save>
@if (isSaving)
{
<DLoading Size="DSize.Xs" LabelText="@Localizer["loading"]" />
}
<i class="ion-md-checkmark" aria-hidden="true"></i><span>@Localizer["Save"]</span>
</DButton>
</div>
}
</div>
@code {
/// <summary>The title of a test notification: the app's own name, in every language.</summary>
const string TestTitle = "decePub";
[Inject] PushService PushService { get; set; }
[Inject] PublicNoticeService Notices { get; set; }
PushSupport support = new() { Supported = true };
PushDevice device = new();
// whether the server gives a key to push with; unknown (null) until it is read, offline
bool? serverPushes;
bool isOn;
bool isSaving;
PushPolicy policy = PushPolicy.All;
List<PersonaChoice> personas = [];
List<AlertGroup> groups = [];
// the personas list the choices were made from: a new one (a persona made or deleted) makes them again
List<ViewAvatar> choseFrom;
PushState State => !support.Supported || serverPushes == false ? PushState.Unsupported
: support.IsDenied ? PushState.Denied
: isOn ? PushState.Subscribed
: PushState.Off;
string StateName => State switch
{
PushState.Subscribed => "subscribed",
PushState.Denied => "denied",
PushState.Unsupported => "unsupported",
_ => "off"
};
string UnsupportedReason => serverPushes == false ? "server" : support.Reason ?? "unsupported";
string UnsupportedText => UnsupportedReason switch
{
"server" => Localizer["This server does not offer push notifications."],
"ios-install" => Localizer["On an iPhone or iPad, add decePub to the Home Screen (Share, then Add to Home Screen) and open it from there: Safari offers notifications only to an installed app."],
"insecure" => Localizer["Notifications need decePub opened over https."],
_ => Localizer["This browser can't receive notifications from decePub while it is closed."]
};
List<string> ChosenPersonas => [.. personas.Where(p => p.Picked && p.Usable).Select(p => p.Id)];
List<string> ChosenAlerts => [.. groups.SelectMany(g => g.Alerts).Where(a => a.Picked).Select(a => a.Name)];
bool IsChanged => policy != device.Policy
|| !ChosenAlerts.ToHashSet().SetEquals(device.Alerts)
|| !ChosenPersonas.ToHashSet().SetEquals(device.Personas.Select(p => p.PersonaId));
protected override async Task OnInitializedAsync()
{
groups =
[
new(Localizer["Mentions and posts"],
[
new("mention", Localizer["Mentions"]),
new("status", Localizer["New posts from accounts you get notified of"]),
new("quote", Localizer["Quotes of your posts"]),
new("update", Localizer["Edits of posts you interacted with"]),
new("poll", Localizer["Polls that ended"])
]),
new(Localizer["Reactions to your posts"],
[
new("favourite", Localizer["Favourites"]),
new("reblog", Localizer["Boosts of your posts"]),
new("pleroma:emoji_reaction", Localizer["Emoji reactions"])
]),
new(Localizer["Who follows you"],
[
new("follow", Localizer["New followers"]),
new("follow_request", Localizer["Follow requests"])
]),
new(Localizer["From this server's staff"],
[
new("moderation_warning", Localizer["Warnings from the staff"])
])
];
// what the browser says and what this device chose are read at once; the server's key needs the connection
await ReadDevice();
TakeChoices();
if (CascadingState.IsOffline)
{
WhenOnline(LoadData);
StartOnlinePolling();
IsLoading = false;
return;
}
await LoadData();
IsLoading = false;
}
async Task LoadData()
{
serverPushes = await PushService.ServerPushes();
// the permission withdrawn in the browser's settings since: the subscriptions go, as at start-up
if (device.Personas.Count > 0 && !support.IsGranted)
{
await PushService.Reconcile(CascadingState.Personas);
await ReadDevice();
}
}
protected override void OnParametersSet()
{
if (ReferenceEquals(choseFrom, CascadingState.Personas))
return;
choseFrom = CascadingState.Personas;
ChoosePersonas(keepPicks: true);
}
async Task ReadDevice()
{
support = await PushService.Support();
device = await PushService.Device();
isOn = device.Personas.Count > 0;
}
/// <summary>The choices as this device has them: the personas subscribed (the current one, when none is), the alerts
/// and the policy.</summary>
void TakeChoices()
{
ChoosePersonas(keepPicks: false);
foreach (var alert in groups.SelectMany(g => g.Alerts))
alert.Picked = device.Alerts.Contains(alert.Name);
policy = device.Policy;
}
void ChoosePersonas(bool keepPicks)
{
var picks = keepPicks ? personas.ToDictionary(p => p.Id, p => p.Picked) : new Dictionary<string, bool>();
var subscribedIds = device.Personas.Select(p => p.PersonaId).ToHashSet();
personas = [.. CascadingState.Personas.Select(persona => new PersonaChoice
{
Id = persona.Id,
UserName = persona.UserName,
Shown = string.IsNullOrWhiteSpace(persona.Name) ? "@" + persona.UserName : $"{persona.Name} (@{persona.UserName})",
Usable = CascadingState.CanUse(persona),
Picked = picks.TryGetValue(persona.Id, out var picked) ? picked
: subscribedIds.Count == 0 ? persona.Id == CascadingState.PersonaId
: subscribedIds.Contains(persona.Id)
})];
}
void Reset() => TakeChoices();
async Task Toggle(bool on)
{
if (!on)
{
await TurnOff();
return;
}
var chosen = ChosenPersonas;
if (chosen.Count == 0)
{
Toast.AddWarning(Localizer["Choose a persona first."]);
return;
}
// told once for this device what the push service learns; then the browser's own question, from this click
if (!await Notices.Confirm(CascadingState.PersonaId, PublicAct.Push))
return;
var permission = await PushService.RequestPermission();
if (permission != "granted")
{
support = await PushService.Support();
Toast.AddWarning(Localizer["This browser did not allow notifications."]);
return;
}
await Apply(chosen, Localizer["Notifications are on for this device."]);
}
async Task Save()
{
var chosen = ChosenPersonas;
if (chosen.Count == 0)
{
await TurnOff();
return;
}
await Apply(chosen, Localizer["Notification settings saved for this device."]);
}
async Task Apply(List<string> chosen, string success)
{
isSaving = true;
var result = await PushService.Apply(chosen, ChosenAlerts, policy);
isSaving = false;
await ReadDevice();
TakeChoices();
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(success);
}
async Task TurnOff()
{
isSaving = true;
var result = await PushService.TurnOff();
isSaving = false;
// the choices stay as they were, for the next time it is turned on
await ReadDevice();
if (result.IsInvalid)
{
Toast.AddResponseError(result);
return;
}
Toast.AddResponseSuccess(Localizer["Notifications are off for this device."]);
}
async Task Test()
{
if (await PushService.Test(CascadingState.PersonaId, TestTitle, Localizer["A test notification: this is how they show on this device."]))
return;
Toast.AddWarning(Localizer["This browser did not show the test notification."]);
}
enum PushState
{
Unsupported = 0,
Denied = 1,
Off = 2,
Subscribed = 3
}
sealed class PersonaChoice
{
public string Id { get; set; }
public string UserName { get; set; }
public string Shown { get; set; }
public bool Usable { get; set; }
public bool Picked { get; set; }
}
sealed class AlertChoice(string name, string shown)
{
public string Name { get; } = name;
public string Shown { get; } = shown;
public bool Picked { get; set; } = true;
}
sealed record AlertGroup(string Heading, List<AlertChoice> Alerts);
}
+106 -5
View File
@@ -7,7 +7,10 @@
taken at sign-in and at start-up, saved there a moment after it changes here. A persona the staff suspended is never taken at sign-in and at start-up, saved there a moment after it changes here. A persona the staff suspended is never
acted as: it can't be chosen, and when PrivaPub refuses the current one a token (TokenAuthStateProvider.PersonaRefused) acted as: it can't be chosen, and when PrivaPub refuses the current one a token (TokenAuthStateProvider.PersonaRefused)
the session moves to another persona of the login, or to none, and says why once. The current persona's reading the session moves to another persona of the login, or to none, and says why once. The current persona's reading
preferences (how its cards show media and content warnings) are read with it and kept here. *@ preferences (how its cards show media and content warnings) are read with it and kept here. Web Push follows the
session (PushService): checked once it is read, turned off before signing out, a persona dropped when it is deleted or
refused; a notification's click opens its persona's notifications (?push= when it opens the app, PushOpened when the
app is open). *@
<CascadingValue IsFixed=false Value=this> <CascadingValue IsFixed=false Value=this>
@ChildContent @ChildContent
</CascadingValue> </CascadingValue>
@@ -27,6 +30,8 @@
[Inject] AuthService AuthService { get; set; } [Inject] AuthService AuthService { get; set; }
[Inject] TokenAuthStateProvider AuthStateProvider { get; set; } [Inject] TokenAuthStateProvider AuthStateProvider { get; set; }
[Inject] InstanceService InstanceService { get; set; } [Inject] InstanceService InstanceService { get; set; }
[Inject] PushService PushService { get; set; }
[Inject] NavigationManager Navigation { get; set; }
[Inject] CoalescingStringLocalizer Localizer { get; set; } [Inject] CoalescingStringLocalizer Localizer { get; set; }
public bool IsOnline { get; private set; } = true; public bool IsOnline { get; private set; } = true;
@@ -66,10 +71,14 @@
CancellationTokenSource pendingSettingsSave; CancellationTokenSource pendingSettingsSave;
/// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary> /// <summary>A refused persona is being moved on from (MoveOnFromRefused); a refusal meanwhile is seen by its loop.</summary>
bool handlingRefusal; bool handlingRefusal;
/// <summary>The registration slot a Web Push notification's click opened the app with (?push=, push-worker.js): its
/// persona is acted as when the session is first read. Taken before the feed page takes the address's question away.</summary>
string pushSlot;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
var cancellationToken = disposeCancellation.Token; var cancellationToken = disposeCancellation.Token;
pushSlot = QueryValue(Navigation.Uri, "push");
try try
{ {
cascadingStateReference = DotNetObjectReference.Create(this); cascadingStateReference = DotNetObjectReference.Create(this);
@@ -209,6 +218,8 @@
ClearSession(); ClearSession();
IsSessionLoaded = true; IsSessionLoaded = true;
StateHasChanged(); StateHasChanged();
// a session that ended while the app was closed: this browser stops showing what PrivaPub still pushes
InBackground(() => PushService.Forget(cancellationToken), nameof(LoadSession), cancellationToken);
return; return;
} }
@@ -221,7 +232,9 @@
foreach (var usable in Personas.Where(CanUse)) foreach (var usable in Personas.Where(CanUse))
AuthStateProvider.Unrefuse(usable.Id); AuthStateProvider.Unrefuse(usable.Id);
var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId); var stored = Personas.FirstOrDefault(p => p.Id == session.PersonaId);
var personaId = stored is not null && CanUse(stored) ? stored.Id : Personas.FirstOrDefault(CanUse)?.Id; var pushed = await PushedPersona(cancellationToken);
var personaId = pushed is not null && CanUse(pushed) ? pushed.Id
: stored is not null && CanUse(stored) ? stored.Id : Personas.FirstOrDefault(CanUse)?.Id;
if (stored is not null && !CanUse(stored)) if (stored is not null && !CanUse(stored))
Toast.AddWarning(personaId is null Toast.AddWarning(personaId is null
? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", Shown(stored)] ? Localizer["{0} is suspended by this server's staff, and no other persona of this login can act now.", Shown(stored)]
@@ -236,6 +249,7 @@
Preferences = new(); Preferences = new();
IsSessionLoaded = true; IsSessionLoaded = true;
StateHasChanged(); StateHasChanged();
ReconcilePush(cancellationToken);
return; return;
} }
@@ -244,8 +258,79 @@
await SwitchPersona(personaId, cancellationToken); await SwitchPersona(personaId, cancellationToken);
IsSessionLoaded = true; IsSessionLoaded = true;
StateHasChanged(); StateHasChanged();
ReconcilePush(cancellationToken);
} }
/// <summary>This device's Web Push subscriptions brought in line with the personas (PushService.Reconcile), once the
/// current persona holds its token, so the two never exchange one for it at once.</summary>
void ReconcilePush(CancellationToken cancellationToken)
{
var personas = Personas;
InBackground(() => PushService.Reconcile(personas, cancellationToken), nameof(LoadSession), cancellationToken);
}
/// <summary>The persona whose notification's click opened the app (pushSlot), once; null otherwise.</summary>
async Task<ViewAvatar> PushedPersona(CancellationToken cancellationToken)
{
var slot = pushSlot;
pushSlot = null;
if (slot is null)
return null;
var personaId = await PushService.PersonaOf(slot, cancellationToken);
return Personas.FirstOrDefault(p => p.Id == personaId);
}
/// <summary>
/// A Web Push notification was clicked while the app is open (push-worker.js posts it, main.js calls this): its persona
/// is acted as, unless it can't be, and the feed opens on the Notifications tab (Index.razor's ?notifications=1).
/// </summary>
[JSInvokable]
public async Task PushOpened(string slot)
{
var cancellationToken = disposeCancellation.Token;
try
{
if (!IsSignedIn)
return;
var personaId = await PushService.PersonaOf(slot, cancellationToken);
if (personaId is not null && personaId != PersonaId && Personas.FirstOrDefault(p => p.Id == personaId) is { } persona && CanUse(persona))
{
var switched = await SwitchPersona(personaId, cancellationToken);
if (switched.IsInvalid)
Toast.AddResponseError(switched);
}
Navigation.NavigateTo("?notifications=1");
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{nameof(PushOpened)}", cancellationToken);
}
}
/// <summary>Runs Web Push's bookkeeping without holding the session up; it fails quietly, logged.</summary>
void InBackground(Func<Task<WebResult>> job, string caller, CancellationToken cancellationToken) => _ = RunInBackground(job, caller, cancellationToken);
async Task RunInBackground(Func<Task<WebResult>> job, string caller, CancellationToken cancellationToken)
{
try
{
var result = await job();
if (result.IsInvalid && result.StatusCode is not 410)
await Logger.ProcessWarning(result.ToString(Localizer), $"{nameof(CascadingState)}.{caller}", cancellationToken);
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
await Logger.ProcessError(ex, $"{nameof(CascadingState)}.{caller}", cancellationToken);
}
}
/// <summary>A value of the address's query, or null.</summary>
static string QueryValue(string uri, string name) =>
System.Web.HttpUtility.ParseQueryString(new Uri(uri).Query)[name] is { Length: > 0 } value ? value : null;
/// <summary>Whether the persona may be acted as: not suspended by the server's staff (its login sees it so in its list).</summary> /// <summary>Whether the persona may be acted as: not suspended by the server's staff (its login sees it so in its list).</summary>
public static bool CanUse(ViewAvatar persona) => persona.AccountState is not ViewAvatarState.Suspended; public static bool CanUse(ViewAvatar persona) => persona.AccountState is not ViewAvatarState.Suspended;
@@ -285,10 +370,13 @@
StateHasChanged(); StateHasChanged();
} }
/// <summary>Reads the root's personas again, after one was made.</summary> /// <summary>Reads the root's personas again, after one was made (or an administrator acted on their own).</summary>
public async Task ReloadPersonas(CancellationToken cancellationToken = default) public async Task ReloadPersonas(CancellationToken cancellationToken = default)
{ {
Personas = await AuthService.Personas(cancellationToken); Personas = await AuthService.Personas(cancellationToken);
// one suspended meanwhile had its Web Push subscription ended with it: this device drops its own
foreach (var suspended in Personas.Where(p => !CanUse(p)).Select(p => p.Id).ToList())
InBackground(() => PushService.Drop(suspended, cancellationToken), nameof(ReloadPersonas), cancellationToken);
StateHasChanged(); StateHasChanged();
} }
@@ -299,6 +387,8 @@
/// </summary> /// </summary>
public async Task PersonaDeleted(string personaId, CancellationToken cancellationToken = default) public async Task PersonaDeleted(string personaId, CancellationToken cancellationToken = default)
{ {
// PrivaPub ended its Web Push subscription with it; this device's goes too
await PushService.Drop(personaId, cancellationToken);
await AuthStateProvider.ForgetPersonaToken(personaId, cancellationToken); await AuthStateProvider.ForgetPersonaToken(personaId, cancellationToken);
Personas = await AuthService.Personas(cancellationToken); Personas = await AuthService.Personas(cancellationToken);
if (personaId != PersonaId) if (personaId != PersonaId)
@@ -324,7 +414,12 @@
StateHasChanged(); StateHasChanged();
} }
void OnPersonaRefused(string personaId) => _ = InvokeAsync(() => MoveOnFromRefused(disposeCancellation.Token)); void OnPersonaRefused(string personaId)
{
// suspended, or gone: PrivaPub ended its Web Push subscription, and this device drops its own
InBackground(() => PushService.Drop(personaId, disposeCancellation.Token), nameof(OnPersonaRefused), disposeCancellation.Token);
_ = InvokeAsync(() => MoveOnFromRefused(disposeCancellation.Token));
}
/// <summary> /// <summary>
/// PrivaPub refused the current persona a token while the login's session is good: the personas are read again, and the /// PrivaPub refused the current persona a token while the login's session is good: the personas are read again, and the
@@ -407,10 +502,14 @@
StateHasChanged(); StateHasChanged();
} }
/// <summary>Revokes the persona tokens, ends the session and forgets the local data.</summary> /// <summary>Ends this device's Web Push subscriptions (each persona's, with its own token, while the tokens still work),
/// revokes the persona tokens, ends the session and forgets the local data.</summary>
public async Task SignOut(CancellationToken cancellationToken = default) public async Task SignOut(CancellationToken cancellationToken = default)
{ {
CancelSettingsSave(); CancelSettingsSave();
var pushOff = await PushService.TurnOff(cancellationToken);
if (pushOff.IsInvalid)
await Logger.ProcessWarning(pushOff.ToString(Localizer), $"{nameof(CascadingState)}.{nameof(SignOut)}", cancellationToken);
await AuthService.Logout(cancellationToken); await AuthService.Logout(cancellationToken);
ClearSession(); ClearSession();
StateHasChanged(); StateHasChanged();
@@ -419,6 +518,8 @@
void ClearSession() void ClearSession()
{ {
CancelSettingsSave(); CancelSettingsSave();
// the app's badge counted that session's notifications
Status.ClearAppBadge();
loginSettings = null; loginSettings = null;
IsSignedIn = false; IsSignedIn = false;
RootUserName = null; RootUserName = null;
+3
View File
@@ -74,6 +74,9 @@ namespace decePubClient;
[JsonSerializable(typeof(AnnouncementReactionEvent))] [JsonSerializable(typeof(AnnouncementReactionEvent))]
[JsonSerializable(typeof(CustomEmoji))] [JsonSerializable(typeof(CustomEmoji))]
[JsonSerializable(typeof(List<CustomEmoji>))] [JsonSerializable(typeof(List<CustomEmoji>))]
[JsonSerializable(typeof(WebPushSubscription))]
[JsonSerializable(typeof(PushSubscriptionForm))]
[JsonSerializable(typeof(PushDataForm))]
public partial class MastodonJsonContext : JsonSerializerContext public partial class MastodonJsonContext : JsonSerializerContext
{ {
} }
+7 -1
View File
@@ -122,7 +122,8 @@ public static class APIs
public const string POST_Revoke = "oauth/revoke"; public const string POST_Revoke = "oauth/revoke";
public const string TokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange"; public const string TokenExchangeGrant = "urn:ietf:params:oauth:grant-type:token-exchange";
public const string JwtTokenType = "urn:ietf:params:oauth:token-type:jwt"; public const string JwtTokenType = "urn:ietf:params:oauth:token-type:jwt";
public const string Scopes = "read write follow"; // push since R9: a persona's token subscribes that persona to Web Push (PrivaPub's PersonaExchange grants it)
public const string Scopes = "read write follow push";
} }
public static class Mastodon public static class Mastodon
@@ -196,6 +197,11 @@ public static class APIs
public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by"; public static string GET_M_FavouritedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/favourited_by";
public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by"; public static string GET_M_RebloggedBy(string statusId) => $"api/v1/statuses/{Uri.EscapeDataString(statusId)}/reblogged_by";
public const string POST_Reports = "api/v1/reports"; public const string POST_Reports = "api/v1/reports";
// Web Push: one subscription per access token, so the persona whose token goes with the request
public const string GET_PushSubscription = "api/v1/push/subscription";
public const string POST_PushSubscription = "api/v1/push/subscription";
public const string PUT_PushSubscription = "api/v1/push/subscription";
public const string DELETE_PushSubscription = "api/v1/push/subscription";
public const string GET_NotificationPolicy = "api/v2/notifications/policy"; public const string GET_NotificationPolicy = "api/v2/notifications/policy";
public const string PATCH_NotificationPolicy = "api/v2/notifications/policy"; public const string PATCH_NotificationPolicy = "api/v2/notifications/policy";
public const string GET_NotificationRequests = "api/v1/notifications/requests"; public const string GET_NotificationRequests = "api/v1/notifications/requests";
+9 -1
View File
@@ -1,7 +1,7 @@
namespace decePubClient.Models.Mastodon; namespace decePubClient.Models.Mastodon;
/// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact, /// <summary>/api/v2/instance, the part the client reads: what the server calls itself and says of itself, its contact,
/// its rules, and what an upload may be.</summary> /// its rules, what an upload may be, and its Web Push key.</summary>
public class InstanceV2 public class InstanceV2
{ {
public string Domain { get; set; } public string Domain { get; set; }
@@ -38,6 +38,14 @@ public class InstanceConfiguration
{ {
public MediaConfiguration MediaAttachments { get; set; } = new(); public MediaConfiguration MediaAttachments { get; set; } = new();
public AccountsConfiguration Accounts { get; set; } = new(); public AccountsConfiguration Accounts { get; set; } = new();
public VapidConfiguration Vapid { get; set; } = new();
}
/// <summary>The server's VAPID public key (RFC 8292), the applicationServerKey a browser subscribes to Web Push with: the
/// uncompressed P-256 point, URL-safe base64. Null on a server that pushes nothing.</summary>
public class VapidConfiguration
{
public string PublicKey { get; set; }
} }
/// <summary>What an account may hold: how many hashtags it features on its profile (PrivaPub's 10).</summary> /// <summary>What an account may hold: how many hashtags it features on its profile (PrivaPub's 10).</summary>
+55
View File
@@ -0,0 +1,55 @@
using System.Text.Json.Serialization;
namespace decePubClient.Models.Mastodon;
/// <summary>Mastodon's WebPushSubscription (/api/v1/push/subscription): one per access token, so one per persona.
/// Alerts holds every alert PrivaPub knows, true or false.</summary>
public class WebPushSubscription
{
public string Id { get; set; }
public string Endpoint { get; set; }
public bool Standard { get; set; }
public Dictionary<string, bool> Alerts { get; set; } = [];
/// <summary>The server's VAPID public key, URL-safe base64.</summary>
public string ServerKey { get; set; }
/// <summary>all, followed, follower or none.</summary>
public string Policy { get; set; }
}
/// <summary>POST /api/v1/push/subscription as JSON: the browser's subscription and what it is pushed. PrivaPub reads it as
/// Mastodon's flattened parameters (subscription[endpoint], subscription[keys][p256dh], data[alerts][mention], ...).</summary>
public class PushSubscriptionForm
{
public PushEndpoint Subscription { get; set; } = new();
public PushData Data { get; set; } = new();
}
/// <summary>The browser's push subscription: where the push service takes the pushes, and the keys they are encrypted
/// to. Standard: RFC 8291's aes128gcm (with RFC 8292's VAPID header), which every browser that pushes decrypts.</summary>
public class PushEndpoint
{
public string Endpoint { get; set; }
public PushKeys Keys { get; set; } = new();
public bool Standard { get; set; }
}
/// <summary>The browser's P-256 public key and authentication secret, URL-safe base64.</summary>
public class PushKeys
{
[JsonPropertyName("p256dh")]
public string P256dh { get; set; }
public string Auth { get; set; }
}
/// <summary>What a subscription is pushed: each alert on or off (one left out is off), and whose.</summary>
public class PushData
{
public Dictionary<string, bool> Alerts { get; set; } = [];
public string Policy { get; set; }
}
/// <summary>PUT /api/v1/push/subscription: data[alerts] and data[policy] replace what the subscription had.</summary>
public class PushDataForm
{
public PushData Data { get; set; } = new();
}
+7 -2
View File
@@ -2,7 +2,8 @@ namespace decePubClient.Models;
/// <summary> /// <summary>
/// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told /// The personas already told that an act of theirs is public (owner decision, PrivaPub's ROADMAP: each persona is told
/// once, before its first one): reactions and poll votes, and joining an event. Kept in localStorage under its type name. /// once, before its first one): reactions and poll votes, and joining an event; and whether this device was told, before
/// its first Web Push subscription, what the browser's push service learns. Kept in localStorage under its type name.
/// </summary> /// </summary>
public class PublicActionNotices public class PublicActionNotices
{ {
@@ -10,10 +11,14 @@ public class PublicActionNotices
public List<string> PersonaIds { get; set; } = []; public List<string> PersonaIds { get; set; } = [];
/// <summary>Told about joining events.</summary> /// <summary>Told about joining events.</summary>
public List<string> EventPersonaIds { get; set; } = []; public List<string> EventPersonaIds { get; set; } = [];
/// <summary>This device was told about Web Push (once for the device, whichever personas it subscribes).</summary>
public bool PushTold { get; set; }
} }
public enum PublicAct public enum PublicAct
{ {
Reaction = 0, Reaction = 0,
Participation = 1 Participation = 1,
/// <summary>Notifications on this device go through the browser's push service, which learns when they arrive.</summary>
Push = 2
} }
+54
View File
@@ -0,0 +1,54 @@
using decePubClient.Models.Types;
namespace decePubClient.Models
{
/// <summary>
/// Web Push on this device (Services/PushService.cs), kept in localStorage under its type name: the personas whose
/// notifications reach it, and the alerts and policy each of their subscriptions holds. Never a token: each persona's
/// subscription is made with that persona's own token, which only AuthData keeps.
/// </summary>
public class PushDevice
{
/// <summary>The personas subscribed on this device, each through its own service worker registration.</summary>
public List<PushPersona> Personas { get; set; } = [];
/// <summary>The alerts turned on (PushAlerts.Offered's names).</summary>
public List<string> Alerts { get; set; } = [.. PushAlerts.Offered];
public PushPolicy Policy { get; set; } = PushPolicy.All;
}
/// <summary>One persona's subscription on this device.</summary>
public class PushPersona
{
/// <summary>The persona's avatar id.</summary>
public string PersonaId { get; set; }
/// <summary>A random name this device gives the persona's service worker registration (its scope is push/{slot}/), so
/// the persona's subscription has an endpoint and keys of its own; it means nothing anywhere else.</summary>
public string Slot { get; set; }
/// <summary>PrivaPub's id for the subscription, as it last answered.</summary>
public string SubscriptionId { get; set; }
}
/// <summary>What js/push.js says of this browser: whether it can be pushed to, why not (insecure, unsupported, or
/// ios-install: an iPhone or iPad pushes only to the app added to the Home Screen), and what the reader allowed.</summary>
public class PushSupport
{
public bool Supported { get; set; }
public string Reason { get; set; }
/// <summary>default, granted or denied (Notification.permission).</summary>
public string Permission { get; set; }
public bool IsGranted => Permission == "granted";
public bool IsDenied => Permission == "denied";
}
/// <summary>A persona's push subscription in this browser, as js/push.js reads it (its keys URL-safe base64), or why it
/// couldn't be made.</summary>
public class BrowserSubscription
{
public string Endpoint { get; set; }
public string P256dh { get; set; }
public string Auth { get; set; }
public bool Standard { get; set; }
public string Error { get; set; }
}
}
+48
View File
@@ -0,0 +1,48 @@
namespace decePubClient.Models.Types
{
/// <summary>Whose notifications a Web Push subscription is pushed (Mastodon's data[policy]): anyone's, those of the
/// accounts the persona follows, of its followers, or nobody's. What the persona tells itself (its poll ended, a warning
/// from the staff) passes every policy but None.</summary>
public enum PushPolicy
{
All = 0,
Followed = 1,
Follower = 2,
None = 3
}
public static class PushPolicies
{
/// <summary>The policy as Mastodon's API names it.</summary>
public static string Name(PushPolicy policy) => policy switch
{
PushPolicy.Followed => "followed",
PushPolicy.Follower => "follower",
PushPolicy.None => "none",
_ => "all"
};
/// <summary>A policy Mastodon's API named; all for anything else, as PrivaPub reads a policy left out.</summary>
public static PushPolicy Of(string name) => name switch
{
"followed" => PushPolicy.Followed,
"follower" => PushPolicy.Follower,
"none" => PushPolicy.None,
_ => PushPolicy.All
};
}
/// <summary>
/// The alerts a decePub subscription offers: Mastodon 4.5's names (data[alerts]) that PrivaPub notifies, then Pleroma's
/// emoji reactions. PrivaPub keeps admin.sign_up, admin.report, quoted_update, severed_relationships and annual_report
/// too, but never fires them, so they are never offered and always sent off.
/// </summary>
public static class PushAlerts
{
public static readonly string[] Offered =
[
"mention", "status", "quote", "update", "poll", "favourite", "reblog", "pleroma:emoji_reaction", "follow", "follow_request",
"moderation_warning"
];
}
}
+58 -4
View File
@@ -11,7 +11,8 @@
PrivaPub's streaming API (StreamingService) for the feeds it streams (the direct conversations too, on the direct PrivaPub's streaming API (StreamingService) for the feeds it streams (the direct conversations too, on the direct
stream), and are polled every 45 seconds while the page is visible otherwise, or while the stream is down; either way stream), and are polled every 45 seconds while the page is visible otherwise, or while the stream is down; either way
they wait behind the "new" button. A deletion or an edit the stream tells is applied at once. The administrators' they wait behind the "new" button. A deletion or an edit the stream tells is applied at once. The administrators'
announcements show above the tabs (AnnouncementsBanner). *@ announcements show above the tabs (AnnouncementsBanner). The installed app's badge counts the persona's unread
notifications, and a Web Push notification's click opens the Notifications tab (?notifications=1). *@
<div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full"> <div class="flex flex-col lg:grid lg:grid-cols-[minmax(0,1fr)_minmax(320px,42%)] gap-3 md:gap-4 w-full h-full">
@* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@ @* full screen over everything once a phone's banner is tapped; the feed underneath stays as it was, out of reach *@
@@ -156,12 +157,20 @@
[Inject] FeedMemory Memory { get; set; } [Inject] FeedMemory Memory { get; set; }
[Inject] AccountService AccountService { get; set; } [Inject] AccountService AccountService { get; set; }
/// <summary>Set by a Web Push notification's click (push-worker.js, CascadingState.PushOpened): the Notifications tab,
/// once, and the address loses the question.</summary>
[SupplyParameterFromQuery(Name = "notifications")] public string FromPush { get; set; }
FeedFilter Filter { get; set; } = new(); FeedFilter Filter { get; set; } = new();
List<FeedPost> posts = []; List<FeedPost> posts = [];
List<FeedPost> pending = []; List<FeedPost> pending = [];
List<ViewGroup> groups = []; List<ViewGroup> groups = [];
List<MastodonList> lists = []; List<MastodonList> lists = [];
int unreadNotifications; int unreadNotifications;
// what the app's badge shows, so it is set only when the count changes
int? badged;
// the Notifications tab a notification's click asked for was opened: the same question again waits for the address to lose it
bool pushTaken;
// the requests the persona's notification policy filtered, shown on the Notifications tab // the requests the persona's notification policy filtered, shown on the Notifications tab
int filteredRequests; int filteredRequests;
// the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab // the accounts waiting for the persona to accept their follow (the first page), shown on the Notifications tab
@@ -205,6 +214,8 @@
Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() }; Filter = await LocalStorage.GetItemAsync<FeedFilter>(nameof(FeedFilter)) ?? new() { ShowGlobe = !CascadingState.Status.IsMobileMedia() };
if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 }) if (Filter.TimelineType is TimelineType.Tag && Filter.Tag is not { Length: > 0 })
Filter.TimelineType = TimelineType.Home; Filter.TimelineType = TimelineType.Home;
if (TakeFromPush())
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (CascadingState.IsOffline) if (CascadingState.IsOffline)
{ {
@@ -256,6 +267,16 @@
// page belongs to it. // page belongs to it.
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
// a notification's click while the feed is shown: its tab, unless the persona changes too (then it reloads below)
if (TakeFromPush())
{
await LocalStorage.SetItemAsync(nameof(FeedFilter), Filter);
if (CascadingState.PersonaId is not null && CascadingState.PersonaId == loadedPersonaId && Query != lastQuery)
{
await Reload();
return;
}
}
if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null) if (waitingForPersona && CascadingState.IsSessionLoaded && CascadingState.PersonaId is null)
{ {
waitingForPersona = false; waitingForPersona = false;
@@ -283,6 +304,39 @@
_ = PollNewer(pageCancellation.Token); _ = PollNewer(pageCancellation.Token);
} }
/// <summary>The Notifications tab a notification's click asked for (FromPush), taken once; the address loses the question,
/// so a reload shows the feed the reader chooses next.</summary>
bool TakeFromPush()
{
if (FromPush is null)
{
pushTaken = false;
return false;
}
if (pushTaken)
return false;
pushTaken = true;
Filter.TimelineType = TimelineType.Notifications;
Navigation.NavigateTo("", replace: true);
return true;
}
/// <summary>The persona's unread notifications, on the Notifications tab's badge and on the installed app's (cleared when
/// none is unread; a browser without badges shows none).</summary>
void ShowUnread(int count)
{
unreadNotifications = count;
if (badged == count)
return;
badged = count;
if (count > 0)
CascadingState.Status.SetAppBadge(count);
else
CascadingState.Status.ClearAppBadge();
}
/// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared /// <summary>Which of the persona's filters apply to this feed: home's for home and lists, public's for the shared
/// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary> /// feeds, none for the persona's own collections (favourites, bookmarks) and conversations.</summary>
FilterContext? FilterIn => Filter.TimelineType switch FilterContext? FilterIn => Filter.TimelineType switch
@@ -454,13 +508,13 @@
unreadConversations = await TimelineService.UnreadConversations(cancellationToken); unreadConversations = await TimelineService.UnreadConversations(cancellationToken);
if (Filter.TimelineType is not TimelineType.Notifications) if (Filter.TimelineType is not TimelineType.Notifications)
{ {
unreadNotifications = await TimelineService.UnreadNotifications(cancellationToken); ShowUnread(await TimelineService.UnreadNotifications(cancellationToken));
return; return;
} }
var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal); var newest = posts.Concat(pending).Select(p => p.Notification?.Id).Where(id => id is not null).Max(StringComparer.Ordinal);
if (newest is not null) if (newest is not null)
await TimelineService.ReadNotificationsUpTo(newest, cancellationToken); await TimelineService.ReadNotificationsUpTo(newest, cancellationToken);
unreadNotifications = 0; ShowUnread(0);
filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0; filteredRequests = (await TimelineService.NotificationPolicy(cancellationToken))?.Summary?.PendingRequestsCount ?? 0;
followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0; followRequests = (await AccountService.FollowRequests(null, cancellationToken)).Data is AccountPage requests ? requests.Accounts.Count : 0;
} }
@@ -522,7 +576,7 @@
pending.Insert(0, group); pending.Insert(0, group);
break; break;
case "notification" when Filter.TimelineType is not TimelineType.Notifications: case "notification" when Filter.TimelineType is not TimelineType.Notifications:
unreadNotifications++; ShowUnread(unreadNotifications + 1);
break; break;
case "delete" when message.DeletedId is { } id: case "delete" when message.DeletedId is { } id:
posts.RemoveAll(p => p.Key == id || p.Status?.Id == id); posts.RemoveAll(p => p.Key == id || p.Status?.Id == id);
+21
View File
@@ -194,6 +194,27 @@
</ChildContent> </ChildContent>
</DCollapse> </DCollapse>
@* Web Push: this device's own choice, for the personas of the login it ticks (PushEditor) *@
<DCollapse Icon="DCollapseIcon.Arrow">
<Header>
<p class="inline-flex items-center gap-2" data-settings-push>
<i class="ion-md-phone-portrait text-xl"></i> <span>@Localizer["Notifications on this device"]</span>
</p>
</Header>
<ChildContent>
<div class="block w-full p-3 md:p-4">
@if (CascadingState.IsSignedIn)
{
<PushEditor />
}
else
{
<a class="link" href="login">@Localizer["Sign in to PrivaPub"]</a>
}
</div>
</ChildContent>
</DCollapse>
<DCollapse Icon="DCollapseIcon.Arrow"> <DCollapse Icon="DCollapseIcon.Arrow">
<Header> <Header>
<p class="inline-flex items-center gap-2"> <p class="inline-flex items-center gap-2">
+1
View File
@@ -55,6 +55,7 @@ builder.Services.AddOptions()
.AddScoped<DiscoveryService>() .AddScoped<DiscoveryService>()
.AddScoped<StreamingService>() .AddScoped<StreamingService>()
.AddScoped<PublicNoticeService>() .AddScoped<PublicNoticeService>()
.AddScoped<PushService>()
.AddScoped<ConfirmService>() .AddScoped<ConfirmService>()
.AddScoped<OverlayService>() .AddScoped<OverlayService>()
.AddScoped<MediaService>() .AddScoped<MediaService>()
+102
View File
@@ -3809,4 +3809,106 @@
<data name="{0} posts with it, the last on {1}" xml:space="preserve"> <data name="{0} posts with it, the last on {1}" xml:space="preserve">
<value>{0} post con questo hashtag, l'ultimo il {1}</value> <value>{0} post con questo hashtag, l'ultimo il {1}</value>
</data> </data>
<data name="This server does not offer push notifications." xml:space="preserve">
<value>Questo server non offre notifiche push.</value>
</data>
<data name="This browser did not subscribe: {0}" xml:space="preserve">
<value>Questo browser non si è iscritto: {0}</value>
</data>
<data name="Notifications go through your browser&#x27;s push service" xml:space="preserve">
<value>Le notifiche passano dal servizio push del tuo browser</value>
</data>
<data name="To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google&#x27;s, Mozilla&#x27;s, Apple&#x27;s…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together." xml:space="preserve">
<value>Per raggiungere questo dispositivo mentre decePub è chiuso, PrivaPub affida ogni notifica al servizio push che usa il tuo browser (quello di Google, di Mozilla, di Apple…). È cifrata per questo solo browser: il servizio push sa quando arriva una notifica e quanto è grande, mai cosa dice, chi l'ha mandata né per quale persona è. Ogni persona ha una propria iscrizione, così PrivaPub non conserva nulla che leghi tra loro le tue persone.</value>
</data>
<data name="Notifications on this device" xml:space="preserve">
<value>Notifiche su questo dispositivo</value>
</data>
<data name="Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here." xml:space="preserve">
<value>Le notifiche raggiungono questo dispositivo anche quando decePub è chiuso, per le persone e i tipi che scegli qui.</value>
</data>
<data name="This browser blocks notifications from decePub. Allow them in the site&#x27;s settings, then turn them on here." xml:space="preserve">
<value>Questo browser blocca le notifiche di decePub. Consentile nelle impostazioni del sito, poi attivale qui.</value>
</data>
<data name="Whose notifications reach this device" xml:space="preserve">
<value>Di chi sono le notifiche che arrivano su questo dispositivo</value>
</data>
<data name="People you follow" xml:space="preserve">
<value>Chi segui</value>
</data>
<data name="To try them, show a test notification: this browser shows it by itself, nothing goes to the server. A mention or a favourite from another account arrives the same way, even with decePub closed." xml:space="preserve">
<value>Per provarle, mostra una notifica di prova: la mostra questo browser da solo, al server non arriva nulla. Una menzione o un preferito da un altro account arriva allo stesso modo, anche con decePub chiuso.</value>
</data>
<data name="Show a test notification" xml:space="preserve">
<value>Mostra una notifica di prova</value>
</data>
<data name="Only this device: another device chooses for itself. The push service of this browser (Google&#x27;s, Mozilla&#x27;s, Apple&#x27;s…) learns when a notification arrives, never what it says." xml:space="preserve">
<value>Solo questo dispositivo: un altro dispositivo sceglie per sé. Il servizio push di questo browser (quello di Google, di Mozilla, di Apple…) sa quando arriva una notifica, mai cosa dice.</value>
</data>
<data name="On an iPhone or iPad, add decePub to the Home Screen (Share, then Add to Home Screen) and open it from there: Safari offers notifications only to an installed app." xml:space="preserve">
<value>Su iPhone o iPad, aggiungi decePub alla schermata Home (Condividi, poi Aggiungi alla schermata Home) e aprilo da lì: Safari offre le notifiche solo a un'app installata.</value>
</data>
<data name="Notifications need decePub opened over https." xml:space="preserve">
<value>Le notifiche richiedono decePub aperto in https.</value>
</data>
<data name="This browser can&#x27;t receive notifications from decePub while it is closed." xml:space="preserve">
<value>Questo browser non può ricevere notifiche da decePub mentre è chiuso.</value>
</data>
<data name="Mentions and posts" xml:space="preserve">
<value>Menzioni e post</value>
</data>
<data name="Mentions" xml:space="preserve">
<value>Menzioni</value>
</data>
<data name="New posts from accounts you get notified of" xml:space="preserve">
<value>Nuovi post degli account di cui ricevi avvisi</value>
</data>
<data name="Quotes of your posts" xml:space="preserve">
<value>Citazioni dei tuoi post</value>
</data>
<data name="Edits of posts you interacted with" xml:space="preserve">
<value>Modifiche dei post con cui hai interagito</value>
</data>
<data name="Polls that ended" xml:space="preserve">
<value>Sondaggi terminati</value>
</data>
<data name="Reactions to your posts" xml:space="preserve">
<value>Reazioni ai tuoi post</value>
</data>
<data name="Boosts of your posts" xml:space="preserve">
<value>Condivisioni dei tuoi post</value>
</data>
<data name="Emoji reactions" xml:space="preserve">
<value>Reazioni con emoji</value>
</data>
<data name="Who follows you" xml:space="preserve">
<value>Chi ti segue</value>
</data>
<data name="New followers" xml:space="preserve">
<value>Nuovi follower</value>
</data>
<data name="From this server&#x27;s staff" xml:space="preserve">
<value>Dallo staff di questo server</value>
</data>
<data name="Warnings from the staff" xml:space="preserve">
<value>Avvisi dello staff</value>
</data>
<data name="This browser did not allow notifications." xml:space="preserve">
<value>Questo browser non ha consentito le notifiche.</value>
</data>
<data name="Notifications are on for this device." xml:space="preserve">
<value>Notifiche attive su questo dispositivo.</value>
</data>
<data name="Notification settings saved for this device." xml:space="preserve">
<value>Impostazioni delle notifiche salvate per questo dispositivo.</value>
</data>
<data name="Notifications are off for this device." xml:space="preserve">
<value>Notifiche disattivate su questo dispositivo.</value>
</data>
<data name="A test notification: this is how they show on this device." xml:space="preserve">
<value>Una notifica di prova: ecco come appaiono su questo dispositivo.</value>
</data>
<data name="This browser did not show the test notification." xml:space="preserve">
<value>Questo browser non ha mostrato la notifica di prova.</value>
</data>
</root> </root>
+102
View File
@@ -3809,4 +3809,106 @@
<data name="{0} posts with it, the last on {1}" xml:space="preserve"> <data name="{0} posts with it, the last on {1}" xml:space="preserve">
<value>{0} posts with it, the last on {1}</value> <value>{0} posts with it, the last on {1}</value>
</data> </data>
<data name="This server does not offer push notifications." xml:space="preserve">
<value>This server does not offer push notifications.</value>
</data>
<data name="This browser did not subscribe: {0}" xml:space="preserve">
<value>This browser did not subscribe: {0}</value>
</data>
<data name="Notifications go through your browser&#x27;s push service" xml:space="preserve">
<value>Notifications go through your browser's push service</value>
</data>
<data name="To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google&#x27;s, Mozilla&#x27;s, Apple&#x27;s…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together." xml:space="preserve">
<value>To reach this device while decePub is closed, PrivaPub hands each notification to the push service your browser uses (Google's, Mozilla's, Apple's…). It is encrypted for this browser alone: the push service learns when a notification arrives and how big it is, never what it says, who sent it or which persona it is for. Each persona has a subscription of its own, so PrivaPub keeps nothing that ties your personas together.</value>
</data>
<data name="Notifications on this device" xml:space="preserve">
<value>Notifications on this device</value>
</data>
<data name="Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here." xml:space="preserve">
<value>Notifications reach this device even while decePub is closed, for the personas and the kinds you choose here.</value>
</data>
<data name="This browser blocks notifications from decePub. Allow them in the site&#x27;s settings, then turn them on here." xml:space="preserve">
<value>This browser blocks notifications from decePub. Allow them in the site's settings, then turn them on here.</value>
</data>
<data name="Whose notifications reach this device" xml:space="preserve">
<value>Whose notifications reach this device</value>
</data>
<data name="People you follow" xml:space="preserve">
<value>People you follow</value>
</data>
<data name="To try them, show a test notification: this browser shows it by itself, nothing goes to the server. A mention or a favourite from another account arrives the same way, even with decePub closed." xml:space="preserve">
<value>To try them, show a test notification: this browser shows it by itself, nothing goes to the server. A mention or a favourite from another account arrives the same way, even with decePub closed.</value>
</data>
<data name="Show a test notification" xml:space="preserve">
<value>Show a test notification</value>
</data>
<data name="Only this device: another device chooses for itself. The push service of this browser (Google&#x27;s, Mozilla&#x27;s, Apple&#x27;s…) learns when a notification arrives, never what it says." xml:space="preserve">
<value>Only this device: another device chooses for itself. The push service of this browser (Google's, Mozilla's, Apple's…) learns when a notification arrives, never what it says.</value>
</data>
<data name="On an iPhone or iPad, add decePub to the Home Screen (Share, then Add to Home Screen) and open it from there: Safari offers notifications only to an installed app." xml:space="preserve">
<value>On an iPhone or iPad, add decePub to the Home Screen (Share, then Add to Home Screen) and open it from there: Safari offers notifications only to an installed app.</value>
</data>
<data name="Notifications need decePub opened over https." xml:space="preserve">
<value>Notifications need decePub opened over https.</value>
</data>
<data name="This browser can&#x27;t receive notifications from decePub while it is closed." xml:space="preserve">
<value>This browser can't receive notifications from decePub while it is closed.</value>
</data>
<data name="Mentions and posts" xml:space="preserve">
<value>Mentions and posts</value>
</data>
<data name="Mentions" xml:space="preserve">
<value>Mentions</value>
</data>
<data name="New posts from accounts you get notified of" xml:space="preserve">
<value>New posts from accounts you get notified of</value>
</data>
<data name="Quotes of your posts" xml:space="preserve">
<value>Quotes of your posts</value>
</data>
<data name="Edits of posts you interacted with" xml:space="preserve">
<value>Edits of posts you interacted with</value>
</data>
<data name="Polls that ended" xml:space="preserve">
<value>Polls that ended</value>
</data>
<data name="Reactions to your posts" xml:space="preserve">
<value>Reactions to your posts</value>
</data>
<data name="Boosts of your posts" xml:space="preserve">
<value>Boosts of your posts</value>
</data>
<data name="Emoji reactions" xml:space="preserve">
<value>Emoji reactions</value>
</data>
<data name="Who follows you" xml:space="preserve">
<value>Who follows you</value>
</data>
<data name="New followers" xml:space="preserve">
<value>New followers</value>
</data>
<data name="From this server&#x27;s staff" xml:space="preserve">
<value>From this server's staff</value>
</data>
<data name="Warnings from the staff" xml:space="preserve">
<value>Warnings from the staff</value>
</data>
<data name="This browser did not allow notifications." xml:space="preserve">
<value>This browser did not allow notifications.</value>
</data>
<data name="Notifications are on for this device." xml:space="preserve">
<value>Notifications are on for this device.</value>
</data>
<data name="Notification settings saved for this device." xml:space="preserve">
<value>Notification settings saved for this device.</value>
</data>
<data name="Notifications are off for this device." xml:space="preserve">
<value>Notifications are off for this device.</value>
</data>
<data name="A test notification: this is how they show on this device." xml:space="preserve">
<value>A test notification: this is how they show on this device.</value>
</data>
<data name="This browser did not show the test notification." xml:space="preserve">
<value>This browser did not show the test notification.</value>
</data>
</root> </root>
+29
View File
@@ -161,6 +161,35 @@ namespace decePubClient.Services
return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken); return response.IsSuccessStatusCode ? new() : await response.Content.ReadWebResult(response.StatusCode, localizer, cancellationToken);
} }
/// <summary>
/// Revokes the persona's token and forgets it, so the next request as the persona exchanges a new one with the scopes
/// the client asks for now (APIs.OAuth.Scopes): a token exchanged before the client asked for push can't subscribe to
/// Web Push (PrivaPub's 403). The persona stays current if it was.
/// </summary>
public async Task<WebResult> RenewPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var result = new WebResult();
var session = await authStateProvider.GetAuthData(cancellationToken);
if (session.PersonaTokens.GetValueOrDefault(personaId) is not { Length: > 0 } token)
return result;
// forgotten first, so no request uses it while it is revoked
await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
using var revoked = await httpService.PostTotallyAnon(APIs.OAuth.POST_Revoke, new Dictionary<string, string>
{
["token"] = token,
["client_id"] = appConfiguration.ClientId
}, cancellationToken: cancellationToken);
if (!revoked.IsSuccessStatusCode)
{
result = await revoked.Content.ReadWebResult(revoked.StatusCode, localizer, cancellationToken);
await logger.ProcessWarning(result.ToString(localizer), $"{nameof(AuthService)}.{nameof(RenewPersonaToken)}", cancellationToken);
return result;
}
return result;
}
/// <summary>The current persona's Mastodon account, or null.</summary> /// <summary>The current persona's Mastodon account, or null.</summary>
public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default) public async Task<Account> CurrentAccount(CancellationToken cancellationToken = default)
{ {
+96 -27
View File
@@ -20,7 +20,8 @@ namespace decePubClient.Services
/// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read /// Nothing throws: a failure comes back as a response carrying an invalid WebResult (SUtility.ClassifiedFailure, read
/// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and /// with ExtensionMethods.ReadWebResult), a cancelled request as HTTP 410. With retryOnError, 408/429/502/503/504 and
/// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10 /// transport errors are retried after 1, 2, 4, 8, 16 and 29 seconds, then every 60, plus jitter, for at most 10
/// minutes. Every request PrivaPub answers is recorded in RequestStats. /// minutes. Every request PrivaPub answers is recorded in RequestStats. The *As methods send a Mastodon API request as
/// a given persona of the login, whichever the session acts as: with that persona's own token, never another's.
/// </summary> /// </summary>
public interface IHttpService public interface IHttpService
{ {
@@ -48,6 +49,22 @@ namespace decePubClient.Services
/// current. A failure comes back like any other. /// current. A failure comes back like any other.
/// </summary> /// </summary>
Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default); Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default);
/// <summary>A Mastodon API GET as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API POST as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API PUT as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
/// <summary>A Mastodon API DELETE as that persona (its token exchanged when missing, without making it current).</summary>
Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default);
} }
public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService, public class HttpService(IHttpClientFactory httpClientFactory, TokenAuthStateProvider authStateProvider, AppStatusService statusService,
@@ -91,10 +108,40 @@ namespace decePubClient.Services
Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken); Send(nameof(Delete), () => BuildRequest(HttpMethod.Delete, uri, payload, queryParams), SendAuthRequest, retryOnError, cancellationToken);
public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default) public async Task<HttpResponseMessage> ExchangePersonaToken(string personaId, CancellationToken cancellationToken = default)
{
var (response, token) = await RequestPersonaToken(personaId, cancellationToken);
if (token is not null)
await authStateProvider.SetPersona(personaId, token, cancellationToken);
return response;
}
public Task<HttpResponseMessage> GetAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(GetAs), () => BuildRequest(HttpMethod.Get, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> PostAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PostAs), () => BuildRequest(HttpMethod.Post, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> PutAs(string personaId, string uri, object payload = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(PutAs), () => BuildRequest(HttpMethod.Put, uri, payload, forceContent: true), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
public Task<HttpResponseMessage> DeleteAs(string personaId, string uri, string[] queryParams = default, bool retryOnError = false,
CancellationToken cancellationToken = default) =>
Send(nameof(DeleteAs), () => BuildRequest(HttpMethod.Delete, uri, queryParams: queryParams), (factory, ct) => SendAsGivenPersona(personaId, factory, ct),
retryOnError, cancellationToken);
/// <summary>The exchange itself (RFC 8693 on /oauth/token, PrivaPub's PersonaExchange): the response, and the persona's
/// new token when PrivaPub gave one. Nothing is stored here.</summary>
async Task<(HttpResponseMessage Response, string Token)> RequestPersonaToken(string personaId, CancellationToken cancellationToken)
{ {
var jwt = await authStateProvider.GetToken(cancellationToken); var jwt = await authStateProvider.GetToken(cancellationToken);
if (jwt is null) if (jwt is null)
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]); return (SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]), null);
var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string> var response = await PostTotallyAnon(APIs.OAuth.POST_Token, new Dictionary<string, string>
{ {
@@ -106,14 +153,16 @@ namespace decePubClient.Services
["scope"] = APIs.OAuth.Scopes ["scope"] = APIs.OAuth.Scopes
}, cancellationToken: cancellationToken); }, cancellationToken: cancellationToken);
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
return response; return (response, null);
var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken); var token = await response.Content.ReadFromJsonAsync<TokenResponse>(SUtility.MastodonSerializer, cancellationToken);
if (string.IsNullOrEmpty(token?.AccessToken)) if (string.IsNullOrEmpty(token?.AccessToken))
return SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway); {
response.Dispose();
return (SUtility.ClassifiedFailure(FailureCodes.ServerError, localizer["The server sent an invalid response."], HttpStatusCode.BadGateway), null);
}
await authStateProvider.SetPersona(personaId, token.AccessToken, cancellationToken); return (response, token.AccessToken);
return response;
} }
async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory, async Task<HttpResponseMessage> Send(string caller, Func<HttpRequestMessage> requestFactory,
@@ -217,7 +266,7 @@ namespace decePubClient.Services
{ {
var request = requestFactory(); var request = requestFactory();
if (IsMastodon(request)) if (IsMastodon(request))
return await SendAsPersona(request, requestFactory, cancellationToken); return await SendAsPersona((await authStateProvider.GetAuthData(cancellationToken)).PersonaId, request, requestFactory, cancellationToken);
var token = await authStateProvider.GetToken(cancellationToken); var token = await authStateProvider.GetToken(cancellationToken);
if (token is null) if (token is null)
@@ -236,24 +285,39 @@ namespace decePubClient.Services
return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]); return SUtility.DefaultUnauthorized(localizer["Your session has expired. Sign in again."]);
} }
/// <summary> /// <summary>A Mastodon API request as the persona named, whichever the session acts as.</summary>
/// A Mastodon API request as the current persona. A missing token is exchanged first; a refused one (revoked, async Task<HttpResponseMessage> SendAsGivenPersona(string personaId, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
/// or PrivaPub's database was reset) is exchanged once more and the request sent again. Without a usable JWT the
/// session ends. A persona PrivaPub refuses a token to while the JWT is good (suspended by the staff, or gone) is
/// marked refused (TokenAuthStateProvider.Refuse, which tells CascadingState once) and no request acts as it again.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(HttpRequestMessage request, Func<HttpRequestMessage> requestFactory, CancellationToken cancellationToken)
{ {
var session = await authStateProvider.GetAuthData(cancellationToken); var request = requestFactory();
if (authStateProvider.IsRefused(session.PersonaId)) if (!IsMastodon(request) || personaId is null)
{
request.Dispose();
return SUtility.ClassifiedFailure(FailureCodes.ClientError,
localizer["The request could not be prepared. Reload the page and try again."], HttpStatusCode.BadRequest);
}
return await SendAsPersona(personaId, request, requestFactory, cancellationToken);
}
/// <summary>
/// A Mastodon API request as the persona (the current one, or the one an *As method names), with that persona's own
/// token. A missing token is exchanged first; a refused one (revoked, or PrivaPub's database was reset) is exchanged
/// once more and the request sent again. Without a usable JWT the session ends. A persona PrivaPub refuses a token to
/// while the JWT is good (suspended by the staff, or gone) is marked refused (TokenAuthStateProvider.Refuse, which
/// tells CascadingState once) and no request acts as it again.
/// </summary>
async Task<HttpResponseMessage> SendAsPersona(string personaId, HttpRequestMessage request, Func<HttpRequestMessage> requestFactory,
CancellationToken cancellationToken)
{
if (authStateProvider.IsRefused(personaId))
return PersonaRefused(); return PersonaRefused();
var token = await authStateProvider.GetPersonaToken(cancellationToken); var token = await authStateProvider.GetPersonaToken(personaId, cancellationToken);
if (token is null) if (token is null)
{ {
var exchanged = await Exchange(session.PersonaId, cancellationToken); var exchanged = await Exchange(personaId, cancellationToken);
if (exchanged.Refused) if (exchanged.Refused)
return PersonaRefused(session.PersonaId); return PersonaRefused(personaId);
token = exchanged.Token; token = exchanged.Token;
} }
if (token is null) if (token is null)
@@ -265,10 +329,10 @@ namespace decePubClient.Services
return response; return response;
response.Dispose(); response.Dispose();
await authStateProvider.ForgetPersonaToken(session.PersonaId, cancellationToken); await authStateProvider.ForgetPersonaToken(personaId, cancellationToken);
var renewed = await Exchange(session.PersonaId, cancellationToken); var renewed = await Exchange(personaId, cancellationToken);
if (renewed.Refused) if (renewed.Refused)
return PersonaRefused(session.PersonaId); return PersonaRefused(personaId);
token = renewed.Token; token = renewed.Token;
if (token is null) if (token is null)
return await EndSession(cancellationToken); return await EndSession(cancellationToken);
@@ -283,15 +347,20 @@ namespace decePubClient.Services
return await EndSession(cancellationToken); return await EndSession(cancellationToken);
} }
/// <summary>A new token for the persona, or none; Refused when PrivaPub itself answered no (its 400 invalid_grant or a /// <summary>A new token for the persona, kept without making it current, or none; Refused when PrivaPub itself answered
/// 403) to a JWT the client still holds as good, rather than the request failing on the way.</summary> /// no (its 400 invalid_grant or a 403) to a JWT the client still holds as good, rather than the request failing on the
/// way.</summary>
async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken) async Task<(string Token, bool Refused)> Exchange(string personaId, CancellationToken cancellationToken)
{ {
if (personaId is null) if (personaId is null)
return (null, false); return (null, false);
using var response = await ExchangePersonaToken(personaId, cancellationToken); var (exchange, token) = await RequestPersonaToken(personaId, cancellationToken);
if (response.IsSuccessStatusCode) using var response = exchange;
return (await authStateProvider.GetPersonaToken(cancellationToken), false); if (token is not null)
{
await authStateProvider.SetPersonaToken(personaId, token, cancellationToken);
return (token, false);
}
var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden var refused = response.StatusCode is HttpStatusCode.BadRequest or HttpStatusCode.Forbidden
&& !response.Content.Headers.Contains(SUtility.FailureHeader) && !response.Content.Headers.Contains(SUtility.FailureHeader)
+9 -4
View File
@@ -6,8 +6,10 @@ namespace decePubClient.Services
{ {
/// <summary> /// <summary>
/// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept /// Asks a persona, once, before its first reaction or poll vote, and once before it first joins an event, to accept
/// that these are public and visible as that persona (an owner decision). PublicNoticeHost shows the question; Confirm /// that these are public and visible as that persona (an owner decision); and this device, once, before its first Web
/// returns true at once for a persona that already accepted it, and false when the reader declines. /// Push subscription, to accept that the browser's push service learns when notifications arrive (PublicAct.Push, told
/// once for the device whichever persona asks). PublicNoticeHost shows the question; Confirm returns true at once for
/// what was accepted already, and false when the reader declines.
/// </summary> /// </summary>
public class PublicNoticeService(ILocalStorageService localStorage) public class PublicNoticeService(ILocalStorageService localStorage)
{ {
@@ -22,11 +24,11 @@ namespace decePubClient.Services
public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default) public async Task<bool> Confirm(string personaId, PublicAct act = PublicAct.Reaction, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrEmpty(personaId)) if (string.IsNullOrEmpty(personaId) && act != PublicAct.Push)
return false; return false;
var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new(); var seen = await localStorage.GetItemAsync<PublicActionNotices>(nameof(PublicActionNotices), cancellationToken) ?? new();
var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds; var told = act == PublicAct.Participation ? seen.EventPersonaIds : seen.PersonaIds;
if (told.Contains(personaId)) if (act == PublicAct.Push ? seen.PushTold : told.Contains(personaId))
return true; return true;
Act = act; Act = act;
@@ -38,6 +40,9 @@ namespace decePubClient.Services
if (!accepted) if (!accepted)
return false; return false;
if (act == PublicAct.Push)
seen.PushTold = true;
else
told.Add(personaId); told.Add(personaId);
await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken); await localStorage.SetItemAsync(nameof(PublicActionNotices), seen, cancellationToken);
return true; return true;
+534
View File
@@ -0,0 +1,534 @@
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
{
/// <summary>
/// Web Push on this device (Mastodon's /api/v1/push/subscription, PrivaPub's R9), through wwwroot/js/push.js.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public class PushService(IHttpService httpService, TokenAuthStateProvider authStateProvider, AuthService authService, AboutService aboutService,
ILocalStorageService localStorage, IJSRuntime jsRuntime, ILoggingService logger, CoalescingStringLocalizer localizer) : IAsyncDisposable
{
/// <summary>How long a changed token waits before its persona is subscribed again: one change often brings others.</summary>
static readonly TimeSpan RenewDelay = TimeSpan.FromSeconds(2);
readonly SemaphoreSlim gate = new(1, 1);
readonly CancellationTokenSource stopping = new();
/// <summary>The personas whose tokens changed since their subscriptions were last made sure of.</summary>
readonly HashSet<string> stale = [];
/// <summary>The personas subscribed on this device, as last read or saved.</summary>
HashSet<string> subscribed = [];
IJSObjectReference module;
bool moduleFailed;
bool listening;
bool renewing;
/// <summary>The persona a request of this service acts as: the token changes its own exchanges make need no renewal.</summary>
string actingAs;
/// <summary>Whether this browser can be pushed to, and what the reader allowed it.</summary>
public async Task<PushSupport> Support(CancellationToken cancellationToken = default) =>
await Js("support", new PushSupport(), [], cancellationToken) ?? new();
/// <summary>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.</summary>
public async Task<string> RequestPermission(CancellationToken cancellationToken = default) =>
await Js("requestPermission", "default", [], cancellationToken) ?? "default";
/// <summary>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.</summary>
public async Task<bool?> 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 };
}
/// <summary>This device's choices: the personas subscribed, the alerts and the policy.</summary>
public async Task<PushDevice> Device(CancellationToken cancellationToken = default)
{
var device = await localStorage.GetItemAsync<PushDevice>(nameof(PushDevice), cancellationToken) ?? new();
subscribed = device.Personas.Select(p => p.PersonaId).ToHashSet();
return device;
}
/// <summary>The persona a registration's slot is for on this device (a notification's click), or null.</summary>
public async Task<string> PersonaOf(string slot, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(slot))
return null;
return (await Device(cancellationToken)).Personas.FirstOrDefault(p => p.Slot == slot)?.PersonaId;
}
/// <summary>
/// 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.
/// </summary>
public async Task<WebResult> Apply(List<string> personaIds, List<string> 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();
}
}
/// <summary>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.</summary>
public Task<WebResult> TurnOff(CancellationToken cancellationToken = default) => Clear(true, cancellationToken);
/// <summary>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).</summary>
public Task<WebResult> Forget(CancellationToken cancellationToken = default) => Clear(false, cancellationToken);
/// <summary>A persona deleted, or suspended by the staff: PrivaPub ended its subscription with it, and this device drops
/// its own.</summary>
public async Task<WebResult> 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();
}
}
/// <summary>
/// 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.
/// </summary>
public async Task<WebResult> Reconcile(List<ViewAvatar> 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();
}
}
/// <summary>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.</summary>
public async Task<bool> 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<WebResult> 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();
}
}
/// <summary>
/// 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.
/// </summary>
async Task<WebResult> 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<BrowserSubscription>("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;
}
}
/// <summary>PrivaPub's side of Push; Data is the WebPushSubscription it holds now.</summary>
async Task<WebResult> 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);
}
/// <summary>The subscription PrivaPub holds for the persona's token as Data, or no Data when it holds none (its 404).</summary>
async Task<WebResult> 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<WebPushSubscription>(cancellationToken) };
}
/// <summary>A POST or PUT of the persona's subscription; Data is what PrivaPub holds then.</summary>
async Task<WebResult> Send(string personaId, Func<Task<HttpResponseMessage>> 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<WebPushSubscription>(cancellationToken) };
}
/// <summary>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.</summary>
async Task<HttpResponseMessage> AsPersona(string personaId, Func<Task<HttpResponseMessage>> 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();
}
/// <summary>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).</summary>
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);
}
/// <summary>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.</summary>
async Task DropStrays(PushDevice device, CancellationToken cancellationToken)
{
var known = device.Personas.Select(p => p.Slot).ToHashSet();
foreach (var slot in await Js<List<string>>("slots", [], [], cancellationToken) ?? [])
if (!known.Contains(slot))
await Js("unsubscribe", false, [slot], cancellationToken);
}
/// <summary>The server's VAPID key (the instance API's configuration.vapid.public_key), or null when it pushes nothing.</summary>
async Task<string> ServerKey(CancellationToken cancellationToken) =>
(await aboutService.Instance(cancellationToken: cancellationToken))?.Configuration?.Vapid?.PublicKey is { Length: > 0 } key ? key : null;
/// <summary>Every alert offered, on as the device has it; those PrivaPub never fires are left out, which is off.</summary>
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);
}
/// <summary>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.</summary>
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<AuthenticationState> 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<AuthenticationState> 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);
}
/// <summary>A push.js function; its fallback when the module can't be loaded or the call fails (logged).</summary>
async Task<T> Js<T>(string function, T fallback, object[] args, CancellationToken cancellationToken)
{
var push = await Module(cancellationToken);
if (push is null)
return fallback;
try
{
return await push.InvokeAsync<T>(function, cancellationToken, args);
}
catch (JSException ex)
{
await logger.ProcessError(ex, $"{nameof(PushService)}.{function}", cancellationToken);
return fallback;
}
}
async Task<IJSObjectReference> Module(CancellationToken cancellationToken)
{
if (module is not null || moduleFailed)
return module;
try
{
return module = await jsRuntime.InvokeAsync<IJSObjectReference>("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);
}
}
}
+34
View File
@@ -30,6 +30,10 @@ namespace decePubClient.Services
/// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary> /// <summary>Raised once for a persona PrivaPub refused a token to (Refuse); CascadingState moves the session on.</summary>
public event Action<string> PersonaRefused; public event Action<string> PersonaRefused;
/// <summary>Raised with a persona's id when its token is replaced or forgotten: a Web Push subscription made with the
/// old one ended with it (PushService subscribes the persona again with its new one).</summary>
public event Action<string> PersonaTokenChanged;
/// <summary>The stored session, or an empty one.</summary> /// <summary>The stored session, or an empty one.</summary>
public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default) public async Task<AuthData> GetAuthData(CancellationToken cancellationToken = default)
{ {
@@ -59,6 +63,15 @@ namespace decePubClient.Services
return session.PersonaTokens.GetValueOrDefault(session.PersonaId); return session.PersonaTokens.GetValueOrDefault(session.PersonaId);
} }
/// <summary>That persona's Mastodon token, or null when it has none yet; never another persona's.</summary>
public async Task<string> GetPersonaToken(string personaId, CancellationToken cancellationToken = default)
{
if (personaId is null)
return null;
var session = await GetAuthData(cancellationToken);
return session.PersonaTokens.GetValueOrDefault(personaId);
}
public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) => public async Task<bool> IsAuthenticatedAsync(CancellationToken cancellationToken = default) =>
await GetToken(cancellationToken) is not null; await GetToken(cancellationToken) is not null;
@@ -81,9 +94,26 @@ namespace decePubClient.Services
{ {
var session = await GetAuthData(cancellationToken); var session = await GetAuthData(cancellationToken);
session.PersonaId = personaId; session.PersonaId = personaId;
var changed = token is not null && session.PersonaTokens.GetValueOrDefault(personaId) != token;
if (token is not null) if (token is not null)
session.PersonaTokens[personaId] = token; session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken); await Save(session, cancellationToken);
if (changed)
PersonaTokenChanged?.Invoke(personaId);
}
/// <summary>Remembers a persona's new token without making it current (a request as another persona than the one
/// the session acts as, HttpService's *As).</summary>
public async Task SetPersonaToken(string personaId, string token, CancellationToken cancellationToken = default)
{
if (personaId is null || token is null)
return;
var session = await GetAuthData(cancellationToken);
if (session.PersonaTokens.GetValueOrDefault(personaId) == token)
return;
session.PersonaTokens[personaId] = token;
await Save(session, cancellationToken);
PersonaTokenChanged?.Invoke(personaId);
} }
/// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary> /// <summary>Drops a persona's token that PrivaPub no longer accepts.</summary>
@@ -93,6 +123,7 @@ namespace decePubClient.Services
if (!session.PersonaTokens.Remove(personaId)) if (!session.PersonaTokens.Remove(personaId))
return; return;
await Save(session, cancellationToken); await Save(session, cancellationToken);
PersonaTokenChanged?.Invoke(personaId);
} }
/// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary> /// <summary>Whether PrivaPub refused the persona a token in this session (Refuse).</summary>
@@ -120,8 +151,11 @@ namespace decePubClient.Services
var session = await GetAuthData(cancellationToken); var session = await GetAuthData(cancellationToken);
if (session.PersonaTokens.Count == 0) if (session.PersonaTokens.Count == 0)
return; return;
var forgotten = session.PersonaTokens.Keys.ToList();
session.PersonaTokens.Clear(); session.PersonaTokens.Clear();
await Save(session, cancellationToken); await Save(session, cancellationToken);
foreach (var personaId in forgotten)
PersonaTokenChanged?.Invoke(personaId);
} }
public async Task LogoutAsync(CancellationToken cancellationToken = default) public async Task LogoutAsync(CancellationToken cancellationToken = default)
+1
View File
@@ -43,6 +43,7 @@ server {
location = /appsettings.json { expires -1; } location = /appsettings.json { expires -1; }
location = /service-worker.js { expires -1; } location = /service-worker.js { expires -1; }
location = /service-worker-assets.js { expires -1; } location = /service-worker-assets.js { expires -1; }
location = /push-worker.js { expires -1; }
location ^~ /_framework/ { location ^~ /_framework/ {
expires 1h; expires 1h;
+175
View File
@@ -0,0 +1,175 @@
// Web Push in this browser (Services/PushService.cs, imported on demand like js/feed.js). Each persona the reader turns
// notifications on for gets a service worker registration of its own: wwwroot/push-worker.js at the scope push/{slot}/,
// where the slot is a random name this device keeps. Its push subscription is that persona's alone, with its own
// endpoint and keys, so nothing PrivaPub stores ties two personas of one login together, and the worker knows the
// persona from its scope without ever reading the payload's token.
//
// What fails as browsers fail (no permission, a push service that refused) comes back as { error }; what fails
// unexpectedly is also reported through window.logFromJs.
const workerUrl = new URL("../push-worker.js", import.meta.url).href
const scopesRoot = new URL("../push/", import.meta.url).href
const scopeOf = slot => `${scopesRoot}${slot}/`
function report(e, where) {
console.error(e)
window.logFromJs?.(e?.message ?? String(e), `push.${where}`)
}
function ios() {
return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1)
}
function installed() {
return window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true
}
// Whether this browser can be pushed to at all, why not, and what the reader allowed: default, granted or denied. On an
// iPhone or iPad, Safari offers Web Push only to the app added to the Home Screen and opened from there.
export function support() {
try {
const permission = typeof Notification == "undefined" ? "default" : Notification.permission
if (!window.isSecureContext)
return { supported: false, reason: "insecure", permission }
if (!("serviceWorker" in navigator) || !("PushManager" in window) || typeof Notification == "undefined" || !("showNotification" in ServiceWorkerRegistration.prototype))
return { supported: false, reason: ios() && !installed() ? "ios-install" : "unsupported", permission }
return { supported: true, reason: null, permission }
} catch (e) {
report(e, "support")
return { supported: false, reason: "unsupported", permission: "default" }
}
}
// Asks the reader once (the browser's own prompt, after a click); what the browser already knows is answered at once.
export async function requestPermission() {
try {
if (Notification.permission !== "default")
return Notification.permission
return await Notification.requestPermission()
} catch (e) {
report(e, "requestPermission")
return typeof Notification == "undefined" ? "default" : Notification.permission
}
}
async function registrationOf(slot, create) {
const scope = scopeOf(slot)
// (getRegistration(url) answers the registration that controls the URL: the app's own one when this one is missing)
let registration = (await navigator.serviceWorker.getRegistrations()).find(r => r.scope === scope)
if (!registration && create)
registration = await navigator.serviceWorker.register(workerUrl, { scope, updateViaCache: "none" })
if (registration)
await activated(registration)
return registration
}
// a push subscription needs an active worker
function activated(registration) {
return new Promise(resolve => {
const worker = registration.installing ?? registration.waiting
if (registration.active || !worker)
return resolve()
const settle = () => {
if (worker.state === "activated" || worker.state === "redundant")
resolve()
}
worker.addEventListener("statechange", settle)
setTimeout(resolve, 10000)
})
}
function fromBase64Url(text) {
const normal = text.replace(/=+$/, "").replace(/-/g, "+").replace(/_/g, "/")
const raw = atob(normal + "===".slice((normal.length + 3) % 4))
return Uint8Array.from(raw, c => c.charCodeAt(0))
}
function sameKey(buffer, bytes) {
// a browser that doesn't say which key it subscribed with is taken at its word
if (!buffer)
return true
const held = new Uint8Array(buffer)
return held.length === bytes.length && held.every((value, i) => value === bytes[i])
}
function describe(subscription) {
const keys = subscription.toJSON().keys ?? {}
const encodings = PushManager.supportedContentEncodings
return {
endpoint: subscription.endpoint,
p256dh: keys.p256dh ?? null,
auth: keys.auth ?? null,
// RFC 8291's aes128gcm, which every browser that pushes decrypts; the aesgcm draft only where it is all there is
standard: !Array.isArray(encodings) || encodings.includes("aes128gcm"),
error: null
}
}
// The persona's subscription in this browser, made (with its registration) when there is none, and made again when it
// was made with another server key. Its endpoint and its keys as URL-safe base64.
export async function subscribe(slot, serverKey) {
try {
if (Notification.permission !== "granted")
return { error: `permission ${Notification.permission}` }
const key = fromBase64Url(serverKey)
const registration = await registrationOf(slot, true)
let subscription = await registration.pushManager.getSubscription()
if (subscription && !sameKey(subscription.options?.applicationServerKey, key)) {
await subscription.unsubscribe()
subscription = null
}
subscription ??= await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
return describe(subscription)
} catch (e) {
console.error(e)
return { error: e?.message ?? String(e) }
}
}
// The persona's subscription and its registration go: the push service forgets the endpoint, and refuses PrivaPub what it
// would still send there.
export async function unsubscribe(slot) {
try {
const registration = await registrationOf(slot, false)
if (!registration)
return true
const subscription = await registration.pushManager.getSubscription()
await subscription?.unsubscribe()
return await registration.unregister()
} catch (e) {
report(e, "unsubscribe")
return false
}
}
// The slots this browser holds registrations for, so the app drops those it no longer knows (a device whose storage was
// cleared would otherwise keep showing what PrivaPub pushes).
export async function slots() {
try {
if (!("serviceWorker" in navigator))
return []
return (await navigator.serviceWorker.getRegistrations())
.map(r => r.scope)
.filter(scope => scope.startsWith(scopesRoot))
.map(scope => scope.slice(scopesRoot.length).replace(/\/$/, ""))
.filter(name => name.length > 0)
} catch (e) {
report(e, "slots")
return []
}
}
// A notification shown by the persona's registration alone, nothing sent anywhere: it shows how they will look, and its
// click goes where theirs would.
export async function test(slot, title, body) {
try {
const registration = await registrationOf(slot, false)
if (!registration || Notification.permission !== "granted")
return false
await registration.showNotification(title, { body, tag: "decepub-test", icon: new URL("../imgs/icon-192.png", import.meta.url).href, data: {} })
return true
} catch (e) {
report(e, "test")
return false
}
}
+28 -2
View File
@@ -49,9 +49,13 @@ window.isOnline = async () => {
window.logFromJs(e.message, "isOnline") window.logFromJs(e.message, "isOnline")
} }
} }
// The installed app's badge: the current persona's unread notifications (Index.razor). Browsers without the Badging API
// (Firefox, or a tab that isn't installed) show none, and say nothing about it.
window.setAppBadge = (counter) => { window.setAppBadge = (counter) => {
try { try {
navigator.setAppBadge(counter) if (typeof navigator.setAppBadge != "function")
return
navigator.setAppBadge(counter)?.catch?.(e => console.debug(e))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
window.logFromJs(e.message, "setAppBadge") window.logFromJs(e.message, "setAppBadge")
@@ -59,12 +63,34 @@ window.setAppBadge = (counter) => {
} }
window.clearAppBadge = () => { window.clearAppBadge = () => {
try { try {
navigator.clearAppBadge() if (typeof navigator.clearAppBadge != "function")
return
navigator.clearAppBadge()?.catch?.(e => console.debug(e))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
window.logFromJs(e.message, "clearAppBadge") window.logFromJs(e.message, "clearAppBadge")
} }
} }
// A click on a Web Push notification while decePub is open: the persona's worker (push-worker.js) focuses this tab and
// says which of its registrations showed it, and the app opens that persona's notifications (CascadingState.PushOpened).
// Only this origin's workers can post here.
try {
if ("serviceWorker" in navigator) {
navigator.serviceWorker.addEventListener("message", event => {
try {
if (event.data?.type !== "decepub-push-open" || typeof event.data.slot != "string")
return
window.cascadingStateInstance?.invokeMethodAsync("PushOpened", event.data.slot)
} catch (e) {
console.error(e)
window.logFromJs(e.message, "pushOpened")
}
})
navigator.serviceWorker.startMessages()
}
} catch (e) {
console.error(e)
}
// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its // On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its
// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes. // thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes.
document.addEventListener("click", event => { document.addEventListener("click", event => {
+101
View File
@@ -0,0 +1,101 @@
// The service worker of one persona's Web Push subscription. wwwroot/js/push.js registers this script once per persona
// the reader turns notifications on for, at the scope push/{slot}/ (a random name this device keeps in localStorage's
// PushDevice, Services/PushService.cs), so each persona has a push subscription of its own: its own endpoint and keys,
// which PrivaPub stores with that persona's token alone. The same file serves development and published builds; the
// app's own workers (service-worker.js, service-worker.published.js) receive no pushes. It controls no page, so it has no
// fetch handler.
//
// A push shows the payload's title, body and icon, tagged with its notification id so a repeat replaces it. The payload
// is Mastodon's, so it also carries the subscription's access token: that is never read, kept, shown or passed on here.
// A click opens decePub on the notifications of the persona this registration is for: an open decePub tab is focused and
// told which (main.js, CascadingState.PushOpened), else a new one opens at ?notifications=1&push={slot}.
const appRoot = new URL("./", self.location).href
const slot = new URL(self.registration.scope).pathname.split("/").filter(part => part.length > 0).pop()
self.addEventListener("install", () => self.skipWaiting())
self.addEventListener("push", event => event.waitUntil(show(event.data)))
self.addEventListener("notificationclick", event => {
event.notification.close()
event.waitUntil(open())
})
// the push service changed the subscription: the same key subscribes again here, and the app tells PrivaPub the new
// endpoint the next time it starts (PushService.Reconcile), since this worker holds no token to tell it with
self.addEventListener("pushsubscriptionchange", event => event.waitUntil(renew(event)))
async function show(data) {
let title = "decePub"
const options = { icon: appRoot + "imgs/icon-192.png", timestamp: Date.now(), data: {} }
try {
const payload = data ? data.json() : null
if (payload && typeof payload == "object") {
if (typeof payload.title == "string" && payload.title.length > 0)
title = payload.title
if (typeof payload.body == "string")
options.body = payload.body
if (isWebAddress(payload.icon))
options.icon = payload.icon
if (payload.notification_id != null)
options.tag = String(payload.notification_id)
if (typeof payload.preferred_locale == "string")
options.lang = payload.preferred_locale
options.data = { notificationId: options.tag ?? null, type: typeof payload.notification_type == "string" ? payload.notification_type : null }
}
} catch (e) {
console.error(e)
}
// a push always shows something (userVisibleOnly): one that can't be read shows decePub's name alone
await self.registration.showNotification(title, options)
try {
// the count is the app's to say (Index.razor); until it opens, a dot says something waits
await self.navigator.setAppBadge?.()
} catch (e) {
console.debug(e)
}
}
function isWebAddress(value) {
if (typeof value != "string")
return false
try {
const url = new URL(value)
return url.protocol === "https:" || url.protocol === "http:"
} catch {
return false
}
}
async function open() {
const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true })
const app = windows.filter(client => client.url.startsWith(appRoot))
const target = app.find(client => client.focused) ?? app.find(client => client.visibilityState === "visible") ?? app[0]
if (target) {
try {
await target.focus()
} catch (e) {
console.error(e)
}
// (navigate() is only for the worker that controls the tab: the app's own one does)
target.postMessage({ type: "decepub-push-open", slot })
return
}
const url = new URL(appRoot)
url.searchParams.set("notifications", "1")
url.searchParams.set("push", slot)
await self.clients.openWindow(url.href)
}
async function renew(event) {
try {
const key = event.oldSubscription?.options?.applicationServerKey
if (!key || event.newSubscription)
return
await self.registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key })
} catch (e) {
console.error(e)
}
}
+2
View File
@@ -1,4 +1,6 @@
// In development, always fetch from the network and do not enable offline support. // In development, always fetch from the network and do not enable offline support.
// This is because caching would make development more difficult (changes would not // This is because caching would make development more difficult (changes would not
// be reflected on the first load after each change). // be reflected on the first load after each change).
// Web Push has workers of its own, one registration per persona at push/{slot}/ (push-worker.js, the same file in
// development and published builds): this worker receives no pushes.
self.addEventListener('fetch', () => { }); self.addEventListener('fetch', () => { });
+4 -2
View File
@@ -9,8 +9,10 @@ self.addEventListener('fetch', event => event.respondWith(onFetch(event)));
const cacheNamePrefix = 'offline-cache-'; const cacheNamePrefix = 'offline-cache-';
const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`; const cacheName = `${cacheNamePrefix}${self.assetsManifest.version}`;
const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ]; const offlineAssetsInclude = [ /\.dll$/, /\.pdb$/, /\.wasm/, /\.html/, /\.js$/, /\.json$/, /\.css$/, /\.woff$/, /\.png$/, /\.jpe?g$/, /\.gif$/, /\.ico$/, /\.blat$/, /\.dat$/ ];
// The globe (4 MB of script and textures) is fetched when the feed first shows it, never precached. // The globe (4 MB of script and textures) is fetched when the feed first shows it, never precached. The personas' Web Push
const offlineAssetsExclude = [ /^service-worker\.js$/, /^vendor\/globe\// ]; // worker (push-worker.js, its own registrations at push/{slot}/) is fetched by the browser as a worker script, which no
// service worker serves, so it isn't precached either; this worker receives no pushes.
const offlineAssetsExclude = [ /^service-worker\.js$/, /^push-worker\.js$/, /^vendor\/globe\// ];
async function onInstall(event) { async function onInstall(event) {
console.info('Service worker: Install'); console.info('Service worker: Install');