summaryrefslogtreecommitdiff
path: root/ui
diff options
context:
space:
mode:
authorojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
committerojacobson <ojacobson@noreply.codeberg.org>2025-11-07 23:17:15 +0100
commit9e6f19f0f188eaa7f8b6be21c8405786cfb0dddd (patch)
treeb2999341645dec61e8143d7bb1b8a9d0056e0db1 /ui
parent3c588861ef5814de329743147398dbae22c1aeeb (diff)
parent78d901328261d2306cf59c8e83fc217a63aa4a64 (diff)
Set up infrastructure for push message subscriptions.
A subscription allows an application server (here, the Pilcrow server) to send web push messages to a user agent. On the server, Pilcrow records subscriptions verbatim, in the clear. Each subscription has an associated key, which will be used to encrypt messages for the corresponding client, but we store them in the clear, for the same broad reason that we store the VAPID key in the clear. They allow anyone who obtains them to impersonate the server and send push messages to clients, but they're rotated regularly - clients must rotate them whenever the server's VAPID key changes. On the client, we monitor VAPID key change events to drive automatic subscription management, once the user sets up an initial subscription manually (which we must do as it can involve a user-interaction-only prompt for permission to send notifications). This isn't the final UI, but rather a bare-minimum version to let us move on with testing push notifications. Merges push-subscribe into push-notify.
Diffstat (limited to 'ui')
-rw-r--r--ui/lib/apiServer.js4
-rw-r--r--ui/lib/components/PushSubscription.svelte27
-rw-r--r--ui/lib/session.svelte.js19
-rw-r--r--ui/lib/state/local/push.svelte.js141
-rw-r--r--ui/lib/state/remote/state.svelte.js15
-rw-r--r--ui/routes/(app)/me/+page.svelte12
6 files changed, 197 insertions, 21 deletions
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index ac707a5..f55f271 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -55,6 +55,10 @@ export async function acceptInvite(inviteId, name, password) {
.catch(responseError);
}
+export async function createPushSubscription(subscription, vapid) {
+ return apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError);
+}
+
export function subscribeToEvents(resumePoint) {
const eventsUrl = apiServer.getUri({
url: '/events',
diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte
new file mode 100644
index 0000000..a85cbb3
--- /dev/null
+++ b/ui/lib/components/PushSubscription.svelte
@@ -0,0 +1,27 @@
+<script>
+ let { vapid, subscription, subscribe = async () => null } = $props();
+ let pending = $state(false);
+
+ function onsubmit(callback) {
+ return async (evt) => {
+ evt.preventDefault();
+
+ pending = true;
+ try {
+ await callback();
+ } finally {
+ pending = false;
+ }
+ };
+ }
+</script>
+
+{#if vapid !== null}
+ {#if subscription === null}
+ <form class="form" onsubmit={onsubmit(subscribe)}>
+ <button disabled={pending} type="submit">create push subscription</button>
+ </form>
+ {/if}
+{:else}
+ Waiting for VAPID key…
+{/if}
diff --git a/ui/lib/session.svelte.js b/ui/lib/session.svelte.js
index c415d0c..cd41aa4 100644
--- a/ui/lib/session.svelte.js
+++ b/ui/lib/session.svelte.js
@@ -5,6 +5,7 @@ import { goto } from '$app/navigation';
import * as api from './apiServer.js';
import * as r from './state/remote/state.svelte.js';
import * as l from './state/local/conversations.svelte.js';
+import * as p from './state/local/push.svelte.js';
import { Watchdog } from './watchdog.js';
import { DateTime } from 'luxon';
@@ -51,6 +52,7 @@ class Message {
class Session {
remote = $state();
local = $state();
+ push = $state();
currentUser = $derived(this.remote.currentUser);
users = $derived(this.remote.users.all);
messages = $derived(
@@ -62,7 +64,7 @@ class Session {
),
);
- static boot({ login, resume_point, heartbeat, events }) {
+ static async boot({ login, resume_point, heartbeat, events }) {
const remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
@@ -70,22 +72,25 @@ class Session {
events,
});
const local = l.Conversations.fromLocalStorage();
- return new Session(remote, local);
+ const push = await p.Push.boot(events);
+ return new Session(remote, local, push);
}
- reboot({ login, resume_point, heartbeat, events }) {
+ async reboot({ login, resume_point, heartbeat, events }) {
this.remote = r.State.boot({
currentUser: login,
resumePoint: resume_point,
heartbeat,
events,
});
+ this.push = await p.Push.boot(events);
}
- constructor(remote, local) {
+ constructor(remote, local, push) {
this.watchdog = new Watchdog(this.watchdogExpired.bind(this));
this.remote = remote;
this.local = local;
+ this.push = push;
}
begin() {
@@ -107,6 +112,7 @@ class Session {
onMessage(message) {
const event = JSON.parse(message.data);
this.remote.onEvent(event);
+ this.push.onEvent(event);
this.local.retainConversations(this.remote.conversations.all);
this.watchdog.reset(this.heartbeatMillis());
}
@@ -127,7 +133,7 @@ class Session {
// which the session may have been abandoned.
if (!this.active()) return;
- this.reboot(response);
+ await this.reboot(response);
this.begin();
}
}
@@ -139,6 +145,7 @@ async function bootOrNavigate(navigateTo) {
} catch (err) {
switch (true) {
case err instanceof api.LoggedOut:
+ await this.push.unsubscribe();
await navigateTo('/login');
break;
case err instanceof api.SetupRequired:
@@ -152,5 +159,5 @@ async function bootOrNavigate(navigateTo) {
export async function boot() {
const response = await bootOrNavigate(async (url) => redirect(307, url));
- return Session.boot(response);
+ return await Session.boot(response);
}
diff --git a/ui/lib/state/local/push.svelte.js b/ui/lib/state/local/push.svelte.js
new file mode 100644
index 0000000..82846b1
--- /dev/null
+++ b/ui/lib/state/local/push.svelte.js
@@ -0,0 +1,141 @@
+import * as api from '$lib/apiServer.js';
+
+// In a few places in this module, I've used suffix to identify which type we're working with:
+// * ...Base64 is a string containing a URL-safe base64 value;
+// * ...Buffer is an ArrayBuffer holding untyped bytes; and
+// * ...Bytes is a Uint8Array holding typed bytes.
+// Working with byte streams in JS is _fun_.
+
+export class Push {
+ static async boot(events) {
+ const serviceWorker = await navigator.serviceWorker.ready;
+ const pushManager = serviceWorker.pushManager;
+ const subscription = await serviceWorker.pushManager.getSubscription();
+
+ const push = new Push(pushManager, subscription);
+
+ for (const event of events) {
+ push.onEvent(event);
+ }
+
+ return push;
+ }
+
+ vapidKey = $state(null);
+ subscription = $state(null);
+
+ constructor(pushManager, subscription) {
+ this.pushManager = pushManager;
+ this.subscription = subscription;
+ }
+
+ async hasPermission() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+
+ const state = await this.pushManager.permissionState(pushOptions);
+ return state === 'granted';
+ }
+
+ async subscribe() {
+ const vapidKeyBuffer = this.vapidKey;
+ const pushOptions = {
+ userVisibleOnly: true,
+ applicationServerKey: vapidKeyBuffer,
+ };
+ this.subscription = await this.pushManager.subscribe(pushOptions);
+
+ const subscriptionJSON = this.subscription.toJSON();
+ const vapidKeyBytes = new Uint8Array(vapidKeyBuffer);
+ const vapidKeyBase64 = vapidKeyBytes.toBase64({ alphabet: 'base64url' });
+ await api.createPushSubscription(subscriptionJSON, vapidKeyBase64);
+ }
+
+ async resubscribe() {
+ if (this.subscription !== null) {
+ // If we have a subscription but it's not for the current VAPID key, then the VAPID key has
+ // been rotated and we need to replace the subscription. The server cannot deliver messages to
+ // subscriptions associated with the old VAPID key after rotation.
+
+ // Per spec, the `options.applicationServerKey` field is either null or an ArrayBuffer,
+ // regardless of the representation passed into `subscribe` to create the subscription:
+ //
+ // <https://w3c.github.io/push-api/#pushsubscriptionoptions-interface>
+ if (!equalArrayBuffers(this.subscription.options.applicationServerKey, this.vapidKey)) {
+ // We have a subscription, and the server key has rotated, so resubscribe. Destroy the old
+ // subscription first - some UAs (Firefox) want subscriptions within an origin to share a
+ // consistent VAPID key, and we're explicitly changing the VAPID key here.
+ await this.unsubscribe();
+ await this.subscribe();
+ }
+ } else if (await this.hasPermission()) {
+ // If we have permission to create push subscriptions, but no push subscription, then set up
+ // a new subscription. This primarily happens after logging into Pilcrow if the user
+ // previously had a push subscription and has logged out.
+ await this.subscribe();
+ }
+ }
+
+ async unsubscribe() {
+ if (this.subscription !== null) {
+ await this.subscription.unsubscribe();
+ this.subscription = null;
+ }
+ }
+
+ onEvent(event) {
+ switch (event.type) {
+ case 'vapid':
+ return this.onVapidEvent(event);
+ }
+ }
+
+ onVapidEvent(event) {
+ switch (event.event) {
+ case 'changed':
+ return this.onVapidChanged(event);
+ }
+ }
+
+ onVapidChanged(event) {
+ const { key: vapidKeyBase64 } = event;
+
+ // For ease of use later on, parse the key into an ArrayBuffer. This is a little fiddly because
+ // of the APIs involved, but it makes comparing the key to the current subscription's key
+ // (which is also provided as an ArrayBuffer) much easier.
+ const vapidKeyBytes = Uint8Array.fromBase64(vapidKeyBase64, {
+ alphabet: 'base64url',
+ });
+ // In practice, `vapidKeyBytes.buffer` is going to be the same bytes as this slice, but in
+ // principle it could be a subset of the underlying buffer, and there's very little downside to
+ // being meticulous here.
+ this.vapidKey = vapidKeyBytes.buffer.slice(
+ vapidKeyBytes.byteOffset,
+ vapidKeyBytes.byteOffset + vapidKeyBytes.byteLength,
+ );
+
+ // Note that `resubscribe()` is async; this will start the [re]subscription process but will
+ // return to the caller before it completes. I'm not willing to make the entire event handling
+ // chain all the way back up to the EventSource asynchronous, since EventSource isn't designed
+ // to support that (we could make it work), and we can't wait for async functions in a non-async
+ // context, so this is the best we can do.
+ this.resubscribe();
+ }
+}
+
+function equalArrayBuffers(aBuffer, bBuffer) {
+ // You might be thinking, surely there's a way to compare two array buffers for equality. I
+ // certainly expected that there would be. [Nope].
+ //
+ // [Nope]: https://github.com/wbinnssmith/arraybuffer-equal
+ //
+ // The algorithm here is simple enough not to need an external dependency. However, this
+ // comparison is not designed to deal with detached ArrayBuffer instances.
+ const aBytes = new Uint8Array(aBuffer);
+ const bBytes = new Uint8Array(bBuffer);
+
+ return aBytes.length === bBytes.length && aBytes.every((value, index) => value === bBytes[index]);
+}
diff --git a/ui/lib/state/remote/state.svelte.js b/ui/lib/state/remote/state.svelte.js
index 8845e02..3d65e4a 100644
--- a/ui/lib/state/remote/state.svelte.js
+++ b/ui/lib/state/remote/state.svelte.js
@@ -7,7 +7,6 @@ export class State {
users = $state(new Users());
conversations = $state(new Conversations());
messages = $state(new Messages());
- vapid_key = $state(null);
static boot({ currentUser, heartbeat, resumePoint, events }) {
const state = new State({
@@ -37,8 +36,6 @@ export class State {
return this.onUserEvent(event);
case 'message':
return this.onMessageEvent(event);
- case 'vapid':
- return this.onVapidEvent(event);
}
}
@@ -91,16 +88,4 @@ export class State {
const { id } = event;
this.messages.remove(id);
}
-
- onVapidEvent(event) {
- switch (event.event) {
- case 'changed':
- return this.onVapidChanged(event);
- }
- }
-
- onVapidChanged(event) {
- let { key } = event;
- this.vapid_key = key;
- }
}
diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 0c960c8..ddb1245 100644
--- a/ui/routes/(app)/me/+page.svelte
+++ b/ui/routes/(app)/me/+page.svelte
@@ -2,10 +2,16 @@
import LogOut from '$lib/components/LogOut.svelte';
import Invites from '$lib/components/Invites.svelte';
import ChangePassword from '$lib/components/ChangePassword.svelte';
+ import PushSubscription from '$lib/components/PushSubscription.svelte';
import { goto } from '$app/navigation';
import * as api from '$lib/apiServer.js';
+ const { data } = $props();
+ const { session } = data;
+ const subscription = $derived(session.push.subscription);
+ const vapid = $derived(session.push.vapidKey);
+
let invites = $state([]);
async function logOut() {
@@ -25,10 +31,16 @@
invites.push(response.data);
}
}
+
+ async function subscribe() {
+ await session.push.subscribe();
+ }
</script>
<ChangePassword {changePassword} />
<hr />
+<PushSubscription {subscription} {vapid} {subscribe} />
+<hr />
<Invites {invites} {createInvite} />
<hr />
<LogOut {logOut} />