summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOwen Jacobson <owen@grimoire.ca>2025-11-08 16:28:10 -0500
committerOwen Jacobson <owen@grimoire.ca>2025-11-08 16:28:10 -0500
commitfc6914831743f6d683c59adb367479defe6f8b3a (patch)
tree5b997adac55f47b52f30022013b8ec3b2c10bcc5
parent0ef69c7d256380e660edc45ace7f1d6151226340 (diff)
parent6bab5b4405c9adafb2ce76540595a62eea80acc0 (diff)
Integrate the prototype push notification support.
We're going to move forwards with this for now, as low-utility as it is, so that we can more easily iterate on it in a real-world environment (hi.grimoire.ca).
-rw-r--r--.sqlx/query-19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3.json32
-rw-r--r--.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json12
-rw-r--r--.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json12
-rw-r--r--.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json12
-rw-r--r--.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json12
-rw-r--r--.sqlx/query-82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json12
-rw-r--r--.sqlx/query-8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e.json20
-rw-r--r--.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json12
-rw-r--r--.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json12
-rw-r--r--.sqlx/query-be295f56960d083d1f4c760f6acfdfbf9898a1a407e8a562a181542834d05eb0.json32
-rw-r--r--.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json12
-rw-r--r--.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json12
-rw-r--r--.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json12
-rw-r--r--.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json32
-rw-r--r--Cargo.lock1029
-rw-r--r--Cargo.toml4
-rw-r--r--docs/api/SUMMARY.md1
-rw-r--r--docs/api/authentication.md4
-rw-r--r--docs/api/basics.md2
-rw-r--r--docs/api/events.md30
-rw-r--r--docs/api/push.md124
-rw-r--r--docs/ops.md14
-rw-r--r--migrations/20250829220738_vapid_keys.sql17
-rw-r--r--migrations/20251009021241_push_subscriptions.sql12
-rw-r--r--migrations/20251028173914_pem_vapid_keys.sql15
-rw-r--r--src/app.rs71
-rw-r--r--src/boot/app.rs27
-rw-r--r--src/boot/handlers/boot/test.rs68
-rw-r--r--src/cli.rs32
-rw-r--r--src/event/app.rs26
-rw-r--r--src/event/handlers/stream/mod.rs4
-rw-r--r--src/event/handlers/stream/test/mod.rs1
-rw-r--r--src/event/handlers/stream/test/vapid.rs111
-rw-r--r--src/event/mod.rs10
-rw-r--r--src/expire.rs4
-rw-r--r--src/lib.rs2
-rw-r--r--src/login/app.rs2
-rw-r--r--src/push/app.rs178
-rw-r--r--src/push/handlers/mod.rs5
-rw-r--r--src/push/handlers/ping/mod.rs23
-rw-r--r--src/push/handlers/ping/test.rs40
-rw-r--r--src/push/handlers/subscribe/mod.rs94
-rw-r--r--src/push/handlers/subscribe/test.rs236
-rw-r--r--src/push/mod.rs3
-rw-r--r--src/push/repo.rs149
-rw-r--r--src/routes.rs16
-rw-r--r--src/test/fixtures/event/mod.rs21
-rw-r--r--src/test/fixtures/event/stream.rs17
-rw-r--r--src/test/fixtures/identity.rs2
-rw-r--r--src/test/fixtures/login.rs2
-rw-r--r--src/test/fixtures/mod.rs6
-rw-r--r--src/test/fixtures/user.rs4
-rw-r--r--src/test/mod.rs1
-rw-r--r--src/test/webpush.rs37
-rw-r--r--src/token/app.rs3
-rw-r--r--src/token/repo/token.rs17
-rw-r--r--src/vapid/app.rs117
-rw-r--r--src/vapid/event.rs37
-rw-r--r--src/vapid/history.rs55
-rw-r--r--src/vapid/middleware.rs17
-rw-r--r--src/vapid/mod.rs10
-rw-r--r--src/vapid/repo.rs161
-rw-r--r--src/vapid/ser.rs63
-rw-r--r--ui/lib/apiServer.js10
-rw-r--r--ui/lib/components/PushSubscription.svelte30
-rw-r--r--ui/lib/session.svelte.js19
-rw-r--r--ui/lib/state/local/push.svelte.js141
-rw-r--r--ui/routes/(app)/me/+page.svelte16
-rw-r--r--ui/routes/(swatch)/.swatch/+page.svelte1
-rw-r--r--ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte79
-rw-r--r--ui/service-worker.js9
71 files changed, 3353 insertions, 112 deletions
diff --git a/.sqlx/query-19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3.json b/.sqlx/query-19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3.json
new file mode 100644
index 0000000..7382bfc
--- /dev/null
+++ b/.sqlx/query-19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n subscription.endpoint,\n subscription.p256dh,\n subscription.auth\n from push_subscription as subscription\n join token on subscription.token = token.id\n where token.login = $1\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "endpoint",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "p256dh",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "auth",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3"
+}
diff --git a/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json b/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json
new file mode 100644
index 0000000..be1779a
--- /dev/null
+++ b/.sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n with stale_tokens as (\n select id from token\n where last_used_at < $1\n )\n delete from push_subscription\n where token in stale_tokens\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9"
+}
diff --git a/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json b/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json
new file mode 100644
index 0000000..ad505b5
--- /dev/null
+++ b/.sqlx/query-3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from vapid_signing_key\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "3f1bf6aaceb140ae03262bed1fb39fb9cb5e89b67861fb384e6238eb9553e6e7"
+}
diff --git a/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json b/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json
new file mode 100644
index 0000000..abd04ac
--- /dev/null
+++ b/.sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n with tokens as (\n select id from token\n where login = $1\n )\n delete from push_subscription\n where token in tokens\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682"
+}
diff --git a/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json b/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json
new file mode 100644
index 0000000..ab98270
--- /dev/null
+++ b/.sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from push_subscription\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a"
+}
diff --git a/.sqlx/query-82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json b/.sqlx/query-82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json
new file mode 100644
index 0000000..8d23fa0
--- /dev/null
+++ b/.sqlx/query-82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from push_subscription\n where endpoint = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79"
+}
diff --git a/.sqlx/query-8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e.json b/.sqlx/query-8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e.json
new file mode 100644
index 0000000..172c4df
--- /dev/null
+++ b/.sqlx/query-8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e.json
@@ -0,0 +1,20 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select key\n from vapid_signing_key\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "key",
+ "ordinal": 0,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": [
+ false
+ ]
+ },
+ "hash": "8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e"
+}
diff --git a/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json b/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json
new file mode 100644
index 0000000..8a80025
--- /dev/null
+++ b/.sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into push_subscription (token, endpoint, p256dh, auth)\n values ($1, $2, $3, $4)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 4
+ },
+ "nullable": []
+ },
+ "hash": "a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584"
+}
diff --git a/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json b/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json
new file mode 100644
index 0000000..5e004a3
--- /dev/null
+++ b/.sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from push_subscription\n where token = $1\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72"
+}
diff --git a/.sqlx/query-be295f56960d083d1f4c760f6acfdfbf9898a1a407e8a562a181542834d05eb0.json b/.sqlx/query-be295f56960d083d1f4c760f6acfdfbf9898a1a407e8a562a181542834d05eb0.json
new file mode 100644
index 0000000..ccec274
--- /dev/null
+++ b/.sqlx/query-be295f56960d083d1f4c760f6acfdfbf9898a1a407e8a562a181542834d05eb0.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n key.changed_at as \"changed_at: DateTime\",\n key.changed_sequence as \"changed_sequence: Sequence\",\n signing.key\n from vapid_key as key\n join vapid_signing_key as signing\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "changed_at: DateTime",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "changed_sequence: Sequence",
+ "ordinal": 1,
+ "type_info": "Integer"
+ },
+ {
+ "name": "key",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "be295f56960d083d1f4c760f6acfdfbf9898a1a407e8a562a181542834d05eb0"
+}
diff --git a/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json b/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json
new file mode 100644
index 0000000..ca62f63
--- /dev/null
+++ b/.sqlx/query-cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n delete from vapid_key\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 0
+ },
+ "nullable": []
+ },
+ "hash": "cf9bc5c02ff22b98b8eec670070aeaf22811cf4e1527624c615a88eb29e6c9df"
+}
diff --git a/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json b/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json
new file mode 100644
index 0000000..a5880c1
--- /dev/null
+++ b/.sqlx/query-f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into vapid_key (changed_at, changed_sequence)\n values ($1, $2)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": []
+ },
+ "hash": "f2218170662ff7450a9a9b18b38e92d740f4f715f39944774a9e8392e0267123"
+}
diff --git a/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json b/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json
new file mode 100644
index 0000000..6827441
--- /dev/null
+++ b/.sqlx/query-f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac.json
@@ -0,0 +1,12 @@
+{
+ "db_name": "SQLite",
+ "query": "\n insert into vapid_signing_key (key)\n values ($1)\n ",
+ "describe": {
+ "columns": [],
+ "parameters": {
+ "Right": 1
+ },
+ "nullable": []
+ },
+ "hash": "f31071e0c069c2e33b3e44fc2524d2b93ff8af35c4978a467a1c7b1331750cac"
+}
diff --git a/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json b/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json
new file mode 100644
index 0000000..3e50a4b
--- /dev/null
+++ b/.sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json
@@ -0,0 +1,32 @@
+{
+ "db_name": "SQLite",
+ "query": "\n select\n subscription.endpoint,\n subscription.p256dh,\n subscription.auth\n from push_subscription as subscription\n join token on subscription.token = token.id\n join login as subscriber on token.login = subscriber.id\n where subscriber.id = $1\n and subscription.endpoint = $2\n ",
+ "describe": {
+ "columns": [
+ {
+ "name": "endpoint",
+ "ordinal": 0,
+ "type_info": "Text"
+ },
+ {
+ "name": "p256dh",
+ "ordinal": 1,
+ "type_info": "Text"
+ },
+ {
+ "name": "auth",
+ "ordinal": 2,
+ "type_info": "Text"
+ }
+ ],
+ "parameters": {
+ "Right": 2
+ },
+ "nullable": [
+ false,
+ false,
+ false
+ ]
+ },
+ "hash": "f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004"
+}
diff --git a/Cargo.lock b/Cargo.lock
index b24fc91..f7d3337 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -30,6 +30,15 @@ dependencies = [
]
[[package]]
+name = "aho-corasick"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "allocator-api2"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -101,6 +110,12 @@ dependencies = [
]
[[package]]
+name = "anyhow"
+version = "1.0.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
+
+[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -113,6 +128,40 @@ dependencies = [
]
[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[package]]
+name = "async-channel"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35"
+dependencies = [
+ "concurrent-queue",
+ "event-listener 2.5.3",
+ "futures-core",
+]
+
+[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
+]
+
+[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -137,7 +186,7 @@ dependencies = [
"bytes",
"form_urlencoded",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"hyper",
@@ -169,7 +218,7 @@ checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"mime",
@@ -194,7 +243,7 @@ dependencies = [
"form_urlencoded",
"futures-util",
"headers",
- "http",
+ "http 1.2.0",
"http-body",
"http-body-util",
"mime",
@@ -223,6 +272,18 @@ dependencies = [
]
[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
name = "base64"
version = "0.21.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -241,6 +302,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
[[package]]
+name = "binstring"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
name = "bitflags"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -259,6 +332,17 @@ dependencies = [
]
[[package]]
+name = "blake2b_simd"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06e903a20b159e944f91ec8499fe1e55651480c541ea0a584f5d967c49ad9d99"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "constant_time_eq",
+]
+
+[[package]]
name = "block-buffer"
version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -286,6 +370,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f61dac84819c6588b558454b194026eb1f09c293b9036ae9b159e74e73ab6cf9"
[[package]]
+name = "castaway"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6"
+
+[[package]]
name = "cc"
version = "1.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -352,7 +442,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -362,6 +452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6"
[[package]]
+name = "coarsetime"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91849686042de1b41cd81490edc83afbcb0abe5a9b6f2c4114f23ce8cca1bcf4"
+dependencies = [
+ "libc",
+ "wasix",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "colorchoice"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -378,11 +479,23 @@ dependencies = [
[[package]]
name = "const-oid"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d6f2aa4d0537bcc1c74df8755072bd31c1ef1a3a1b85a68e8404a8c353b7b8b"
+
+[[package]]
+name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
+name = "constant_time_eq"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6"
+
+[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -439,6 +552,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
name = "crypto-common"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -449,6 +574,43 @@ dependencies = [
]
[[package]]
+name = "ct-codecs"
+version = "1.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8"
+
+[[package]]
+name = "curl"
+version = "0.4.49"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79fc3b6dd0b87ba36e565715bf9a2ced221311db47bd18011676f24a6066edbc"
+dependencies = [
+ "curl-sys",
+ "libc",
+ "openssl-probe",
+ "openssl-sys",
+ "schannel",
+ "socket2 0.6.1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "curl-sys"
+version = "0.4.83+curl-8.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5830daf304027db10c82632a464879d46a3f7c4ba17a31592657ad16c719b483"
+dependencies = [
+ "cc",
+ "libc",
+ "libnghttp2-sys",
+ "libz-sys",
+ "openssl-sys",
+ "pkg-config",
+ "vcpkg",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -469,7 +631,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -480,7 +642,17 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
- "syn",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "der"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79b71cca7d95d7681a4b3b9cdf63c8dbc3730d0584c2c74e31416d64a90493f4"
+dependencies = [
+ "const-oid 0.6.2",
+ "der_derive",
]
[[package]]
@@ -489,12 +661,24 @@ version = "0.7.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0"
dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
"pem-rfc7468",
"zeroize",
]
[[package]]
+name = "der_derive"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8aed3b3c608dc56cf36c45fe979d04eda51242e6703d8d0bb03426ef7c41db6a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "synstructure 0.12.6",
+]
+
+[[package]]
name = "deranged"
version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -517,7 +701,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [
"block-buffer",
- "const-oid",
+ "const-oid 0.9.6",
"crypto-common",
"subtle",
]
@@ -551,7 +735,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -567,6 +751,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der 0.7.9",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "ece"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2ea1d2f2cc974957a4e2575d8e5bb494549bab66338d6320c2789abcfff5746"
+dependencies = [
+ "base64 0.21.7",
+ "byteorder",
+ "hex",
+ "hkdf",
+ "lazy_static",
+ "once_cell",
+ "openssl",
+ "serde",
+ "sha2",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ed25519-compact"
+version = "2.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9b3460f44bea8cd47f45a0c70892f1eff856d97cd55358b2f73f663789f6190"
+dependencies = [
+ "ct-codecs",
+ "getrandom 0.2.15",
+]
+
+[[package]]
name = "either"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -576,6 +802,36 @@ dependencies = [
]
[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -604,6 +860,12 @@ dependencies = [
[[package]]
name = "event-listener"
+version = "2.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0"
+
+[[package]]
+name = "event-listener"
version = "5.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3492acde4c3fc54c845eaab3eed8bd00c7a7d881f78bfc801e43a93dec1331ae"
@@ -638,11 +900,30 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be"
+dependencies = [
+ "instant",
+]
+
+[[package]]
+name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
name = "flume"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -666,6 +947,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a0d2fde1f7b3d48b8395d5f2de76c18a528bd6a9cdde438df747bfcba3e05d6f"
[[package]]
+name = "foreign-types"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
+dependencies = [
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
+
+[[package]]
name = "form_urlencoded"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -734,6 +1030,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
[[package]]
+name = "futures-lite"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce"
+dependencies = [
+ "fastrand 1.9.0",
+ "futures-core",
+ "futures-io",
+ "memchr",
+ "parking",
+ "pin-project-lite",
+ "waker-fn",
+]
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand 2.3.0",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -741,7 +1065,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -782,6 +1106,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
+ "zeroize",
]
[[package]]
@@ -791,8 +1116,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi 0.11.0+wasi-snapshot-preview1",
+ "wasm-bindgen",
]
[[package]]
@@ -814,6 +1141,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
name = "hashbrown"
version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -866,7 +1204,7 @@ dependencies = [
"base64 0.21.7",
"bytes",
"headers-core",
- "http",
+ "http 1.2.0",
"httpdate",
"mime",
"sha1",
@@ -878,7 +1216,7 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4"
dependencies = [
- "http",
+ "http 1.2.0",
]
[[package]]
@@ -918,6 +1256,30 @@ dependencies = [
]
[[package]]
+name = "hmac-sha1-compact"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18492c9f6f9a560e0d346369b665ad2bdbc89fa9bceca75796584e79042694c3"
+
+[[package]]
+name = "hmac-sha256"
+version = "1.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "hmac-sha512"
+version = "1.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89e8d20b3799fa526152a5301a771eaaad80857f83e01b23216ceaafb2d9280"
+dependencies = [
+ "digest",
+]
+
+[[package]]
name = "home"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -928,6 +1290,17 @@ dependencies = [
[[package]]
name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f16ca2af56261c99fba8bac40a10251ce8188205a4c448fbb745a2e4daa76fea"
@@ -944,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
dependencies = [
"bytes",
- "http",
+ "http 1.2.0",
]
[[package]]
@@ -955,7 +1328,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"pin-project-lite",
]
@@ -981,7 +1354,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"httparse",
"httpdate",
@@ -999,7 +1372,7 @@ checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4"
dependencies = [
"bytes",
"futures-util",
- "http",
+ "http 1.2.0",
"http-body",
"hyper",
"pin-project-lite",
@@ -1145,7 +1518,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1198,12 +1571,48 @@ dependencies = [
]
[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "is_terminal_polyfill"
version = "1.70.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
[[package]]
+name = "isahc"
+version = "1.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9"
+dependencies = [
+ "async-channel",
+ "castaway",
+ "crossbeam-utils",
+ "curl",
+ "curl-sys",
+ "encoding_rs",
+ "event-listener 2.5.3",
+ "futures-lite 1.13.0",
+ "http 0.2.12",
+ "log",
+ "mime",
+ "once_cell",
+ "polling",
+ "slab",
+ "sluice",
+ "tracing",
+ "tracing-futures",
+ "url",
+ "waker-fn",
+]
+
+[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1229,6 +1638,46 @@ dependencies = [
]
[[package]]
+name = "jwt-simple"
+version = "0.12.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "731011e9647a71ff4f8474176ff6ce6e0d2de87a0173f15613af3a84c3e3401a"
+dependencies = [
+ "anyhow",
+ "binstring",
+ "blake2b_simd",
+ "coarsetime",
+ "ct-codecs",
+ "ed25519-compact",
+ "hmac-sha1-compact",
+ "hmac-sha256",
+ "hmac-sha512",
+ "k256",
+ "p256",
+ "p384",
+ "rand",
+ "serde",
+ "serde_json",
+ "superboring",
+ "thiserror 2.0.17",
+ "zeroize",
+]
+
+[[package]]
+name = "k256"
+version = "0.13.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b"
+dependencies = [
+ "cfg-if",
+ "ecdsa",
+ "elliptic-curve",
+ "once_cell",
+ "sha2",
+ "signature",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1250,12 +1699,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa"
[[package]]
+name = "libnghttp2-sys"
+version = "0.1.11+1.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b6c24e48a7167cffa7119da39d577fa482e66c688a4aac016bee862e1a713c4"
+dependencies = [
+ "cc",
+ "libc",
+]
+
+[[package]]
name = "libredox"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"libc",
]
@@ -1271,6 +1730,18 @@ dependencies = [
]
[[package]]
+name = "libz-sys"
+version = "1.1.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1352,7 +1823,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"cfg-if",
"cfg_aliases",
"libc",
@@ -1427,12 +1898,80 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e"
[[package]]
+name = "openssl"
+version = "0.10.74"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654"
+dependencies = [
+ "bitflags 2.8.0",
+ "cfg-if",
+ "foreign-types",
+ "libc",
+ "once_cell",
+ "openssl-macros",
+ "openssl-sys",
+]
+
+[[package]]
+name = "openssl-macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.110"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
name = "parking"
version = "2.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1473,6 +2012,27 @@ dependencies = [
]
[[package]]
+name = "pem"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd56cbd21fea48d0c440b41cd69c589faacade08c992d9a54e471b79d0fd13eb"
+dependencies = [
+ "base64 0.13.1",
+ "once_cell",
+ "regex",
+]
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1492,8 +2052,10 @@ name = "pilcrow"
version = "0.1.0"
dependencies = [
"argon2",
+ "async-trait",
"axum",
"axum-extra",
+ "base64 0.22.1",
"chrono",
"clap",
"faker_rand",
@@ -1503,6 +2065,7 @@ dependencies = [
"itertools",
"mime",
"nix",
+ "p256",
"password-hash",
"pin-project",
"rand",
@@ -1513,7 +2076,7 @@ dependencies = [
"serde_json",
"serde_with",
"sqlx",
- "thiserror",
+ "thiserror 2.0.17",
"tokio",
"tokio-stream",
"tower",
@@ -1522,6 +2085,7 @@ dependencies = [
"unicode-segmentation",
"unix_path",
"uuid",
+ "web-push",
]
[[package]]
@@ -1541,7 +2105,7 @@ checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1562,7 +2126,7 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
- "der",
+ "der 0.7.9",
"pkcs8",
"spki",
]
@@ -1573,7 +2137,7 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "der",
+ "der 0.7.9",
"spki",
]
@@ -1584,6 +2148,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2"
[[package]]
+name = "polling"
+version = "2.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce"
+dependencies = [
+ "autocfg",
+ "bitflags 1.3.2",
+ "cfg-if",
+ "concurrent-queue",
+ "libc",
+ "log",
+ "pin-project-lite",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1599,6 +2179,15 @@ dependencies = [
]
[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.93"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1652,7 +2241,7 @@ version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
]
[[package]]
@@ -1663,7 +2252,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b"
dependencies = [
"getrandom 0.2.15",
"libredox",
- "thiserror",
+ "thiserror 2.0.17",
]
[[package]]
@@ -1683,7 +2272,46 @@ checksum = "1165225c21bff1f3bbce98f5a1f889949bc902d3575308cc7b0de30b4f6d27c7"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "regex"
+version = "1.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
]
[[package]]
@@ -1692,7 +2320,7 @@ version = "0.9.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519"
dependencies = [
- "const-oid",
+ "const-oid 0.9.6",
"digest",
"num-bigint-dig",
"num-integer",
@@ -1700,6 +2328,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core",
+ "sha2",
"signature",
"spki",
"subtle",
@@ -1712,7 +2341,7 @@ version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink 0.9.1",
@@ -1741,7 +2370,7 @@ dependencies = [
"quote",
"rust-embed-utils",
"shellexpand",
- "syn",
+ "syn 2.0.98",
"walkdir",
]
@@ -1767,7 +2396,7 @@ version = "0.38.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
"errno",
"libc",
"linux-raw-sys",
@@ -1796,6 +2425,15 @@ dependencies = [
]
[[package]]
+name = "schannel"
+version = "0.1.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
name = "schemars"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1826,23 +2464,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der 0.7.9",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "sec1_decode"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6326ddc956378a0739200b2c30892dccaf198992dfd7323274690b9e188af23"
+dependencies = [
+ "der 0.4.5",
+ "pem 0.8.3",
+ "thiserror 1.0.69",
+]
+
+[[package]]
name = "serde"
-version = "1.0.217"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
-version = "1.0.217"
+version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1860,14 +2533,15 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.138"
+version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949"
+checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [
"itoa",
"memchr",
"ryu",
"serde",
+ "serde_core",
]
[[package]]
@@ -1921,7 +2595,7 @@ dependencies = [
"darling",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -1981,6 +2655,17 @@ dependencies = [
]
[[package]]
+name = "sluice"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5"
+dependencies = [
+ "async-channel",
+ "futures-core",
+ "futures-io",
+]
+
+[[package]]
name = "smallvec"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2000,6 +2685,16 @@ dependencies = [
]
[[package]]
+name = "socket2"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2015,7 +2710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.9",
]
[[package]]
@@ -2042,7 +2737,7 @@ dependencies = [
"crc",
"crossbeam-queue",
"either",
- "event-listener",
+ "event-listener 5.4.0",
"futures-core",
"futures-intrusive",
"futures-io",
@@ -2058,7 +2753,7 @@ dependencies = [
"serde_json",
"sha2",
"smallvec",
- "thiserror",
+ "thiserror 2.0.17",
"tokio",
"tokio-stream",
"tracing",
@@ -2075,7 +2770,7 @@ dependencies = [
"quote",
"sqlx-core",
"sqlx-macros-core",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2098,7 +2793,7 @@ dependencies = [
"sqlx-mysql",
"sqlx-postgres",
"sqlx-sqlite",
- "syn",
+ "syn 2.0.98",
"tempfile",
"tokio",
"url",
@@ -2112,7 +2807,7 @@ checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.8.0",
"byteorder",
"bytes",
"chrono",
@@ -2142,7 +2837,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.17",
"tracing",
"whoami",
]
@@ -2155,7 +2850,7 @@ checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613"
dependencies = [
"atoi",
"base64 0.22.1",
- "bitflags",
+ "bitflags 2.8.0",
"byteorder",
"chrono",
"crc",
@@ -2180,7 +2875,7 @@ dependencies = [
"smallvec",
"sqlx-core",
"stringprep",
- "thiserror",
+ "thiserror 2.0.17",
"tracing",
"whoami",
]
@@ -2239,6 +2934,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]]
+name = "superboring"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "515cce34a781d7250b8a65706e0f2a5b99236ea605cb235d4baed6685820478f"
+dependencies = [
+ "getrandom 0.2.15",
+ "hmac-sha256",
+ "hmac-sha512",
+ "rand",
+ "rsa",
+]
+
+[[package]]
+name = "syn"
+version = "1.0.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
name = "syn"
version = "2.0.98"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2257,13 +2976,25 @@ checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "synstructure"
+version = "0.12.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f36bdaa60a83aca3921b5259d5400cbf5e90fc51931376a9bd4a0eb79aa7210f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 1.0.109",
+ "unicode-xid",
+]
+
+[[package]]
+name = "synstructure"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2273,7 +3004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e5a0acb1f3f55f65cc4a866c361b2fb2a0ff6366785ae6fbb5f85df07ba230"
dependencies = [
"cfg-if",
- "fastrand",
+ "fastrand 2.3.0",
"getrandom 0.3.1",
"once_cell",
"rustix",
@@ -2282,22 +3013,42 @@ dependencies = [
[[package]]
name = "thiserror"
-version = "2.0.11"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
dependencies = [
- "thiserror-impl",
+ "thiserror-impl 2.0.17",
]
[[package]]
name = "thiserror-impl"
-version = "2.0.11"
+version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "26afc1baea8a989337eeb52b6e72a039780ce45c3edfcc9c5b9d112feeb173c2"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.98",
]
[[package]]
@@ -2367,7 +3118,7 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
- "socket2",
+ "socket2 0.5.8",
"tokio-macros",
"windows-sys 0.52.0",
]
@@ -2380,7 +3131,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2456,7 +3207,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2469,6 +3220,16 @@ dependencies = [
]
[[package]]
+name = "tracing-futures"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2"
+dependencies = [
+ "pin-project",
+ "tracing",
+]
+
+[[package]]
name = "typenum"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2514,6 +3275,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
name = "unix_path"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2579,6 +3346,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
+name = "waker-fn"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7"
+
+[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2610,6 +3383,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
[[package]]
+name = "wasix"
+version = "0.12.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1fbb4ef9bbca0c1170e0b00dd28abc9e3b68669821600cad1caaed606583c6d"
+dependencies = [
+ "wasi 0.11.0+wasi-snapshot-preview1",
+]
+
+[[package]]
name = "wasm-bindgen"
version = "0.2.100"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2631,7 +3413,7 @@ dependencies = [
"log",
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
"wasm-bindgen-shared",
]
@@ -2653,7 +3435,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
"wasm-bindgen-backend",
"wasm-bindgen-shared",
]
@@ -2668,6 +3450,28 @@ dependencies = [
]
[[package]]
+name = "web-push"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d5c305b9ee2993ab68b7744b13ef32231d83600dd879ac8183b4c76ae31d28ac"
+dependencies = [
+ "async-trait",
+ "chrono",
+ "ct-codecs",
+ "ece",
+ "futures-lite 2.6.1",
+ "http 0.2.12",
+ "isahc",
+ "jwt-simple",
+ "log",
+ "pem 3.0.6",
+ "sec1_decode",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
name = "whoami"
version = "1.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2696,6 +3500,12 @@ dependencies = [
]
[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
name = "windows-sys"
version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2723,6 +3533,24 @@ dependencies = [
]
[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
name = "windows-targets"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2746,7 +3574,7 @@ dependencies = [
"windows_aarch64_gnullvm 0.52.6",
"windows_aarch64_msvc 0.52.6",
"windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm",
+ "windows_i686_gnullvm 0.52.6",
"windows_i686_msvc 0.52.6",
"windows_x86_64_gnu 0.52.6",
"windows_x86_64_gnullvm 0.52.6",
@@ -2754,6 +3582,23 @@ dependencies = [
]
[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
name = "windows_aarch64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2766,6 +3611,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
name = "windows_aarch64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2778,6 +3629,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
name = "windows_i686_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2790,12 +3647,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
name = "windows_i686_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2808,6 +3677,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
name = "windows_x86_64_gnu"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2820,6 +3695,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
name = "windows_x86_64_gnullvm"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2832,6 +3713,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
name = "windows_x86_64_msvc"
version = "0.48.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2844,12 +3731,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
name = "wit-bindgen-rt"
version = "0.33.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3268f3d866458b787f390cf61f4bbb563b922d091359f9608842999eaee3943c"
dependencies = [
- "bitflags",
+ "bitflags 2.8.0",
]
[[package]]
@@ -2884,8 +3777,8 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154"
dependencies = [
"proc-macro2",
"quote",
- "syn",
- "synstructure",
+ "syn 2.0.98",
+ "synstructure 0.13.1",
]
[[package]]
@@ -2906,7 +3799,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
[[package]]
@@ -2926,8 +3819,8 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808"
dependencies = [
"proc-macro2",
"quote",
- "syn",
- "synstructure",
+ "syn 2.0.98",
+ "synstructure 0.13.1",
]
[[package]]
@@ -2955,5 +3848,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.98",
]
diff --git a/Cargo.toml b/Cargo.toml
index 04c3f6f..1f5fa3d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -24,6 +24,7 @@ assets = [
argon2 = "0.5.3"
axum = { version = "0.8.1", features = ["form"] }
axum-extra = { version = "0.10.0", features = ["cookie", "query", "typed-header"] }
+base64 = "0.22.1"
chrono = { version = "0.4.39", features = ["serde"] }
clap = { version = "4.5.30", features = ["derive", "env"] }
futures = "0.3.31"
@@ -32,6 +33,7 @@ hex-literal = "0.4.1"
itertools = "0.14.0"
mime = "0.3.17"
nix = { version = "0.30.1", features = ["fs"] }
+p256 = { version = "0.13.2", features = ["ecdsa"] }
password-hash = { version = "0.5.0", features = ["std"] }
rand = "0.8.5"
rand_core = { version = "0.6.4", features = ["getrandom"] }
@@ -54,8 +56,10 @@ unicode-normalization = "0.1.24"
unicode-segmentation = "1.12.0"
unix_path = "1.0.1"
uuid = { version = "1.13.2", features = ["v4"] }
+web-push = "0.11.0"
[dev-dependencies]
+async-trait = "0.1.89"
faker_rand = "0.1.1"
pin-project = "1.1.9"
rand = "0.8.5"
diff --git a/docs/api/SUMMARY.md b/docs/api/SUMMARY.md
index f51fbc7..5974ba7 100644
--- a/docs/api/SUMMARY.md
+++ b/docs/api/SUMMARY.md
@@ -7,3 +7,4 @@
- [Events](events.md)
- [Invitations](invitations.md)
- [Conversations and messages](conversations-messages.md)
+- [Push notifications](push.md)
diff --git a/docs/api/authentication.md b/docs/api/authentication.md
index 189103e..801f0e7 100644
--- a/docs/api/authentication.md
+++ b/docs/api/authentication.md
@@ -85,6 +85,8 @@ This endpoint will respond with a status of `401 Unauthorized` if the login name
Invalidates the identity token used to make the request, logging the caller out.
+This terminates any [push subscriptions](push.md#receiving-web-push-messages) associated with the token.
+
### Request
```json
@@ -103,6 +105,8 @@ The response will include a `Set-Cookie` header that clears the `identity` cooki
Changes the current user's password, and invalidates all outstanding identity tokens.
+This terminates any [push subscriptions](push.md#receiving-web-push-messages) associated with existing tokens.
+
### Authentication failure
This endpoint will respond with a status of `401 Unauthorized` if the provided identity token is not valid.
diff --git a/docs/api/basics.md b/docs/api/basics.md
index ab39570..180880e 100644
--- a/docs/api/basics.md
+++ b/docs/api/basics.md
@@ -49,3 +49,5 @@ In addition to the documented status codes for each endpoint, any endpoint may r
## Errors
When the server returns an error (any response whose status code is 400 or greater), the response body is freeform text (specifically, `text/plain`), which may be shown to the user, logged, or otherwise handled. Programmatic action should rely on the documented status codes, and not on the response body.
+
+A small number of endpoints deliver errors in other formats. These exceptions are documented with the endpoints they're relevant to.
diff --git a/docs/api/events.md b/docs/api/events.md
index e692f82..9db4613 100644
--- a/docs/api/events.md
+++ b/docs/api/events.md
@@ -237,3 +237,33 @@ These events have the `event` field set to `"deleted"`. They include the followi
| :---- | :-------- | :---------------------------------- |
| `at` | timestamp | The moment the message was deleted. |
| `id` | string | The deleted message's ID. |
+
+## VAPID key events
+
+The following events describe changes to Pilcrow's [VAPID key](https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#applicationserverkey).
+
+These events have the `type` field set to `"vapid"`.
+
+### VAPID key changed
+
+```json
+{
+ "type": "vapid",
+ "event": "changed",
+ "at": "2025-08-30T05:44:58.100206Z",
+ "key": "BKILjh0SzTiHOZ5P_sfduv-iqtOg_S18nR7ePcjnDivJaOY6nOG1L3OmvjXjNnthlRDdVnawl1_owfdPCvmDt5U="
+}
+```
+
+Sent whenever the server's VAPID key changes.
+
+These events have the `event` field set to `"changed"`. They include the following additional fields:
+
+| Field | Type | Description |
+| :---- | :-------- | :----------------------------------------------------------------------------------------------- |
+| `at` | timestamp | The moment the key was changed. |
+| `key` | string | A URL-safe Base64 encoding of the VAPID public key, usable to create new Web Push subscriptions. |
+
+The server may change its VAPID key at any time, and will do so periodically to manage the risk of the private key being leaked. When the key is changed, old keys are immediately destroyed, and the corresponding change events are removed from the event stream.
+
+Clients must use the most recent VAPID key when creating Web Push subscriptions. If the key changes, clients must invalidate or recreate existing subscriptions - the previous key is no longer valid and will no longer be used, and push subscriptions using that key will not be fulfilled.
diff --git a/docs/api/push.md b/docs/api/push.md
new file mode 100644
index 0000000..363bff3
--- /dev/null
+++ b/docs/api/push.md
@@ -0,0 +1,124 @@
+# Push Notifications
+
+```mermaid
+---
+Push lifecycle
+---
+sequenceDiagram
+ actor Andrea
+ participant API
+ participant Broker
+
+ Note over Andrea, Broker : Creating a push subscription
+
+ Andrea ->>+ API : Client Boot
+ API ->>- Andrea : Boot message with VAPID keys
+
+ Andrea ->>+ API : Subscribe to events
+
+ Andrea ->>+ Broker : New Push Subscription
+ Broker ->>- Andrea : Subscription URL
+
+ Andrea ->>+ API : Subscribe
+ API ->>- Andrea : Created
+
+ API ->>- Andrea : Disconnect
+
+ Note over Andrea, Broker : Asynchronous notification
+
+ API ->> Broker : Push Publication
+ Broker ->> Andrea : Push Delivery
+
+ Andrea ->>+ API : Resume event subscription
+ API ->>- Andrea : Disconnect
+
+ Note over Andrea, Broker : VAPID key rotation
+
+ Andrea ->>+ API : Subscribe to events
+ API -) Andrea : VAPID key changed
+
+ Andrea ->>+ Broker : Unsubscribe
+ Broker ->>- Andrea : Unsubscribed
+ Andrea ->>+ Broker : New Push Subscription
+ Broker ->>- Andrea : Subscription URL
+
+ Andrea ->>+ API : Subscribe
+ API ->>- Andrea : Created
+
+ API ->>- Andrea : Disconnect
+```
+
+Pilcrow uses [Web Push] to notify clients asynchronously of interesting events, to allow clients to disconnect from [the event stream](events.md) while keeping their users informed of updates to conversations. Clients are _not required_ to implement push subscriptions. Pilcrow's primarily channel for communicating real-time communications data to clients is the [event stream](events.md).
+
+[Web Push]: https://developer.mozilla.org/en-US/docs/Web/API/Push_API
+
+## VAPID keys
+
+Pilcrow uses a generated Voluntary Application Server Identification (VAPID) key to authenticate to push brokers. Clients do not generally need to handle VAPID authentication themselves, but, due to the design of the Web Push protocol, clients are responsible for providing Pilcrow's VAPID key to push brokers when subscribing to notifications. To make this possible, Pilcrow delivers its VAPID key to clients via the event stream. See the [VAPID key events](events.md#vapid-key-events) section for details of these events.
+
+Pilcrow rotates the VAPID key periodically, and sends all clients an event when this occurs. This immediately invalidates all existing subscriptions, as the previous VAPID key is destroyed. Clients that wish to continue receiving messages must re-subscribe using the new VAPID key when this happens, and will miss any Web Push messages sent during this interval.
+
+## Receiving Web Push messages
+
+Pilcrow sends web push messages to subscriptions. A Pilcrow user may have many subscriptions - in principle, generally one per client, but Pilcrow does not enforce any such limit. Clients create subscriptions as needed, and inform the Pilcrow server of those subscriptions to begin the flow of push messages.
+
+The specific taxonomy and structure of push messages is still under development. This API is even more experimental than the rest of Pilcrow.
+
+Pilcrow keeps track of the token used to create a web push subscription, and abandons subscriptions when that token is invalidated. This prevents the server from inadvertently sending information to a client after the user for which that information is intended has logged out, or after another user has taken over the client. Clients are responsible for re-establishing subscriptions on login if they wish to resume push messaging.
+
+## `POST /api/push/subscribe`
+
+Inform the Pilcrow server of a push subscription.
+
+### Request
+
+```json
+{
+ "subscription": {
+ "endpoint": "https://example.push.com/P1234",
+ "keys": {
+ "p256dh": "base64-encoded key",
+ "auth": "base64-encoded authentication secret"
+ }
+ }
+ "vapid": "BKILjh0SzTiHOZ5P_sfduv-iqtOg_S18nR7ePcjnDivJaOY6nOG1L3OmvjXjNnthlRDdVnawl1_owfdPCvmDt5U="
+}
+```
+
+The request must have the following fields:
+
+| Field | Type | Description |
+| :------------- | :----- | :------------------------------------------------------ |
+| `subscription` | object | The push subscription object created by the user agent. |
+| `vapid` | string | The VAPID key used to create the subscription. |
+
+The `subscription` field should be the result of calling `toJSON()` on a `PushSubscription` object in the DOM API. For details, see [the WebPush specification](https://w3c.github.io/push-api/#dom-pushsubscription-tojson) or [MDN](https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/toJSON).
+
+The `vapid` field should be set to the key used when creating the subscription, which should in turn be obtained from the event stream.
+
+[wp-example]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription#sending_coding_information_to_the_server
+
+This request can be retried on any non-400 response, including any network-related errors. A duplicate subscription with the same endpoint, the same public key and the same authentication secret will be accepted by the server even if the subscription has already been created.
+
+### Success
+
+This endpoint will respond with a status of `201 Created` when successful. The response body is empty.
+
+### Duplicate endpoint
+
+If the push subscription's endpoint URL is already associated with a subscription with different keys, then Pilcrow will return a `409 Conflict` response.
+
+### Stale VAPID key
+
+If the provided `vapid` key is not the server's current VAPID key, then the client has created a subscription which is not usable. This may happen if Pilcrow rotates its key while a client is negotiating with a Web Push broker to set up a subscription using the old key. Pilcrow will respond a `400 Bad Request` response.
+
+**This response includes a JSON body**, unlike other errors, and will have a `content-type: application/json` header.
+
+The response will include the following fields:
+
+| Field | Type | Description |
+| :-------- | :----- | :------------------------------ |
+| `message` | string | A human-readable error message. |
+| `key` | string | Pilcrow's new VAPID key. |
+
+Clients should immediately destroy the push subscription they were attempting to create, and may try again using the new VAPID key (either immediately, using the key from the response, or asynchronously, using the key when it is delivered via the event stream).
diff --git a/docs/ops.md b/docs/ops.md
index a622c04..678d2a4 100644
--- a/docs/ops.md
+++ b/docs/ops.md
@@ -21,3 +21,17 @@ By default, the `pilcrow` command will set the process' umask to a value that pr
- any octal value corresponding to a valid umask, such as `0027`.
Pilcrow does not check or change the permissions of the database after creation. Changing the umask of the server after the database has been created has no effect on the database's filesystem permissions.
+
+## VAPID keys
+
+Pilcrow uses [VAPID] to identify itself to public Web Push brokers, which then deliver notifications to Pilcrow's users of interesting events, such as messages. VAPID uses cryptographic signatures to authenticate the server.
+
+[VAPID]: https://datatracker.ietf.org/doc/html/rfc8292
+
+The key is stored in the `pilcrow` database. Pilcrow will create its key automatically, and will rotate the key every 30 days.
+
+If the `pilcrow` database is accessed inappropriately or leaked, then the key can be used to send push notifications to Pilcrow users as if from the Pilcrow server. If this happens, the key _must_ be rotated to prevent misuse.
+
+The key can be rotated at any time running `pilcrow […options…] rotate-vapid-key`, as the same user Pilcrow normally runs as. This does not require that the server be shut down or restarted. The `[…options…]` must be set to the same values as used by the running server.
+
+When the key is rotated, no further push messages will be sent from the Pilcrow server using that key. Unfortunately, the Web Push protocol doesn't allow Pilcrow to proactively invalidate clients' push subscriptions, but Pilcrow will inform clients when the key is rotated so that they can invalidate the affected subscriptions themselves.
diff --git a/migrations/20250829220738_vapid_keys.sql b/migrations/20250829220738_vapid_keys.sql
new file mode 100644
index 0000000..64f4295
--- /dev/null
+++ b/migrations/20250829220738_vapid_keys.sql
@@ -0,0 +1,17 @@
+create table vapid_signing_key (
+ key blob
+ not null
+);
+
+create unique index vapid_signing_key_singleton
+ on vapid_signing_key (0);
+
+create table vapid_key (
+ changed_at text
+ not null,
+ changed_sequence bigint
+ not null
+);
+
+create unique index vapid_key_singleton
+ on vapid_key (0);
diff --git a/migrations/20251009021241_push_subscriptions.sql b/migrations/20251009021241_push_subscriptions.sql
new file mode 100644
index 0000000..b42d122
--- /dev/null
+++ b/migrations/20251009021241_push_subscriptions.sql
@@ -0,0 +1,12 @@
+create table push_subscription (
+ endpoint text
+ primary key
+ not null,
+ token text
+ not null
+ references token (id),
+ p256dh text
+ not null,
+ auth text
+ not null
+);
diff --git a/migrations/20251028173914_pem_vapid_keys.sql b/migrations/20251028173914_pem_vapid_keys.sql
new file mode 100644
index 0000000..6302504
--- /dev/null
+++ b/migrations/20251028173914_pem_vapid_keys.sql
@@ -0,0 +1,15 @@
+drop table vapid_signing_key;
+
+create table vapid_signing_key (
+ key text
+ not null
+);
+
+create unique index vapid_signing_key_singleton
+ on vapid_signing_key (0);
+
+-- Whatever key we had, if any, was just destroyed by dropping the table. Delete the metadata
+-- as well so that the server will issue a new one.
+delete
+from
+ vapid_key;
diff --git a/src/app.rs b/src/app.rs
index ad19bc0..098ae9f 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -10,30 +10,34 @@ use crate::{
invite::app::Invites,
login::app::Logins,
message::app::Messages,
+ push::app::Push,
setup::app::Setup,
token::{self, app::Tokens},
+ vapid::app::Vapid,
};
#[derive(Clone)]
-pub struct App {
+pub struct App<P> {
db: SqlitePool,
+ webpush: P,
events: event::Broadcaster,
token_events: token::Broadcaster,
}
-impl App {
- pub fn from(db: SqlitePool) -> Self {
+impl<P> App<P> {
+ pub fn from(db: SqlitePool, webpush: P) -> Self {
let events = event::Broadcaster::default();
let token_events = token::Broadcaster::default();
Self {
db,
+ webpush,
events,
token_events,
}
}
}
-impl App {
+impl<P> App<P> {
pub fn boot(&self) -> Boot {
Boot::new(self.db.clone())
}
@@ -58,6 +62,13 @@ impl App {
Messages::new(self.db.clone(), self.events.clone())
}
+ pub fn push(&self) -> Push<P>
+ where
+ P: Clone,
+ {
+ Push::new(self.db.clone(), self.webpush.clone())
+ }
+
pub fn setup(&self) -> Setup {
Setup::new(self.db.clone(), self.events.clone())
}
@@ -70,46 +81,70 @@ impl App {
pub fn users(&self) -> Users {
Users::new(self.db.clone(), self.events.clone())
}
+
+ pub fn vapid(&self) -> Vapid {
+ Vapid::new(self.db.clone(), self.events.clone())
+ }
+
+ #[cfg(test)]
+ pub fn webpush(&self) -> &P {
+ &self.webpush
+ }
}
-impl FromRef<App> for Boot {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Boot {
+ fn from_ref(app: &App<P>) -> Self {
app.boot()
}
}
-impl FromRef<App> for Conversations {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Conversations {
+ fn from_ref(app: &App<P>) -> Self {
app.conversations()
}
}
-impl FromRef<App> for Invites {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Invites {
+ fn from_ref(app: &App<P>) -> Self {
app.invites()
}
}
-impl FromRef<App> for Logins {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Logins {
+ fn from_ref(app: &App<P>) -> Self {
app.logins()
}
}
-impl FromRef<App> for Messages {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Messages {
+ fn from_ref(app: &App<P>) -> Self {
app.messages()
}
}
-impl FromRef<App> for Setup {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Push<P>
+where
+ P: Clone,
+{
+ fn from_ref(app: &App<P>) -> Self {
+ app.push()
+ }
+}
+
+impl<P> FromRef<App<P>> for Setup {
+ fn from_ref(app: &App<P>) -> Self {
app.setup()
}
}
-impl FromRef<App> for Tokens {
- fn from_ref(app: &App) -> Self {
+impl<P> FromRef<App<P>> for Tokens {
+ fn from_ref(app: &App<P>) -> Self {
app.tokens()
}
}
+
+impl<P> FromRef<App<P>> for Vapid {
+ fn from_ref(app: &App<P>) -> Self {
+ app.vapid()
+ }
+}
diff --git a/src/boot/app.rs b/src/boot/app.rs
index 840243e..1ca8adb 100644
--- a/src/boot/app.rs
+++ b/src/boot/app.rs
@@ -4,10 +4,12 @@ use sqlx::sqlite::SqlitePool;
use super::Snapshot;
use crate::{
conversation::{self, repo::Provider as _},
+ db::NotFound,
event::{Event, Sequence, repo::Provider as _},
message::{self, repo::Provider as _},
name,
user::{self, repo::Provider as _},
+ vapid::{self, repo::Provider as _},
};
pub struct Boot {
@@ -26,6 +28,7 @@ impl Boot {
let users = tx.users().all(resume_point).await?;
let conversations = tx.conversations().all(resume_point).await?;
let messages = tx.messages().all(resume_point).await?;
+ let vapid = tx.vapid().current().await.optional()?;
tx.commit().await?;
@@ -50,9 +53,16 @@ impl Boot {
.filter(Sequence::up_to(resume_point))
.map(Event::from);
+ let vapid_events = vapid
+ .iter()
+ .flat_map(vapid::History::events)
+ .filter(Sequence::up_to(resume_point))
+ .map(Event::from);
+
let events = user_events
.merge_by(conversation_events, Sequence::merge)
.merge_by(message_events, Sequence::merge)
+ .merge_by(vapid_events, Sequence::merge)
.collect();
Ok(Snapshot {
@@ -65,8 +75,11 @@ impl Boot {
#[derive(Debug, thiserror::Error)]
#[error(transparent)]
pub enum Error {
- Name(#[from] name::Error),
Database(#[from] sqlx::Error),
+ Name(#[from] name::Error),
+ Ecdsa(#[from] p256::ecdsa::Error),
+ Pkcs8(#[from] p256::pkcs8::Error),
+ WebPush(#[from] web_push::WebPushError),
}
impl From<user::repo::LoadError> for Error {
@@ -88,3 +101,15 @@ impl From<conversation::repo::LoadError> for Error {
}
}
}
+
+impl From<vapid::repo::Error> for Error {
+ fn from(error: vapid::repo::Error) -> Self {
+ use vapid::repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ Error::Pkcs8(error) => error.into(),
+ Error::WebPush(error) => error.into(),
+ }
+ }
+}
diff --git a/src/boot/handlers/boot/test.rs b/src/boot/handlers/boot/test.rs
index a9891eb..f192478 100644
--- a/src/boot/handlers/boot/test.rs
+++ b/src/boot/handlers/boot/test.rs
@@ -81,6 +81,74 @@ async fn includes_messages() {
}
#[tokio::test]
+async fn includes_vapid_key() {
+ let app = fixtures::scratch_app().await;
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("key rotation always succeeds");
+
+ let viewer = fixtures::identity::fictitious();
+ let response = super::handler(State(app.boot()), viewer)
+ .await
+ .expect("boot always succeeds");
+
+ response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key has been created");
+}
+
+#[tokio::test]
+async fn includes_only_latest_vapid_key() {
+ let app = fixtures::scratch_app().await;
+
+ app.vapid()
+ .refresh_key(&fixtures::ancient())
+ .await
+ .expect("key rotation always succeeds");
+
+ let viewer = fixtures::identity::fictitious();
+ let response = super::handler(State(app.boot()), viewer.clone())
+ .await
+ .expect("boot always succeeds");
+
+ let original_key = response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key has been created");
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("key rotation always succeeds");
+
+ let response = super::handler(State(app.boot()), viewer)
+ .await
+ .expect("boot always succeeds");
+
+ let rotated_key = response
+ .snapshot
+ .events
+ .into_iter()
+ .filter_map(fixtures::event::vapid)
+ .filter_map(fixtures::event::vapid::changed)
+ .exactly_one()
+ .expect("only one vapid key should be returned");
+
+ assert_ne!(original_key, rotated_key);
+}
+
+#[tokio::test]
async fn includes_expired_messages() {
let app = fixtures::scratch_app().await;
let sender = fixtures::user::create(&app, &fixtures::ancient()).await;
diff --git a/src/cli.rs b/src/cli.rs
index 378686b..154771b 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -10,9 +10,10 @@ use axum::{
middleware,
response::{IntoResponse, Response},
};
-use clap::{CommandFactory, Parser};
+use clap::{CommandFactory, Parser, Subcommand};
use sqlx::sqlite::SqlitePool;
use tokio::net;
+use web_push::{IsahcWebPushClient, WebPushClient};
use crate::{
app::App,
@@ -65,6 +66,15 @@ pub struct Args {
/// upgrades
#[arg(short = 'D', long, env, default_value = "sqlite://pilcrow.db.backup")]
backup_database_url: String,
+
+ #[command(subcommand)]
+ command: Option<Command>,
+}
+
+#[derive(Subcommand)]
+enum Command {
+ /// Immediately rotate the server's VAPID (Web Push) application key.
+ RotateVapidKey,
}
impl Args {
@@ -88,7 +98,21 @@ impl Args {
self.umask.set();
let pool = self.pool().await?;
- let app = App::from(pool);
+ let webpush = IsahcWebPushClient::new()?;
+ let app = App::from(pool, webpush);
+
+ match self.command {
+ None => self.serve(app).await?,
+ Some(Command::RotateVapidKey) => app.vapid().rotate_key().await?,
+ }
+
+ Result::<_, Error>::Ok(())
+ }
+
+ async fn serve<P>(self, app: App<P>) -> Result<(), Error>
+ where
+ P: WebPushClient + Clone + Send + Sync + 'static,
+ {
let app = routes::routes(&app)
.route_layer(middleware::from_fn(clock::middleware))
.route_layer(middleware::map_response(Self::server_info()))
@@ -101,7 +125,7 @@ impl Args {
println!("{started_msg}");
serve.await?;
- Result::<_, Error>::Ok(())
+ Ok(())
}
async fn listener(&self) -> io::Result<net::TcpListener> {
@@ -140,5 +164,7 @@ fn started_msg(listener: &net::TcpListener) -> io::Result<String> {
enum Error {
Io(#[from] io::Error),
Database(#[from] db::Error),
+ Sqlx(#[from] sqlx::Error),
Umask(#[from] umask::Error),
+ Webpush(#[from] web_push::WebPushError),
}
diff --git a/src/event/app.rs b/src/event/app.rs
index 8fa760a..e422de9 100644
--- a/src/event/app.rs
+++ b/src/event/app.rs
@@ -8,9 +8,12 @@ use sqlx::sqlite::SqlitePool;
use super::{Event, Sequence, Sequenced, broadcaster::Broadcaster};
use crate::{
conversation::{self, repo::Provider as _},
+ db::NotFound,
message::{self, repo::Provider as _},
name,
user::{self, repo::Provider as _},
+ vapid,
+ vapid::repo::Provider as _,
};
pub struct Events {
@@ -57,9 +60,17 @@ impl Events {
.filter(Sequence::after(resume_at))
.map(Event::from);
+ let vapid = tx.vapid().current().await.optional()?;
+ let vapid_events = vapid
+ .iter()
+ .flat_map(vapid::History::events)
+ .filter(Sequence::after(resume_at))
+ .map(Event::from);
+
let replay_events = user_events
.merge_by(conversation_events, Sequence::merge)
.merge_by(message_events, Sequence::merge)
+ .merge_by(vapid_events, Sequence::merge)
.collect::<Vec<_>>();
let resume_live_at = replay_events.last().map_or(resume_at, Sequenced::sequence);
@@ -86,6 +97,9 @@ impl Events {
pub enum Error {
Database(#[from] sqlx::Error),
Name(#[from] name::Error),
+ Ecdsa(#[from] p256::ecdsa::Error),
+ Pkcs8(#[from] p256::pkcs8::Error),
+ WebPush(#[from] web_push::WebPushError),
}
impl From<user::repo::LoadError> for Error {
@@ -107,3 +121,15 @@ impl From<conversation::repo::LoadError> for Error {
}
}
}
+
+impl From<vapid::repo::Error> for Error {
+ fn from(error: vapid::repo::Error) -> Self {
+ use vapid::repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ Error::Pkcs8(error) => error.into(),
+ Error::WebPush(error) => error.into(),
+ }
+ }
+}
diff --git a/src/event/handlers/stream/mod.rs b/src/event/handlers/stream/mod.rs
index 63bfff3..8b89c31 100644
--- a/src/event/handlers/stream/mod.rs
+++ b/src/event/handlers/stream/mod.rs
@@ -18,8 +18,8 @@ use crate::{
#[cfg(test)]
mod test;
-pub async fn handler(
- State(app): State<App>,
+pub async fn handler<P>(
+ State(app): State<App<P>>,
identity: Identity,
last_event_id: Option<LastEventId<Sequence>>,
Query(query): Query<QueryParams>,
diff --git a/src/event/handlers/stream/test/mod.rs b/src/event/handlers/stream/test/mod.rs
index 3bc634f..c3a6ce6 100644
--- a/src/event/handlers/stream/test/mod.rs
+++ b/src/event/handlers/stream/test/mod.rs
@@ -4,5 +4,6 @@ mod message;
mod resume;
mod setup;
mod token;
+mod vapid;
use super::{QueryParams, Response, handler};
diff --git a/src/event/handlers/stream/test/vapid.rs b/src/event/handlers/stream/test/vapid.rs
new file mode 100644
index 0000000..dbc3929
--- /dev/null
+++ b/src/event/handlers/stream/test/vapid.rs
@@ -0,0 +1,111 @@
+use axum::extract::State;
+use axum_extra::extract::Query;
+use futures::StreamExt as _;
+
+use crate::test::{fixtures, fixtures::future::Expect as _};
+
+#[tokio::test]
+async fn live_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ // Subscribe to events
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_some("a vapid key change event is sent")
+ .await;
+}
+
+#[tokio::test]
+async fn stored_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Subscribe to events
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_some("a vapid key change event is sent")
+ .await;
+}
+
+#[tokio::test]
+async fn no_past_vapid_key_changes() {
+ // Set up the context
+ let app = fixtures::scratch_app().await;
+
+ // Rotate the VAPID key
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the vapid key always succeeds");
+
+ // Subscribe to events
+
+ let resume_point = fixtures::boot::resume_point(&app).await;
+
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+ let super::Response(events) = super::handler(
+ State(app.clone()),
+ subscriber,
+ None,
+ Query(super::QueryParams { resume_point }),
+ )
+ .await
+ .expect("subscribe never fails");
+
+ // Verify that there's a key rotation event
+
+ events
+ .filter_map(fixtures::event::stream::vapid)
+ .filter_map(fixtures::event::stream::vapid::changed)
+ .next()
+ .expect_wait("a vapid key change event is not sent")
+ .await;
+}
diff --git a/src/event/mod.rs b/src/event/mod.rs
index f41dc9c..83b0ce7 100644
--- a/src/event/mod.rs
+++ b/src/event/mod.rs
@@ -2,7 +2,7 @@ use std::time::Duration;
use axum::response::sse::{self, KeepAlive};
-use crate::{conversation, message, user};
+use crate::{conversation, message, user, vapid};
pub mod app;
mod broadcaster;
@@ -22,6 +22,7 @@ pub enum Event {
User(user::Event),
Conversation(conversation::Event),
Message(message::Event),
+ Vapid(vapid::Event),
}
// Serialized representation is intended to look like the serialized representation of `Event`,
@@ -40,6 +41,7 @@ impl Sequenced for Event {
Self::User(event) => event.instant(),
Self::Conversation(event) => event.instant(),
Self::Message(event) => event.instant(),
+ Self::Vapid(event) => event.instant(),
}
}
}
@@ -62,6 +64,12 @@ impl From<message::Event> for Event {
}
}
+impl From<vapid::Event> for Event {
+ fn from(event: vapid::Event) -> Self {
+ Self::Vapid(event)
+ }
+}
+
impl Heartbeat {
// The following values are a first-rough-guess attempt to balance noticing connection problems
// quickly with managing the (modest) costs of delivering and processing heartbeats. Feel
diff --git a/src/expire.rs b/src/expire.rs
index 4177a53..c3b0117 100644
--- a/src/expire.rs
+++ b/src/expire.rs
@@ -7,8 +7,8 @@ use axum::{
use crate::{app::App, clock::RequestedAt, error::Internal};
// Expires messages and conversations before each request.
-pub async fn middleware(
- State(app): State<App>,
+pub async fn middleware<P>(
+ State(app): State<App<P>>,
RequestedAt(expired_at): RequestedAt,
req: Request,
next: Next,
diff --git a/src/lib.rs b/src/lib.rs
index f05cce3..38e6bc5 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -20,6 +20,7 @@ mod message;
mod name;
mod normalize;
mod password;
+mod push;
mod routes;
mod setup;
#[cfg(test)]
@@ -28,3 +29,4 @@ mod token;
mod ui;
mod umask;
mod user;
+mod vapid;
diff --git a/src/login/app.rs b/src/login/app.rs
index a2f9636..8cc8cd0 100644
--- a/src/login/app.rs
+++ b/src/login/app.rs
@@ -6,6 +6,7 @@ use crate::{
login::{self, Login, repo::Provider as _},
name::{self, Name},
password::Password,
+ push::repo::Provider as _,
token::{Broadcaster, Event as TokenEvent, Secret, Token, repo::Provider as _},
};
@@ -76,6 +77,7 @@ impl Logins {
let mut tx = self.db.begin().await?;
tx.logins().set_password(&login, &to_hash).await?;
+ tx.push().unsubscribe_login(&login).await?;
let revoked = tx.tokens().revoke_all(&login).await?;
tx.tokens().create(&token, &secret).await?;
tx.commit().await?;
diff --git a/src/push/app.rs b/src/push/app.rs
new file mode 100644
index 0000000..56b9a02
--- /dev/null
+++ b/src/push/app.rs
@@ -0,0 +1,178 @@
+use futures::future::join_all;
+use itertools::Itertools as _;
+use p256::ecdsa::VerifyingKey;
+use sqlx::SqlitePool;
+use web_push::{
+ ContentEncoding, PartialVapidSignatureBuilder, SubscriptionInfo, WebPushClient, WebPushError,
+ WebPushMessage, WebPushMessageBuilder,
+};
+
+use super::repo::Provider as _;
+use crate::{login::Login, token::extract::Identity, vapid, vapid::repo::Provider as _};
+
+pub struct Push<P> {
+ db: SqlitePool,
+ webpush: P,
+}
+
+impl<P> Push<P> {
+ pub const fn new(db: SqlitePool, webpush: P) -> Self {
+ Self { db, webpush }
+ }
+
+ pub async fn subscribe(
+ &self,
+ subscriber: &Identity,
+ subscription: &SubscriptionInfo,
+ vapid: &VerifyingKey,
+ ) -> Result<(), SubscribeError> {
+ let mut tx = self.db.begin().await?;
+
+ let current = tx.vapid().current().await?;
+ if vapid != &current.key {
+ return Err(SubscribeError::StaleVapidKey(current.key));
+ }
+
+ match tx.push().create(&subscriber.token, subscription).await {
+ Ok(()) => (),
+ Err(err) => {
+ if let Some(err) = err.as_database_error()
+ && err.is_unique_violation()
+ {
+ let current = tx
+ .push()
+ .by_endpoint(&subscriber.login, &subscription.endpoint)
+ .await?;
+ // If we already have a subscription for this endpoint, with _different_
+ // parameters, then this is a client error. They shouldn't reuse endpoint URLs,
+ // per the various RFCs.
+ //
+ // However, if we have a subscription for this endpoint with the same parameters
+ // then we accept it and silently do nothing. This may happen if, for example,
+ // the subscribe request is retried due to a network interruption where it's
+ // not clear whether the original request succeeded.
+ if &current != subscription {
+ return Err(SubscribeError::Duplicate);
+ }
+ } else {
+ return Err(SubscribeError::Database(err));
+ }
+ }
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+}
+
+impl<P> Push<P>
+where
+ P: WebPushClient,
+{
+ fn prepare_ping(
+ signer: &PartialVapidSignatureBuilder,
+ subscription: &SubscriptionInfo,
+ ) -> Result<WebPushMessage, WebPushError> {
+ let signature = signer.clone().add_sub_info(subscription).build()?;
+
+ let payload = "ping".as_bytes();
+
+ let mut message = WebPushMessageBuilder::new(subscription);
+ message.set_payload(ContentEncoding::Aes128Gcm, payload);
+ message.set_vapid_signature(signature);
+ let message = message.build()?;
+
+ Ok(message)
+ }
+
+ pub async fn ping(&self, recipient: &Login) -> Result<(), PushError> {
+ let mut tx = self.db.begin().await?;
+
+ let signer = tx.vapid().signer().await?;
+ let subscriptions = tx.push().by_login(recipient).await?;
+
+ let pings: Vec<_> = subscriptions
+ .into_iter()
+ .map(|sub| Self::prepare_ping(&signer, &sub).map(|message| (sub, message)))
+ .try_collect()?;
+
+ let deliveries = pings
+ .into_iter()
+ .map(async |(sub, message)| (sub, self.webpush.send(message).await));
+
+ let failures: Vec<_> = join_all(deliveries)
+ .await
+ .into_iter()
+ .filter_map(|(sub, result)| result.err().map(|err| (sub, err)))
+ .collect();
+
+ if !failures.is_empty() {
+ for (sub, err) in &failures {
+ match err {
+ // I _think_ this is the complete set of permanent failures. See
+ // <https://docs.rs/web-push/latest/web_push/enum.WebPushError.html> for a complete
+ // list.
+ WebPushError::Unauthorized(_)
+ | WebPushError::InvalidUri
+ | WebPushError::EndpointNotValid(_)
+ | WebPushError::EndpointNotFound(_)
+ | WebPushError::InvalidCryptoKeys
+ | WebPushError::MissingCryptoKeys => {
+ tx.push().unsubscribe(sub).await?;
+ }
+ _ => (),
+ }
+ }
+
+ return Err(PushError::Delivery(
+ failures.into_iter().map(|(_, err)| err).collect(),
+ ));
+ }
+
+ tx.commit().await?;
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum SubscribeError {
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Vapid(#[from] vapid::repo::Error),
+ #[error("subscription created with stale VAPID key")]
+ StaleVapidKey(VerifyingKey),
+ #[error("subscription already exists for endpoint")]
+ // The endpoint URL is not included in the error, as it is a bearer credential in its own right
+ // and we want to limit its proliferation. The only intended recipient of this message is the
+ // client, which already knows the endpoint anyways and doesn't need us to tell them.
+ Duplicate,
+}
+
+#[derive(Debug, thiserror::Error)]
+pub enum PushError {
+ #[error(transparent)]
+ Database(#[from] sqlx::Error),
+ #[error(transparent)]
+ Ecdsa(#[from] p256::ecdsa::Error),
+ #[error(transparent)]
+ Pkcs8(#[from] p256::pkcs8::Error),
+ #[error(transparent)]
+ WebPush(#[from] WebPushError),
+ #[error("push message delivery failures: {0:?}")]
+ Delivery(Vec<WebPushError>),
+}
+
+impl From<vapid::repo::Error> for PushError {
+ fn from(error: vapid::repo::Error) -> Self {
+ use vapid::repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ Error::Pkcs8(error) => error.into(),
+ Error::WebPush(error) => error.into(),
+ }
+ }
+}
diff --git a/src/push/handlers/mod.rs b/src/push/handlers/mod.rs
new file mode 100644
index 0000000..bb58774
--- /dev/null
+++ b/src/push/handlers/mod.rs
@@ -0,0 +1,5 @@
+mod ping;
+mod subscribe;
+
+pub use ping::handler as ping;
+pub use subscribe::handler as subscribe;
diff --git a/src/push/handlers/ping/mod.rs b/src/push/handlers/ping/mod.rs
new file mode 100644
index 0000000..db828fa
--- /dev/null
+++ b/src/push/handlers/ping/mod.rs
@@ -0,0 +1,23 @@
+use axum::{Json, extract::State, http::StatusCode};
+use web_push::WebPushClient;
+
+use crate::{error::Internal, push::app::Push, token::extract::Identity};
+
+#[cfg(test)]
+mod test;
+
+#[derive(serde::Deserialize)]
+pub struct Request {}
+
+pub async fn handler<P>(
+ State(push): State<Push<P>>,
+ identity: Identity,
+ Json(_): Json<Request>,
+) -> Result<StatusCode, Internal>
+where
+ P: WebPushClient,
+{
+ push.ping(&identity.login).await?;
+
+ Ok(StatusCode::ACCEPTED)
+}
diff --git a/src/push/handlers/ping/test.rs b/src/push/handlers/ping/test.rs
new file mode 100644
index 0000000..5725131
--- /dev/null
+++ b/src/push/handlers/ping/test.rs
@@ -0,0 +1,40 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+};
+
+use crate::test::fixtures;
+
+#[tokio::test]
+async fn ping_without_subscriptions() {
+ let app = fixtures::scratch_app().await;
+
+ let recipient = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ let response = super::handler(State(app.push()), recipient, Json(super::Request {}))
+ .await
+ .expect("sending a ping with no subscriptions always succeeds");
+
+ assert_eq!(StatusCode::ACCEPTED, response);
+
+ assert!(app.webpush().sent().is_empty());
+}
+
+// More complete testing requires that we figure out how to generate working p256 ECDH keys for
+// testing _with_, as `web_push` will actually parse and use those keys even if push messages are
+// ultimately never serialized or sent over HTTP.
+//
+// Tests that are missing:
+//
+// * Verify that subscribing and sending a ping causes a ping to be delivered to that subscription.
+// * Verify that two subscriptions both get pings.
+// * Verify that other users' subscriptions are not pinged.
+// * Verify that a ping that causes a permanent error causes the subscription to be deleted.
+// * Verify that a ping that causes a non-permanent error does not cause the subscription to be
+// deleted.
+// * Verify that a failure on one subscription doesn't affect delivery on other subscriptions.
diff --git a/src/push/handlers/subscribe/mod.rs b/src/push/handlers/subscribe/mod.rs
new file mode 100644
index 0000000..a1a5899
--- /dev/null
+++ b/src/push/handlers/subscribe/mod.rs
@@ -0,0 +1,94 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+ response::{IntoResponse, Response},
+};
+use p256::ecdsa::VerifyingKey;
+use web_push::SubscriptionInfo;
+
+use crate::{
+ error::Internal,
+ push::{app, app::Push},
+ token::extract::Identity,
+};
+
+#[cfg(test)]
+mod test;
+
+#[derive(Clone, serde::Deserialize)]
+pub struct Request {
+ subscription: Subscription,
+ #[serde(with = "crate::vapid::ser::key")]
+ vapid: VerifyingKey,
+}
+
+// This structure is described in <https://w3c.github.io/push-api/#dom-pushsubscription-tojson>.
+#[derive(Clone, serde::Deserialize)]
+pub struct Subscription {
+ endpoint: String,
+ keys: Keys,
+}
+
+// This structure is described in <https://w3c.github.io/push-api/#dom-pushsubscription-tojson>.
+#[derive(Clone, serde::Deserialize)]
+pub struct Keys {
+ p256dh: String,
+ auth: String,
+}
+
+pub async fn handler<P>(
+ State(push): State<Push<P>>,
+ identity: Identity,
+ Json(request): Json<Request>,
+) -> Result<StatusCode, Error> {
+ let Request {
+ subscription,
+ vapid,
+ } = request;
+
+ push.subscribe(&identity, &subscription.into(), &vapid)
+ .await?;
+
+ Ok(StatusCode::CREATED)
+}
+
+impl From<Subscription> for SubscriptionInfo {
+ fn from(request: Subscription) -> Self {
+ let Subscription {
+ endpoint,
+ keys: Keys { p256dh, auth },
+ } = request;
+ SubscriptionInfo::new(endpoint, p256dh, auth)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub struct Error(#[from] app::SubscribeError);
+
+impl IntoResponse for Error {
+ fn into_response(self) -> Response {
+ let Self(err) = self;
+
+ match err {
+ app::SubscribeError::StaleVapidKey(key) => {
+ let body = StaleVapidKey {
+ message: err.to_string(),
+ key,
+ };
+ (StatusCode::BAD_REQUEST, Json(body)).into_response()
+ }
+ app::SubscribeError::Duplicate => {
+ (StatusCode::CONFLICT, err.to_string()).into_response()
+ }
+ other => Internal::from(other).into_response(),
+ }
+ }
+}
+
+#[derive(serde::Serialize)]
+struct StaleVapidKey {
+ message: String,
+ #[serde(with = "crate::vapid::ser::key")]
+ key: VerifyingKey,
+}
diff --git a/src/push/handlers/subscribe/test.rs b/src/push/handlers/subscribe/test.rs
new file mode 100644
index 0000000..b72624d
--- /dev/null
+++ b/src/push/handlers/subscribe/test.rs
@@ -0,0 +1,236 @@
+use axum::{
+ extract::{Json, State},
+ http::StatusCode,
+};
+
+use crate::{
+ push::app::SubscribeError,
+ test::{fixtures, fixtures::event},
+};
+
+#[tokio::test]
+async fn accepts_new_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Check that the response looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+}
+
+#[tokio::test]
+async fn accepts_repeat_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber.clone(), Json(request.clone()))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Check that the response looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+
+ // Repeat the request
+
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect("test request will succeed twice on a fresh app");
+
+ // Check that the second response also looks as expected.
+
+ assert_eq!(StatusCode::CREATED, response);
+}
+
+#[tokio::test]
+async fn rejects_duplicate_subscription() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with that key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ super::handler(State(app.push()), subscriber.clone(), Json(request))
+ .await
+ .expect("test request will succeed on a fresh app");
+
+ // Repeat the request with different keys
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("different-test-p256dh-value"),
+ auth: String::from("different-test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect_err("request with duplicate endpoint should fail");
+
+ // Make sure we got the error we expected.
+
+ assert!(matches!(response, super::Error(SubscribeError::Duplicate)));
+}
+
+#[tokio::test]
+async fn rejects_stale_vapid_key() {
+ let app = fixtures::scratch_app().await;
+ let subscriber = fixtures::identity::create(&app, &fixtures::now()).await;
+
+ // Issue a VAPID key.
+
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what that VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Change the VAPID key.
+
+ app.vapid()
+ .rotate_key()
+ .await
+ .expect("key rotation always succeeds");
+ app.vapid()
+ .refresh_key(&fixtures::now())
+ .await
+ .expect("refreshing the VAPID key always succeeds");
+
+ // Find out what the new VAPID key is.
+
+ let boot = app.boot().snapshot().await.expect("boot always succeeds");
+ let fresh_vapid = boot
+ .events
+ .into_iter()
+ .filter_map(event::vapid)
+ .filter_map(event::vapid::changed)
+ .next_back()
+ .expect("the application will have a vapid key after a refresh");
+
+ // Create a dummy subscription with the original key.
+
+ let request = super::Request {
+ subscription: super::Subscription {
+ endpoint: String::from("https://push.example.com/endpoint"),
+ keys: super::Keys {
+ p256dh: String::from("test-p256dh-value"),
+ auth: String::from("test-auth-value"),
+ },
+ },
+ vapid: vapid.key,
+ };
+ let response = super::handler(State(app.push()), subscriber, Json(request))
+ .await
+ .expect_err("test request has a stale vapid key");
+
+ // Check that the response looks as expected.
+
+ assert!(matches!(
+ response,
+ super::Error(SubscribeError::StaleVapidKey(key)) if key == fresh_vapid.key
+ ));
+}
diff --git a/src/push/mod.rs b/src/push/mod.rs
new file mode 100644
index 0000000..1394ea4
--- /dev/null
+++ b/src/push/mod.rs
@@ -0,0 +1,3 @@
+pub mod app;
+pub mod handlers;
+pub mod repo;
diff --git a/src/push/repo.rs b/src/push/repo.rs
new file mode 100644
index 0000000..4183489
--- /dev/null
+++ b/src/push/repo.rs
@@ -0,0 +1,149 @@
+use sqlx::{Sqlite, SqliteConnection, Transaction};
+use web_push::SubscriptionInfo;
+
+use crate::{login::Login, token::Token};
+
+pub trait Provider {
+ fn push(&mut self) -> Push<'_>;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn push(&mut self) -> Push<'_> {
+ Push(self)
+ }
+}
+
+pub struct Push<'t>(&'t mut SqliteConnection);
+
+impl Push<'_> {
+ pub async fn create(
+ &mut self,
+ token: &Token,
+ subscription: &SubscriptionInfo,
+ ) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ insert into push_subscription (token, endpoint, p256dh, auth)
+ values ($1, $2, $3, $4)
+ "#,
+ token.id,
+ subscription.endpoint,
+ subscription.keys.p256dh,
+ subscription.keys.auth,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn by_login(&mut self, login: &Login) -> Result<Vec<SubscriptionInfo>, sqlx::Error> {
+ sqlx::query!(
+ r#"
+ select
+ subscription.endpoint,
+ subscription.p256dh,
+ subscription.auth
+ from push_subscription as subscription
+ join token on subscription.token = token.id
+ where token.login = $1
+ "#,
+ login.id,
+ )
+ .map(|row| SubscriptionInfo::new(row.endpoint, row.p256dh, row.auth))
+ .fetch_all(&mut *self.0)
+ .await
+ }
+
+ pub async fn by_endpoint(
+ &mut self,
+ subscriber: &Login,
+ endpoint: &str,
+ ) -> Result<SubscriptionInfo, sqlx::Error> {
+ let row = sqlx::query!(
+ r#"
+ select
+ subscription.endpoint,
+ subscription.p256dh,
+ subscription.auth
+ from push_subscription as subscription
+ join token on subscription.token = token.id
+ join login as subscriber on token.login = subscriber.id
+ where subscriber.id = $1
+ and subscription.endpoint = $2
+ "#,
+ subscriber.id,
+ endpoint,
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+
+ let info = SubscriptionInfo::new(row.endpoint, row.p256dh, row.auth);
+
+ Ok(info)
+ }
+
+ pub async fn unsubscribe(
+ &mut self,
+ subscription: &SubscriptionInfo,
+ ) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ delete from push_subscription
+ where endpoint = $1
+ "#,
+ subscription.endpoint,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn unsubscribe_token(&mut self, token: &Token) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ delete from push_subscription
+ where token = $1
+ "#,
+ token.id,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn unsubscribe_login(&mut self, login: &Login) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ with tokens as (
+ select id from token
+ where login = $1
+ )
+ delete from push_subscription
+ where token in tokens
+ "#,
+ login.id,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ // Unsubscribe logic for token expiry lives in the `tokens` repository, for maintenance reasons.
+
+ pub async fn clear(&mut self) -> Result<(), sqlx::Error> {
+ // We assume that _all_ stored subscriptions are for a VAPID key we're about to delete.
+ sqlx::query!(
+ r#"
+ delete from push_subscription
+ "#,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+}
diff --git a/src/routes.rs b/src/routes.rs
index b848afb..1c07e78 100644
--- a/src/routes.rs
+++ b/src/routes.rs
@@ -3,10 +3,16 @@ use axum::{
response::Redirect,
routing::{delete, get, post},
};
+use web_push::WebPushClient;
-use crate::{app::App, boot, conversation, event, expire, invite, login, message, setup, ui};
+use crate::{
+ app::App, boot, conversation, event, expire, invite, login, message, push, setup, ui, vapid,
+};
-pub fn routes(app: &App) -> Router<App> {
+pub fn routes<P>(app: &App<P>) -> Router<App<P>>
+where
+ P: WebPushClient + Clone + Send + Sync + 'static,
+{
// UI routes that can be accessed before the administrator completes setup.
let ui_bootstrap = Router::new()
.route("/{*path}", get(ui::handlers::asset))
@@ -44,6 +50,8 @@ pub fn routes(app: &App) -> Router<App> {
.route("/api/invite/{invite}", get(invite::handlers::get))
.route("/api/invite/{invite}", post(invite::handlers::accept))
.route("/api/messages/{message}", delete(message::handlers::delete))
+ .route("/api/push/ping", post(push::handlers::ping))
+ .route("/api/push/subscribe", post(push::handlers::subscribe))
.route("/api/password", post(login::handlers::change_password))
// Run expiry whenever someone accesses the API. This was previously a blanket middleware
// affecting the whole service, but loading the client makes a several requests before the
@@ -56,6 +64,10 @@ pub fn routes(app: &App) -> Router<App> {
app.clone(),
expire::middleware,
))
+ .route_layer(middleware::from_fn_with_state(
+ app.clone(),
+ vapid::middleware,
+ ))
.route_layer(setup::Required(app.clone()));
[
diff --git a/src/test/fixtures/event/mod.rs b/src/test/fixtures/event/mod.rs
index 08b17e7..f8651ba 100644
--- a/src/test/fixtures/event/mod.rs
+++ b/src/test/fixtures/event/mod.rs
@@ -23,6 +23,13 @@ pub fn user(event: Event) -> Option<crate::user::Event> {
}
}
+pub fn vapid(event: Event) -> Option<crate::vapid::Event> {
+ match event {
+ Event::Vapid(event) => Some(event),
+ _ => None,
+ }
+}
+
pub mod conversation {
use crate::conversation::{Event, event};
@@ -72,3 +79,17 @@ pub mod user {
}
}
}
+
+pub mod vapid {
+ use crate::vapid::{Event, event};
+
+ // This could be defined as `-> event::Changed`. However, I want the interface to be consistent
+ // with the event stream transformers for other types, and we'd have to refactor the return type
+ // to `-> Option<event::Changed>` the instant VAPID keys sprout a second event.
+ #[allow(clippy::unnecessary_wraps)]
+ pub fn changed(event: Event) -> Option<event::Changed> {
+ match event {
+ Event::Changed(changed) => Some(changed),
+ }
+ }
+}
diff --git a/src/test/fixtures/event/stream.rs b/src/test/fixtures/event/stream.rs
index 5b3621d..bb83d0d 100644
--- a/src/test/fixtures/event/stream.rs
+++ b/src/test/fixtures/event/stream.rs
@@ -14,6 +14,10 @@ pub fn user(event: Event) -> Ready<Option<crate::user::Event>> {
future::ready(event::user(event))
}
+pub fn vapid(event: Event) -> Ready<Option<crate::vapid::Event>> {
+ future::ready(event::vapid(event))
+}
+
pub mod conversation {
use std::future::{self, Ready};
@@ -60,3 +64,16 @@ pub mod user {
future::ready(user::created(event))
}
}
+
+pub mod vapid {
+ use std::future::{self, Ready};
+
+ use crate::{
+ test::fixtures::event::vapid,
+ vapid::{Event, event},
+ };
+
+ pub fn changed(event: Event) -> Ready<Option<event::Changed>> {
+ future::ready(vapid::changed(event))
+ }
+}
diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs
index 20929f9..adc3e73 100644
--- a/src/test/fixtures/identity.rs
+++ b/src/test/fixtures/identity.rs
@@ -14,7 +14,7 @@ use crate::{
},
};
-pub async fn create(app: &App, created_at: &RequestedAt) -> Identity {
+pub async fn create<P>(app: &App<P>, created_at: &RequestedAt) -> Identity {
let credentials = fixtures::user::create_with_password(app, created_at).await;
logged_in(app, &credentials, created_at).await
}
diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs
index d9aca81..839a412 100644
--- a/src/test/fixtures/login.rs
+++ b/src/test/fixtures/login.rs
@@ -5,7 +5,7 @@ use crate::{
test::fixtures::user::{propose, propose_name},
};
-pub async fn create(app: &App, created_at: &DateTime) -> Login {
+pub async fn create<P>(app: &App<P>, created_at: &DateTime) -> Login {
let (name, password) = propose();
app.users()
.create(&name, &password, created_at)
diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs
index 3d69cfa..53bf31b 100644
--- a/src/test/fixtures/mod.rs
+++ b/src/test/fixtures/mod.rs
@@ -1,6 +1,6 @@
use chrono::{TimeDelta, Utc};
-use crate::{app::App, clock::RequestedAt, db};
+use crate::{app::App, clock::RequestedAt, db, test::webpush::Client};
pub mod boot;
pub mod conversation;
@@ -13,11 +13,11 @@ pub mod login;
pub mod message;
pub mod user;
-pub async fn scratch_app() -> App {
+pub async fn scratch_app() -> App<Client> {
let pool = db::prepare("sqlite::memory:", "sqlite::memory:")
.await
.expect("setting up in-memory sqlite database");
- App::from(pool)
+ App::from(pool, Client::new())
}
pub fn now() -> RequestedAt {
diff --git a/src/test/fixtures/user.rs b/src/test/fixtures/user.rs
index d4d8db4..3ad4436 100644
--- a/src/test/fixtures/user.rs
+++ b/src/test/fixtures/user.rs
@@ -3,7 +3,7 @@ use uuid::Uuid;
use crate::{app::App, clock::RequestedAt, login::Login, name::Name, password::Password};
-pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) {
+pub async fn create_with_password<P>(app: &App<P>, created_at: &RequestedAt) -> (Name, Password) {
let (name, password) = propose();
let user = app
.users()
@@ -14,7 +14,7 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name,
(user.name, password)
}
-pub async fn create(app: &App, created_at: &RequestedAt) -> Login {
+pub async fn create<P>(app: &App<P>, created_at: &RequestedAt) -> Login {
super::login::create(app, created_at).await
}
diff --git a/src/test/mod.rs b/src/test/mod.rs
index ebbbfef..f798b9c 100644
--- a/src/test/mod.rs
+++ b/src/test/mod.rs
@@ -1,2 +1,3 @@
pub mod fixtures;
pub mod verify;
+pub mod webpush;
diff --git a/src/test/webpush.rs b/src/test/webpush.rs
new file mode 100644
index 0000000..c86d03f
--- /dev/null
+++ b/src/test/webpush.rs
@@ -0,0 +1,37 @@
+use std::{
+ mem,
+ sync::{Arc, Mutex},
+};
+
+use web_push::{WebPushClient, WebPushError, WebPushMessage};
+
+#[derive(Clone)]
+pub struct Client {
+ sent: Arc<Mutex<Vec<WebPushMessage>>>,
+}
+
+impl Client {
+ pub fn new() -> Self {
+ Self {
+ sent: Arc::default(),
+ }
+ }
+
+ // Clears the list of sent messages (for all clones of this Client) when called, because we
+ // can't clone `WebPushMessage`s so we either need to move them or try to reconstruct them,
+ // either of which sucks but moving them sucks less.
+ pub fn sent(&self) -> Vec<WebPushMessage> {
+ let mut sent = self.sent.lock().unwrap();
+ mem::replace(&mut *sent, Vec::new())
+ }
+}
+
+#[async_trait::async_trait]
+impl WebPushClient for Client {
+ async fn send(&self, message: WebPushMessage) -> Result<(), WebPushError> {
+ let mut sent = self.sent.lock().unwrap();
+ sent.push(message);
+
+ Ok(())
+ }
+}
diff --git a/src/token/app.rs b/src/token/app.rs
index 332473d..4a08877 100644
--- a/src/token/app.rs
+++ b/src/token/app.rs
@@ -10,7 +10,7 @@ use super::{
extract::Identity,
repo::{self, Provider as _},
};
-use crate::{clock::DateTime, db::NotFound as _, name};
+use crate::{clock::DateTime, db::NotFound as _, name, push::repo::Provider as _};
pub struct Tokens {
db: SqlitePool,
@@ -112,6 +112,7 @@ impl Tokens {
pub async fn logout(&self, token: &Token) -> Result<(), ValidateError> {
let mut tx = self.db.begin().await?;
+ tx.push().unsubscribe_token(token).await?;
tx.tokens().revoke(token).await?;
tx.commit().await?;
diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs
index 52a3987..33c33af 100644
--- a/src/token/repo/token.rs
+++ b/src/token/repo/token.rs
@@ -89,6 +89,23 @@ impl Tokens<'_> {
// Expire and delete all tokens that haven't been used more recently than
// `expire_at`.
pub async fn expire(&mut self, expire_at: &DateTime) -> Result<Vec<Id>, sqlx::Error> {
+ // This lives here, rather than in the `push` repository, to ensure that the criteria for
+ // stale tokens don't drift apart between the two queries. That would be a larger risk if
+ // the queries lived in very separate parts of the codebase.
+ sqlx::query!(
+ r#"
+ with stale_tokens as (
+ select id from token
+ where last_used_at < $1
+ )
+ delete from push_subscription
+ where token in stale_tokens
+ "#,
+ expire_at,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
let tokens = sqlx::query_scalar!(
r#"
delete
diff --git a/src/vapid/app.rs b/src/vapid/app.rs
new file mode 100644
index 0000000..9949aa5
--- /dev/null
+++ b/src/vapid/app.rs
@@ -0,0 +1,117 @@
+use chrono::TimeDelta;
+use sqlx::SqlitePool;
+
+use super::{History, repo, repo::Provider as _};
+use crate::{
+ clock::DateTime,
+ db::NotFound as _,
+ event::{Broadcaster, Sequence, repo::Provider},
+ push::repo::Provider as _,
+};
+
+pub struct Vapid {
+ db: SqlitePool,
+ events: Broadcaster,
+}
+
+impl Vapid {
+ pub const fn new(db: SqlitePool, events: Broadcaster) -> Self {
+ Self { db, events }
+ }
+
+ pub async fn rotate_key(&self) -> Result<(), sqlx::Error> {
+ let mut tx = self.db.begin().await?;
+ // This is called from a separate CLI utility (see `cli.rs`), and we _can't_ deliver events
+ // to active clients from another process, so don't do anything that would require us to
+ // send events, like generating a new key.
+ //
+ // Instead, the server's next `refresh_key` call will generate a key and notify clients
+ // of the change. All we have to do is remove the existing key, so that the server can know
+ // to do so.
+ tx.vapid().clear().await?;
+ // Delete outstanding subscriptions for the existing VAPID key, as well. They're
+ // unserviceable once we lose the key. Clients can resubscribe when they process the next
+ // key rotation event, which will be quite quickly once the running server notices that the
+ // VAPID key has been removed.
+ tx.push().clear().await?;
+ tx.commit().await?;
+
+ Ok(())
+ }
+
+ pub async fn refresh_key(&self, ensure_at: &DateTime) -> Result<(), Error> {
+ let mut tx = self.db.begin().await?;
+ let key = tx.vapid().current().await.optional()?;
+ if key.is_none() {
+ let changed_at = tx.sequence().next(ensure_at).await?;
+ let (key, secret) = History::begin(&changed_at);
+
+ tx.vapid().clear().await?;
+ tx.vapid().store_signing_key(&secret).await?;
+
+ let events = key.events().filter(Sequence::start_from(changed_at));
+ tx.vapid().record_events(events.clone()).await?;
+
+ tx.commit().await?;
+
+ self.events.broadcast_from(events);
+ } else if let Some(key) = key
+ // Somewhat arbitrarily, rotate keys every 30 days.
+ && key.older_than(ensure_at.to_owned() - TimeDelta::days(30))
+ {
+ // If you can think of a way to factor out this duplication, be my guest. I tried.
+ // The only approach I could think of mirrors `crate::user::create::Create`, encoding
+ // the process in a state machine made of types, and that's a very complex solution
+ // to a problem that doesn't seem to merit it. -o
+ let changed_at = tx.sequence().next(ensure_at).await?;
+ let (key, secret) = key.rotate(&changed_at);
+
+ // This will delete _all_ stored subscriptions. This is fine; they're all for the
+ // current VAPID key, and we won't be able to use them anyways once the key is rotated.
+ // We have no way to inform the push broker services of that, unfortunately.
+ tx.push().clear().await?;
+ tx.vapid().clear().await?;
+ tx.vapid().store_signing_key(&secret).await?;
+
+ // Refactoring constraint: this `events` iterator borrows `key`. Anything that moves
+ // `key` has to give it back, but it can't give both `key` back and an event iterator
+ // borrowing from `key` because Rust doesn't support types that borrow from other
+ // parts of themselves.
+ let events = key.events().filter(Sequence::start_from(changed_at));
+ tx.vapid().record_events(events.clone()).await?;
+
+ // Refactoring constraint: we _really_ want to commit the transaction before we send
+ // out events, so that anything acting on those events is guaranteed to see the state
+ // of the service at some point at or after the side effects of this. I'd also prefer
+ // to keep the commit in the same method that the transaction is begun in, for clarity.
+ tx.commit().await?;
+
+ self.events.broadcast_from(events);
+ }
+ // else, the key exists and is not stale. Don't bother allocating a sequence number, and
+ // in fact throw away the whole transaction.
+
+ Ok(())
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub enum Error {
+ Database(#[from] sqlx::Error),
+ Ecdsa(#[from] p256::ecdsa::Error),
+ Pkcs8(#[from] p256::pkcs8::Error),
+ WebPush(#[from] web_push::WebPushError),
+}
+
+impl From<repo::Error> for Error {
+ fn from(error: repo::Error) -> Self {
+ use repo::Error;
+ match error {
+ Error::Database(error) => error.into(),
+ Error::Ecdsa(error) => error.into(),
+ Error::Pkcs8(error) => error.into(),
+ Error::WebPush(error) => error.into(),
+ }
+ }
+}
diff --git a/src/vapid/event.rs b/src/vapid/event.rs
new file mode 100644
index 0000000..cf3be77
--- /dev/null
+++ b/src/vapid/event.rs
@@ -0,0 +1,37 @@
+use p256::ecdsa::VerifyingKey;
+
+use crate::event::{Instant, Sequenced};
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+#[serde(tag = "event", rename_all = "snake_case")]
+pub enum Event {
+ Changed(Changed),
+}
+
+impl Sequenced for Event {
+ fn instant(&self) -> Instant {
+ match self {
+ Self::Changed(event) => event.instant(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)]
+pub struct Changed {
+ #[serde(flatten)]
+ pub instant: Instant,
+ #[serde(with = "crate::vapid::ser::key")]
+ pub key: VerifyingKey,
+}
+
+impl From<Changed> for Event {
+ fn from(event: Changed) -> Self {
+ Self::Changed(event)
+ }
+}
+
+impl Sequenced for Changed {
+ fn instant(&self) -> Instant {
+ self.instant
+ }
+}
diff --git a/src/vapid/history.rs b/src/vapid/history.rs
new file mode 100644
index 0000000..42f062b
--- /dev/null
+++ b/src/vapid/history.rs
@@ -0,0 +1,55 @@
+use p256::ecdsa::{SigningKey, VerifyingKey};
+use rand::thread_rng;
+
+use super::event::{Changed, Event};
+use crate::{clock::DateTime, event::Instant};
+
+#[derive(Debug)]
+pub struct History {
+ pub key: VerifyingKey,
+ pub changed: Instant,
+}
+
+// Lifecycle interface
+impl History {
+ pub fn begin(changed: &Instant) -> (Self, SigningKey) {
+ let key = SigningKey::random(&mut thread_rng());
+ (
+ Self {
+ key: VerifyingKey::from(&key),
+ changed: *changed,
+ },
+ key,
+ )
+ }
+
+ // `self` _is_ unused here, clippy is right about that. This choice is deliberate, however - it
+ // makes it harder to inadvertently reuse a rotated key via its history, and it makes the
+ // lifecycle interface more obviously consistent between this and other History types.
+ #[allow(clippy::unused_self)]
+ pub fn rotate(self, changed: &Instant) -> (Self, SigningKey) {
+ Self::begin(changed)
+ }
+}
+
+// State interface
+impl History {
+ pub fn older_than(&self, when: DateTime) -> bool {
+ self.changed.at < when
+ }
+}
+
+// Events interface
+impl History {
+ pub fn events(&self) -> impl Iterator<Item = Event> + Clone {
+ [self.changed()].into_iter()
+ }
+
+ fn changed(&self) -> Event {
+ Changed {
+ key: self.key,
+ instant: self.changed,
+ }
+ .into()
+ }
+}
diff --git a/src/vapid/middleware.rs b/src/vapid/middleware.rs
new file mode 100644
index 0000000..3129aa7
--- /dev/null
+++ b/src/vapid/middleware.rs
@@ -0,0 +1,17 @@
+use axum::{
+ extract::{Request, State},
+ middleware::Next,
+ response::Response,
+};
+
+use crate::{clock::RequestedAt, error::Internal, vapid::app::Vapid};
+
+pub async fn middleware(
+ State(vapid): State<Vapid>,
+ RequestedAt(now): RequestedAt,
+ request: Request,
+ next: Next,
+) -> Result<Response, Internal> {
+ vapid.refresh_key(&now).await?;
+ Ok(next.run(request).await)
+}
diff --git a/src/vapid/mod.rs b/src/vapid/mod.rs
new file mode 100644
index 0000000..364f602
--- /dev/null
+++ b/src/vapid/mod.rs
@@ -0,0 +1,10 @@
+pub mod app;
+pub mod event;
+mod history;
+mod middleware;
+pub mod repo;
+pub mod ser;
+
+pub use event::Event;
+pub use history::History;
+pub use middleware::middleware;
diff --git a/src/vapid/repo.rs b/src/vapid/repo.rs
new file mode 100644
index 0000000..9db61e1
--- /dev/null
+++ b/src/vapid/repo.rs
@@ -0,0 +1,161 @@
+use std::io::Cursor;
+
+use p256::{
+ ecdsa::SigningKey,
+ pkcs8::{DecodePrivateKey as _, EncodePrivateKey as _, LineEnding},
+};
+use sqlx::{Sqlite, SqliteConnection, Transaction};
+use web_push::{PartialVapidSignatureBuilder, VapidSignatureBuilder};
+
+use super::{
+ History,
+ event::{Changed, Event},
+};
+use crate::{
+ clock::DateTime,
+ db::NotFound,
+ event::{Instant, Sequence},
+};
+
+pub trait Provider {
+ fn vapid(&mut self) -> Vapid<'_>;
+}
+
+impl Provider for Transaction<'_, Sqlite> {
+ fn vapid(&mut self) -> Vapid<'_> {
+ Vapid(self)
+ }
+}
+
+pub struct Vapid<'a>(&'a mut SqliteConnection);
+
+impl Vapid<'_> {
+ pub async fn record_events(
+ &mut self,
+ events: impl IntoIterator<Item = Event>,
+ ) -> Result<(), sqlx::Error> {
+ for event in events {
+ self.record_event(&event).await?;
+ }
+ Ok(())
+ }
+
+ pub async fn record_event(&mut self, event: &Event) -> Result<(), sqlx::Error> {
+ match event {
+ Event::Changed(changed) => self.record_changed(changed).await,
+ }
+ }
+
+ async fn record_changed(&mut self, changed: &Changed) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ insert into vapid_key (changed_at, changed_sequence)
+ values ($1, $2)
+ "#,
+ changed.instant.at,
+ changed.instant.sequence,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn clear(&mut self) -> Result<(), sqlx::Error> {
+ sqlx::query!(
+ r#"
+ delete from vapid_key
+ "#
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ sqlx::query!(
+ r#"
+ delete from vapid_signing_key
+ "#
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn store_signing_key(&mut self, key: &SigningKey) -> Result<(), Error> {
+ let key = key.to_pkcs8_pem(LineEnding::CRLF)?;
+ let key = key.as_str();
+ sqlx::query!(
+ r#"
+ insert into vapid_signing_key (key)
+ values ($1)
+ "#,
+ key,
+ )
+ .execute(&mut *self.0)
+ .await?;
+
+ Ok(())
+ }
+
+ pub async fn current(&mut self) -> Result<History, Error> {
+ let key = sqlx::query!(
+ r#"
+ select
+ key.changed_at as "changed_at: DateTime",
+ key.changed_sequence as "changed_sequence: Sequence",
+ signing.key
+ from vapid_key as key
+ join vapid_signing_key as signing
+ "#
+ )
+ .map(|row| {
+ let key = SigningKey::from_pkcs8_pem(&row.key)?;
+ let key = key.verifying_key().to_owned();
+
+ let changed = Instant::new(row.changed_at, row.changed_sequence);
+
+ Ok::<_, Error>(History { key, changed })
+ })
+ .fetch_one(&mut *self.0)
+ .await??;
+
+ Ok(key)
+ }
+
+ pub async fn signer(&mut self) -> Result<PartialVapidSignatureBuilder, Error> {
+ let key = sqlx::query_scalar!(
+ r#"
+ select key
+ from vapid_signing_key
+ "#
+ )
+ .fetch_one(&mut *self.0)
+ .await?;
+ let key = Cursor::new(&key);
+ let signer = VapidSignatureBuilder::from_pem_no_sub(key)?;
+
+ Ok(signer)
+ }
+}
+
+#[derive(Debug, thiserror::Error)]
+#[error(transparent)]
+pub enum Error {
+ Ecdsa(#[from] p256::ecdsa::Error),
+ Pkcs8(#[from] p256::pkcs8::Error),
+ WebPush(#[from] web_push::WebPushError),
+ Database(#[from] sqlx::Error),
+}
+
+impl<T> NotFound for Result<T, Error> {
+ type Ok = T;
+ type Error = Error;
+
+ fn optional(self) -> Result<Option<T>, Error> {
+ match self {
+ Ok(value) => Ok(Some(value)),
+ Err(Error::Database(sqlx::Error::RowNotFound)) => Ok(None),
+ Err(other) => Err(other),
+ }
+ }
+}
diff --git a/src/vapid/ser.rs b/src/vapid/ser.rs
new file mode 100644
index 0000000..02c77e1
--- /dev/null
+++ b/src/vapid/ser.rs
@@ -0,0 +1,63 @@
+pub mod key {
+ use std::fmt;
+
+ use base64::{Engine as _, engine::general_purpose::URL_SAFE};
+ use p256::ecdsa::VerifyingKey;
+ use serde::{Deserializer, Serialize as _, de};
+
+ // This serialization - to a URL-safe base-64-encoded string and back - is based on my best
+ // understanding of RFC 8292 and the corresponding browser APIs. Particularly, it's based on
+ // section 3.2:
+ //
+ // > The "k" parameter includes an ECDSA public key [FIPS186] in uncompressed form [X9.62] that
+ // > is encoded using base64url encoding [RFC7515].
+ //
+ // <https://datatracker.ietf.org/doc/html/rfc8292#section-3.2>
+ //
+ // I believe this is also supported by MDN's explanation:
+ //
+ // > `applicationServerKey`
+ // >
+ // > A Base64-encoded string or ArrayBuffer containing an ECDSA P-256 public key that the push
+ // > server will use to authenticate your application server. If specified, all messages from
+ // > your application server must use the VAPID authentication scheme, and include a JWT signed
+ // > with the corresponding private key. This key IS NOT the same ECDH key that you use to
+ // > encrypt the data. For more information, see "Using VAPID with WebPush".
+ //
+ // <https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#applicationserverkey>
+
+ pub fn serialize<S>(key: &VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
+ where
+ S: serde::Serializer,
+ {
+ let key = key.to_sec1_bytes();
+ let key = URL_SAFE.encode(key);
+ key.serialize(serializer)
+ }
+
+ pub fn deserialize<'de, D>(deserializer: D) -> Result<VerifyingKey, D::Error>
+ where
+ D: Deserializer<'de>,
+ {
+ deserializer.deserialize_str(Visitor)
+ }
+
+ struct Visitor;
+ impl de::Visitor<'_> for Visitor {
+ type Value = VerifyingKey;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
+ formatter.write_str("a string containing a VAPID key")
+ }
+
+ fn visit_str<E>(self, key: &str) -> Result<Self::Value, E>
+ where
+ E: de::Error,
+ {
+ let key = URL_SAFE.decode(key).map_err(E::custom)?;
+ let key = VerifyingKey::from_sec1_bytes(&key).map_err(E::custom)?;
+
+ Ok(key)
+ }
+ }
+}
diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js
index ac707a5..9eeb128 100644
--- a/ui/lib/apiServer.js
+++ b/ui/lib/apiServer.js
@@ -47,7 +47,7 @@ export async function getInvite(inviteId) {
}
export async function acceptInvite(inviteId, name, password) {
- return apiServer
+ return await apiServer
.post(`/invite/${inviteId}`, {
name,
password,
@@ -55,6 +55,14 @@ export async function acceptInvite(inviteId, name, password) {
.catch(responseError);
}
+export async function createPushSubscription(subscription, vapid) {
+ return await apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError);
+}
+
+export async function sendPing() {
+ return await apiServer.post('/push/ping', {}).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..aab4929
--- /dev/null
+++ b/ui/lib/components/PushSubscription.svelte
@@ -0,0 +1,30 @@
+<script>
+ let { vapid, subscription, subscribe = async () => null, ping = 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}
+ {#if !subscription}
+ <form class="form" onsubmit={onsubmit(subscribe)}>
+ <button disabled={pending} type="submit">create push subscription</button>
+ </form>
+ {/if}
+ <form class="form" onsubmit={onsubmit(ping)}>
+ <button disabled={pending} type="submit">send test notification</button>
+ </form>
+{: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/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte
index 0c960c8..a21d160 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,20 @@
invites.push(response.data);
}
}
+
+ async function subscribe() {
+ await session.push.subscribe();
+ }
+
+ async function ping() {
+ await api.sendPing();
+ }
</script>
<ChangePassword {changePassword} />
<hr />
+<PushSubscription {subscription} {vapid} {subscribe} {ping} />
+<hr />
<Invites {invites} {createInvite} />
<hr />
<LogOut {logOut} />
diff --git a/ui/routes/(swatch)/.swatch/+page.svelte b/ui/routes/(swatch)/.swatch/+page.svelte
index 5334438..c1969e5 100644
--- a/ui/routes/(swatch)/.swatch/+page.svelte
+++ b/ui/routes/(swatch)/.swatch/+page.svelte
@@ -19,5 +19,6 @@
<li><a href="MessageRun">MessageRun</a></li>
<li><a href="MessageInput">MessageInput</a></li>
<li><a href="Message">Message</a></li>
+ <li><a href="PushSubscription">PushSubscription</a></li>
<li><a href="swatch/EventLog">swatch/EventLog</a></li>
</ul>
diff --git a/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte
new file mode 100644
index 0000000..3d564a3
--- /dev/null
+++ b/ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte
@@ -0,0 +1,79 @@
+<script>
+ import { DateTime } from 'luxon';
+
+ import PushSubscription from '$lib/components/PushSubscription.svelte';
+ import { makeDeriver } from '$lib/swatch/derive.js';
+ import EventLog from '$lib/components/swatch/EventLog.svelte';
+ import EventCapture from '$lib/swatch/event-capture.svelte.js';
+ import * as json from '$lib/swatch/json.js';
+
+ function fromBase64(str) {
+ if (str.trim().length === 0) {
+ return null;
+ }
+ const bytes = Uint8Array.fromBase64(str, { alphabet: 'base64url' });
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+ }
+
+ const base64Deriver = makeDeriver(fromBase64);
+
+ // This is a "real" key, but it's not a key that's in use anywhere. I generated it for this
+ // purpose. -o
+ const testVapidKey =
+ 'BJXUH-WxM8BoxntTsrLufxc2Zlbwk-A1wsF01-ykUyh9pSUZG1Ymk3R-FOxJTGApQeJWIYTW9j-1sLQFIL8cGBU=';
+
+ let vapidInput = $state('');
+ let vapid = $derived(base64Deriver(vapidInput));
+
+ // See <> for schema. This is an approximation of the browser subscription object.
+ const testSubscription = json.encode({
+ endpoint: 'https://push.example.com/1234',
+ expirationTime: performance.now() + 86400 /* sec */ * 1000 /* millisec */,
+ options: {
+ userVisibleOnly: true,
+ applicationServerKey: null,
+ },
+ });
+
+ let subscriptionInput = $state('');
+ let subscription = $derived(json.decode(subscriptionInput));
+
+ let capture = $state(new EventCapture());
+ const subscribe = capture.on('subscribe');
+ const ping = capture.on('ping');
+</script>
+
+<h1><code>PushSubscription</code></h1>
+
+<nav><p><a href=".">Back to swatches</a></p></nav>
+
+<h2>properties</h2>
+
+<div class="component-properties">
+ <label>vapid key <input type="text" bind:value={vapidInput} /></label>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (vapidInput = '')}>(none)</button>
+ <button onclick={() => (vapidInput = testVapidKey)}>test key</button>
+ </div>
+
+ <label
+ ><p>subscription (json)</p>
+ <textarea bind:value={subscriptionInput}></textarea>
+ <div class="suggestion">
+ interesting values:
+ <button onclick={() => (subscriptionInput = '')}>(none)</button>
+ <button onclick={() => (subscriptionInput = testSubscription)}>example</button>
+ </div>
+ </label>
+</div>
+
+<h2>rendered</h2>
+
+<div class="component-preview">
+ <PushSubscription {vapid} {subscription} {subscribe} {ping} />
+</div>
+
+<h2>events</h2>
+
+<EventLog events={capture.events} clear={capture.clear.bind(capture)} />
diff --git a/ui/service-worker.js b/ui/service-worker.js
index d9b2a7c..cb32d0d 100644
--- a/ui/service-worker.js
+++ b/ui/service-worker.js
@@ -52,3 +52,12 @@ async function cacheFirst(request) {
self.addEventListener('fetch', (event) => {
event.respondWith(cacheFirst(event.request));
});
+
+self.addEventListener('push', (event) => {
+ event.waitUntil(
+ self.registration.showNotification('Test notification', {
+ actions: [],
+ body: event.data.text(),
+ }),
+ );
+});