From 3bd63e20777126216777b392615dc8144f21bb9a Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 30 Aug 2025 00:50:24 -0400 Subject: Generate, store, and deliver a VAPID key. VAPID is used to authenticate applications to push brokers, as part of the [Web Push] specification. It's notionally optional, but we believe [Apple requires it][apple], and in any case making it impossible to use subscription URLs without the corresponding private key available, and thus harder to impersonate the server, seems like a good security practice regardless. [Web Push]: https://developer.mozilla.org/en-US/docs/Web/API/Push_API [apple]: https://developer.apple.com/documentation/usernotifications/sending-web-push-notifications-in-web-apps-and-browsers There are several implementations of VAPID for Rust: * [web_push](https://docs.rs/web-push/latest/web_push/) includes an implementation of VAPID but requires callers to provision their own keys. We will likely use this crate for Web Push fulfilment, but we cannot use it for key generation. * [vapid](https://docs.rs/vapid/latest/vapid/) includes an implementation of VAPID key generation. It delegates to `openssl` to handle cryptographic operations. * [p256](https://docs.rs/p256/latest/p256/) implements NIST P-256 in Rust. It's maintained by the RustCrypto team, though as of this writing it is largely written by a single contributor. It isn't specifically designed for use with VAPID. I opted to use p256 for this, as I believe the RustCrypto team are the most likely to produce a correct and secure implementation, and because openssl has consistently left a bad taste in my mouth for years. Because it's a general implementation of the algorithm, I expect that it will require more work for us to adapt it for use with VAPID specifically; I'm willing to chance it and we can swap it out for the vapid crate if it sucks. This has left me with one area of uncertainty: I'm not actually sure I'm using the right parts of p256. The choice of `ecdsa::SigningKey` over `p256::SecretKey` is based on [the MDN docs] using phrases like "This value is part of a signing key pair generated by your application server, and usable with elliptic curve digital signature (ECDSA), over the P-256 curve." and on [RFC 8292]'s "The 'k' parameter includes an ECDSA public key in uncompressed form that is encoded using base64url encoding. However, we won't be able to test my implementation until we implement some other key parts of Web Push, which are out of scope of this commit. [the MDN docs]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/options [RFC 8292]: https://datatracker.ietf.org/doc/html/rfc8292#section-3.2 Following the design used for storing logins and users, VAPID keys are split into a non-synchronized part (consisting of the private key), whose exposure would allow others to impersonate the Pilcrow server, and a synchronized part (consisting of event coordinates and, notionally, the public key), which is non-sensitive and can be safely shared with any user. However, the public key is derived from the stored private key, rather than being stored directly, to minimize redundancy in the stored data. Following the design used for expiring stale entities, the app checks for and creates, or rotates, its VAPID key using middleware that runs before most API requests. If, at that point, the key is either absent, or more than 30 days old, it is replaced. This imposes a small tax on API request latency, which is used to fund prompt and automatic key rotation without the need for an operator-facing key management interface. VAPID keys are delivered to clients via the event stream, as laid out in `docs/api/events.md`. There are a few reasons for this, but the big one is that changing the VAPID key would immediately invalidate push subscriptions: we throw away the private key, so we wouldn't be able to publish to them any longer. Clients must replace their push subscriptions in order to resume delivery, and doing so promptly when notified that the key has changed will minimize the gap. This design is intended to allow for manual key rotation. The key can be rotated "immedately" by emptying the `vapid_key` and `vapid_signing_key` tables (which destroys the rotated kye); the server will generate a new one before it is needed, and will notify clients that the key has been invalidated. This change includes client support for tracking the current VAPID key. The client doesn't _use_ this information anywhere, yet, but it has it. --- Cargo.toml | 2 ++ 1 file changed, 2 insertions(+) (limited to 'Cargo.toml') diff --git a/Cargo.toml b/Cargo.toml index 5b861a8..c0648cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,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" @@ -33,6 +34,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"] } -- cgit v1.2.3 From 9c271b27ff03cf4976326090ff54e3b5dfc04962 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 24 Oct 2025 16:51:07 -0400 Subject: Raise minimum Rust version to 1.90. We've made stylistic changes that follow from Rust 1.86 through 1.89 anyways, and I'm not at all confident we aren't using APIs that only exist in those versions. --- Cargo.toml | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) (limited to 'Cargo.toml') diff --git a/Cargo.toml b/Cargo.toml index c0648cd..a3d4594 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,21 +2,20 @@ name = "pilcrow" version = "0.1.0" edition = "2024" -rust-version = "1.85" +rust-version = "1.90" authors = [ - "Owen Jacobson ", - "Kit La Touche ", + "Owen Jacobson ", + "Kit La Touche ", ] [package.metadata.deb] maintainer = "Owen Jacobson " maintainer-scripts = "debian" assets = [ - # Binaries - ["target/release/pilcrow", "/usr/bin/pilcrow", "755"], - - # Configuration - ["debian/default", "/etc/default/pilcrow", "644"], + # Binaries + ["target/release/pilcrow", "/usr/bin/pilcrow", "755"], + # Configuration + ["debian/default", "/etc/default/pilcrow", "644"], ] [package.metadata.deb.systemd-units] -- cgit v1.2.3 From 1f44cd930cdff94bb8cf04f645a5b035507438d9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 24 Oct 2025 19:10:29 -0400 Subject: Add an endpoint for creating push subscriptions. The semantics of this endpoint are somewhat complex, and are incompletely captured in the associated docs change. For posterity, the intended workflow is: 1. Obtain Pilcrow's current VAPID key by connecting (it's in the events, either from boot or from the event stream). 2. Use the browser push APIs to create a push subscription, using that VAPID key. 3. Send Pilcrow the push subscription endpoint and keys, plus the VAPID key the client used to create it so that the server can detect race conditions with key rotation. 4. Wait for messages to arrive. This commit does not introduce any actual messages, just subscription management endpoints. When the server's VAPID key is rotated, all existing subscriptions are discarded. Without the VAPID key, the server cannot service those subscriptions. We can't exactly notify the broker to stop processing messages on those subscriptions, so this is an incomplete solution to what to do if the key is being rotated due to a compromise, but it's better than nothing. The shape of the API endpoint is heavily informed by the [JSON payload][web-push-json] provided by browser Web Push implementations, to ease client development in a browser-based context. The idea is that a client can take that JSON and send it to the server verbatim, without needing to transform it in any way, to submit the subscription to the server for use. [web-push-json]: https://developer.mozilla.org/en-US/docs/Web/API/PushSubscription/toJSON Push subscriptions are operationally associated with a specific _user agent_, and have no inherent relationship with a Pilcrow login or token (session). Taken as-is, a subscription created by user A could be reused by user B if they share a user agent, even if user A logs out before user B logs in. Pilcrow therefore _logically_ associates push subscriptions with specific tokens, and abandons those subscriptions when the token is invalidated by * logging out, * expiry, or * changing passwords. (There are no other token invalidation workflows at this time.) Stored subscriptions are also abandoned when the server's VAPID key changes. --- ...830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json | 12 + ...c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json | 12 + ...0bcbfa9b4638273c89ceafd0790c7c372e6954619a.json | 12 + ...535179504b0d354431a66a870a4d457c028da3c584.json | 12 + ...7572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json | 12 + ...fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json | 32 + Cargo.lock | 911 +++++++++++++++++++-- Cargo.toml | 1 + docs/api/SUMMARY.md | 1 + docs/api/authentication.md | 4 + docs/api/basics.md | 2 + docs/api/push.md | 124 +++ migrations/20251009021241_push_subscriptions.sql | 12 + src/app.rs | 11 + src/lib.rs | 1 + src/login/app.rs | 2 + src/push/app.rs | 76 ++ src/push/handlers/mod.rs | 3 + src/push/handlers/subscribe/mod.rs | 95 +++ src/push/handlers/subscribe/test.rs | 236 ++++++ src/push/mod.rs | 3 + src/push/repo.rs | 114 +++ src/routes.rs | 3 +- src/token/app.rs | 3 +- src/token/repo/token.rs | 17 + src/vapid/app.rs | 5 + src/vapid/ser.rs | 30 +- 27 files changed, 1673 insertions(+), 73 deletions(-) create mode 100644 .sqlx/query-3e008c1231d35b4c0675d2830f025325f44d5eb8ddbc573fe134a3deb28e04b9.json create mode 100644 .sqlx/query-3f42f859ded1a8b6184ee2c8a11099fefbd148d52705a834d7db4cbcc6fdd682.json create mode 100644 .sqlx/query-46c94988ed02829744cff50bcbfa9b4638273c89ceafd0790c7c372e6954619a.json create mode 100644 .sqlx/query-a86531c0ed384706645a19535179504b0d354431a66a870a4d457c028da3c584.json create mode 100644 .sqlx/query-b361b2ac9085881d74f5567572e2ed30d72997edad1a3c1e7c8e10c7933c9c72.json create mode 100644 .sqlx/query-f4bf2e6a701f7374770f79fabf83c405f7f7a88f2cbf374c0ea1682af9743004.json create mode 100644 docs/api/push.md create mode 100644 migrations/20251009021241_push_subscriptions.sql create mode 100644 src/push/app.rs create mode 100644 src/push/handlers/mod.rs create mode 100644 src/push/handlers/subscribe/mod.rs create mode 100644 src/push/handlers/subscribe/test.rs create mode 100644 src/push/mod.rs create mode 100644 src/push/repo.rs (limited to 'Cargo.toml') 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-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-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-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 0f9ece9..63a36ec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,6 +29,15 @@ dependencies = [ "zerocopy", ] +[[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" @@ -100,6 +109,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" @@ -112,6 +127,40 @@ dependencies = [ "password-hash", ] +[[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" @@ -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", @@ -228,6 +277,12 @@ 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" @@ -246,6 +301,18 @@ version = "1.6.0" 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" @@ -264,6 +331,17 @@ dependencies = [ "digest", ] +[[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" @@ -291,6 +369,12 @@ version = "1.10.0" 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" @@ -358,7 +442,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -367,6 +451,17 @@ version = "0.7.4" 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" @@ -382,12 +477,24 @@ dependencies = [ "crossbeam-utils", ] +[[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" @@ -466,6 +573,43 @@ dependencies = [ "typenum", ] +[[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" @@ -487,7 +631,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.98", ] [[package]] @@ -498,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]] @@ -507,11 +661,23 @@ 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" @@ -535,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", ] @@ -569,7 +735,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -590,7 +756,7 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.9", "digest", "elliptic-curve", "rfc6979", @@ -598,6 +764,34 @@ dependencies = [ "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" @@ -619,6 +813,7 @@ dependencies = [ "ff", "generic-array", "group", + "hkdf", "pem-rfc7468", "pkcs8", "rand_core", @@ -627,6 +822,15 @@ dependencies = [ "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" @@ -654,6 +858,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[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" @@ -688,6 +898,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -727,6 +946,21 @@ version = "0.1.4" 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" @@ -795,6 +1029,34 @@ version = "0.3.31" 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" @@ -803,7 +1065,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -854,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]] @@ -940,7 +1204,7 @@ dependencies = [ "base64 0.21.7", "bytes", "headers-core", - "http", + "http 1.2.0", "httpdate", "mime", "sha1", @@ -952,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]] @@ -991,6 +1255,30 @@ dependencies = [ "digest", ] +[[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" @@ -1000,6 +1288,17 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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" @@ -1018,7 +1317,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.2.0", ] [[package]] @@ -1029,7 +1328,7 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", + "http 1.2.0", "http-body", "pin-project-lite", ] @@ -1055,7 +1354,7 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", + "http 1.2.0", "http-body", "httparse", "httpdate", @@ -1073,7 +1372,7 @@ checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" dependencies = [ "bytes", "futures-util", - "http", + "http 1.2.0", "http-body", "hyper", "pin-project-lite", @@ -1219,7 +1518,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1271,12 +1570,48 @@ dependencies = [ "serde", ] +[[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" @@ -1302,6 +1637,46 @@ dependencies = [ "wasm-bindgen", ] +[[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" @@ -1323,13 +1698,23 @@ version = "0.2.11" 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", ] @@ -1344,6 +1729,18 @@ dependencies = [ "vcpkg", ] +[[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" @@ -1426,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", @@ -1500,6 +1897,50 @@ version = "1.20.3" 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" @@ -1518,6 +1959,18 @@ dependencies = [ "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" @@ -1558,6 +2011,27 @@ dependencies = [ "subtle", ] +[[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" @@ -1601,7 +2075,7 @@ dependencies = [ "serde_json", "serde_with", "sqlx", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-stream", "tower", @@ -1610,6 +2084,7 @@ dependencies = [ "unicode-segmentation", "unix_path", "uuid", + "web-push", ] [[package]] @@ -1629,7 +2104,7 @@ checksum = "f6e859e6e5bd50440ab63c47e3ebabc90f26251f7c73c3d3e837b74a1cc3fa67" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -1650,7 +2125,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", ] @@ -1661,7 +2136,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", + "der 0.7.9", "spki", ] @@ -1671,6 +2146,22 @@ version = "0.3.31" 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" @@ -1749,7 +2240,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" dependencies = [ - "bitflags", + "bitflags 2.8.0", ] [[package]] @@ -1760,7 +2251,7 @@ checksum = "dd6f9d3d47bdd2ad6945c5015a226ec6155d0bcdfd8f7cd29f86b71f8de99d2b" dependencies = [ "getrandom 0.2.15", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -1780,9 +2271,38 @@ 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" @@ -1799,7 +2319,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", @@ -1807,6 +2327,7 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core", + "sha2", "signature", "spki", "subtle", @@ -1819,7 +2340,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", @@ -1848,7 +2369,7 @@ dependencies = [ "quote", "rust-embed-utils", "shellexpand", - "syn", + "syn 2.0.98", "walkdir", ] @@ -1874,7 +2395,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", @@ -1902,6 +2423,15 @@ dependencies = [ "winapi-util", ] +[[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" @@ -1939,31 +2469,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "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 = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +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 = "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]] @@ -1981,14 +2532,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]] @@ -2042,7 +2594,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2101,6 +2653,17 @@ dependencies = [ "autocfg", ] +[[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" @@ -2120,6 +2683,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[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" @@ -2136,7 +2709,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.9", ] [[package]] @@ -2163,7 +2736,7 @@ dependencies = [ "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.0", "futures-core", "futures-intrusive", "futures-io", @@ -2179,7 +2752,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -2196,7 +2769,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.98", ] [[package]] @@ -2219,7 +2792,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.98", "tempfile", "tokio", "url", @@ -2233,7 +2806,7 @@ checksum = "4560278f0e00ce64938540546f59f590d60beee33fffbd3b9cd47851e5fff233" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "bytes", "chrono", @@ -2263,7 +2836,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.17", "tracing", "whoami", ] @@ -2276,7 +2849,7 @@ checksum = "c5b98a57f363ed6764d5b3a12bfedf62f07aa16e1856a7ddc2a0bb190a959613" dependencies = [ "atoi", "base64 0.22.1", - "bitflags", + "bitflags 2.8.0", "byteorder", "chrono", "crc", @@ -2301,7 +2874,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.17", "tracing", "whoami", ] @@ -2359,6 +2932,30 @@ version = "2.6.1" 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" @@ -2376,6 +2973,18 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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" @@ -2384,7 +2993,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2394,7 +3003,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", @@ -2403,22 +3012,42 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.11" +version = "1.0.69" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d452f284b73e6d76dd36758a0c8684b1d5be31f92b89d07fd5822175732206fc" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" dependencies = [ - "thiserror-impl", + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "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]] @@ -2488,7 +3117,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.5.8", "tokio-macros", "windows-sys 0.52.0", ] @@ -2501,7 +3130,7 @@ checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2577,7 +3206,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -2589,6 +3218,16 @@ dependencies = [ "once_cell", ] +[[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" @@ -2634,6 +3273,12 @@ version = "1.12.0" 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" @@ -2699,6 +3344,12 @@ version = "0.9.5" 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" @@ -2730,6 +3381,15 @@ version = "0.1.0" 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" @@ -2752,7 +3412,7 @@ dependencies = [ "log", "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-shared", ] @@ -2774,7 +3434,7 @@ checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", "wasm-bindgen-backend", "wasm-bindgen-shared", ] @@ -2788,6 +3448,28 @@ dependencies = [ "unicode-ident", ] +[[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" @@ -2816,6 +3498,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -2843,6 +3531,24 @@ dependencies = [ "windows-targets 0.52.6", ] +[[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" @@ -2867,13 +3573,30 @@ 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", "windows_x86_64_msvc 0.52.6", ] +[[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" @@ -2886,6 +3609,12 @@ version = "0.52.6" 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" @@ -2898,6 +3627,12 @@ version = "0.52.6" 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" @@ -2910,12 +3645,24 @@ version = "0.52.6" 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" @@ -2928,6 +3675,12 @@ version = "0.52.6" 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" @@ -2940,6 +3693,12 @@ version = "0.52.6" 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" @@ -2952,6 +3711,12 @@ version = "0.52.6" 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" @@ -2964,13 +3729,19 @@ version = "0.52.6" 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]] @@ -3005,8 +3776,8 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.98", + "synstructure 0.13.1", ] [[package]] @@ -3027,7 +3798,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] [[package]] @@ -3047,8 +3818,8 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn", - "synstructure", + "syn 2.0.98", + "synstructure 0.13.1", ] [[package]] @@ -3076,5 +3847,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.98", ] diff --git a/Cargo.toml b/Cargo.toml index a3d4594..821ff6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,6 +56,7 @@ 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] faker_rand = "0.1.1" 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/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/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/src/app.rs b/src/app.rs index 2bfabbe..e24331b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -10,6 +10,7 @@ use crate::{ invite::app::Invites, login::app::Logins, message::app::Messages, + push::app::Push, setup::app::Setup, token::{self, app::Tokens}, vapid::app::Vapid, @@ -59,6 +60,10 @@ impl App { Messages::new(self.db.clone(), self.events.clone()) } + pub fn push(&self) -> Push { + Push::new(self.db.clone()) + } + pub fn setup(&self) -> Setup { Setup::new(self.db.clone(), self.events.clone()) } @@ -107,6 +112,12 @@ impl FromRef for Messages { } } +impl FromRef for Push { + fn from_ref(app: &App) -> Self { + app.push() + } +} + impl FromRef for Setup { fn from_ref(app: &App) -> Self { app.setup() diff --git a/src/lib.rs b/src/lib.rs index 6b2a83c..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)] 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..358a8cc --- /dev/null +++ b/src/push/app.rs @@ -0,0 +1,76 @@ +use p256::ecdsa::VerifyingKey; +use sqlx::SqlitePool; +use web_push::SubscriptionInfo; + +use super::repo::Provider as _; +use crate::{token::extract::Identity, vapid, vapid::repo::Provider as _}; + +pub struct Push { + db: SqlitePool, +} + +impl Push { + pub const fn new(db: SqlitePool) -> Self { + Self { db } + } + + 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 != ¤t.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 ¤t != subscription { + return Err(SubscribeError::Duplicate); + } + } else { + return Err(SubscribeError::Database(err)); + } + } + } + + 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, +} diff --git a/src/push/handlers/mod.rs b/src/push/handlers/mod.rs new file mode 100644 index 0000000..86eeea0 --- /dev/null +++ b/src/push/handlers/mod.rs @@ -0,0 +1,3 @@ +mod subscribe; + +pub use subscribe::handler as subscribe; diff --git a/src/push/handlers/subscribe/mod.rs b/src/push/handlers/subscribe/mod.rs new file mode 100644 index 0000000..d142df6 --- /dev/null +++ b/src/push/handlers/subscribe/mod.rs @@ -0,0 +1,95 @@ +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 . +#[derive(Clone, serde::Deserialize)] +pub struct Subscription { + endpoint: String, + keys: Keys, +} + +// This structure is described in . +#[derive(Clone, serde::Deserialize)] +pub struct Keys { + p256dh: String, + auth: String, +} + +pub async fn handler( + State(push): State, + identity: Identity, + Json(request): Json, +) -> Result { + let Request { + subscription, + vapid, + } = request; + + push.subscribe(&identity, &subscription.into(), &vapid) + .await?; + + Ok(StatusCode::CREATED) +} + +impl From for SubscriptionInfo { + fn from(request: Subscription) -> Self { + let Subscription { + endpoint, + keys: Keys { p256dh, auth }, + } = request; + let info = SubscriptionInfo::new(endpoint, p256dh, auth); + info + } +} + +#[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..6c18c6e --- /dev/null +++ b/src/push/repo.rs @@ -0,0 +1,114 @@ +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_endpoint( + &mut self, + subscriber: &Login, + endpoint: &str, + ) -> Result { + 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_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 2979abe..00d9d3e 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -5,7 +5,7 @@ use axum::{ }; use crate::{ - app::App, boot, conversation, event, expire, invite, login, message, setup, ui, vapid, + app::App, boot, conversation, event, expire, invite, login, message, push, setup, ui, vapid, }; pub fn routes(app: &App) -> Router { @@ -46,6 +46,7 @@ pub fn routes(app: &App) -> Router { .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/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 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, 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 index 61523d5..7d872ed 100644 --- a/src/vapid/app.rs +++ b/src/vapid/app.rs @@ -6,6 +6,7 @@ use crate::{ clock::DateTime, db::NotFound as _, event::{Broadcaster, Sequence, repo::Provider}, + push::repo::Provider as _, }; pub struct Vapid { @@ -60,6 +61,10 @@ impl Vapid { 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?; diff --git a/src/vapid/ser.rs b/src/vapid/ser.rs index f5372c8..02c77e1 100644 --- a/src/vapid/ser.rs +++ b/src/vapid/ser.rs @@ -1,7 +1,9 @@ pub mod key { + use std::fmt; + use base64::{Engine as _, engine::general_purpose::URL_SAFE}; use p256::ecdsa::VerifyingKey; - use serde::Serialize as _; + 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 @@ -32,4 +34,30 @@ pub mod key { let key = URL_SAFE.encode(key); key.serialize(serializer) } + + pub fn deserialize<'de, D>(deserializer: D) -> Result + 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(self, key: &str) -> Result + 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) + } + } } -- cgit v1.2.3 From 6bab5b4405c9adafb2ce76540595a62eea80acc0 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Fri, 7 Nov 2025 21:39:39 -0500 Subject: De minimis "send me a notification" implementation. When a user clicks "send a test notification," Pilcrow delivers a push message (with a fixed payload) to all active subscriptions. The included client then displays this as a notification, using browser APIs to do so. This lets us verify that push notification works, end to end - and it appears to. The API endpoint for sending a test notification is not documented. I didn't feel it prudent to extensively document an endpoint that is intended to be temporary and whose side effects are very much subject to change. However, for posterity, the endpoint is POST /api/push/ping {} and the push message payload is ping Subscriptions with permanent delivery failures are nuked when we encounter them. Subscriptions with temporary failures cause the `ping` endpoint to return an internal server error, and are not retried. We'll likely want retry logic - including retry logic to handle server restarts - for any more serious use, but for a smoke test, giving up immediately is fine. To make the push implementation testable, `App` is now generic over it. Tests use a dummy implementation that stores sent messages in memory. This has some significant limitations, documented in the test suite, but it beats sending real notifications to nowhere in tests. --- ...968d2884e5091ced5a739f025656fcd66e05200af3.json | 32 ++++++ ...9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json | 12 +++ ...0afb36060fd7450bfb4675069ede7aff15d575127e.json | 20 ++++ Cargo.lock | 1 + Cargo.toml | 1 + src/app.rs | 61 ++++++----- src/boot/app.rs | 2 + src/cli.rs | 10 +- src/event/app.rs | 2 + src/event/handlers/stream/mod.rs | 4 +- src/expire.rs | 4 +- src/push/app.rs | 114 +++++++++++++++++++-- src/push/handlers/mod.rs | 2 + src/push/handlers/ping/mod.rs | 23 +++++ src/push/handlers/ping/test.rs | 40 ++++++++ src/push/handlers/subscribe/mod.rs | 7 +- src/push/repo.rs | 35 +++++++ src/routes.rs | 7 +- src/test/fixtures/identity.rs | 2 +- src/test/fixtures/login.rs | 2 +- src/test/fixtures/mod.rs | 6 +- src/test/fixtures/user.rs | 4 +- src/test/mod.rs | 1 + src/test/webpush.rs | 37 +++++++ src/vapid/app.rs | 2 + src/vapid/repo.rs | 19 ++++ ui/lib/apiServer.js | 8 +- ui/lib/components/PushSubscription.svelte | 9 +- ui/routes/(app)/me/+page.svelte | 6 +- ui/routes/(swatch)/.swatch/+page.svelte | 1 + .../(swatch)/.swatch/PushSubscription/+page.svelte | 79 ++++++++++++++ ui/service-worker.js | 9 ++ 32 files changed, 508 insertions(+), 54 deletions(-) create mode 100644 .sqlx/query-19abe80d3fffd112a8f7c1968d2884e5091ced5a739f025656fcd66e05200af3.json create mode 100644 .sqlx/query-82d682f5579c3abb09fd9c9e8531fd810dcc0c86aba831c384f5e31b3d5c0b79.json create mode 100644 .sqlx/query-8e0d1fb04dcbaaf13a56550afb36060fd7450bfb4675069ede7aff15d575127e.json create mode 100644 src/push/handlers/ping/mod.rs create mode 100644 src/push/handlers/ping/test.rs create mode 100644 src/test/webpush.rs create mode 100644 ui/routes/(swatch)/.swatch/PushSubscription/+page.svelte (limited to 'Cargo.toml') 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-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/Cargo.lock b/Cargo.lock index 63a36ec..f7d3337 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2052,6 +2052,7 @@ name = "pilcrow" version = "0.1.0" dependencies = [ "argon2", + "async-trait", "axum", "axum-extra", "base64 0.22.1", diff --git a/Cargo.toml b/Cargo.toml index 821ff6a..1f5fa3d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,7 @@ 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/src/app.rs b/src/app.rs index e24331b..098ae9f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,25 +17,27 @@ use crate::{ }; #[derive(Clone)] -pub struct App { +pub struct App

{ db: SqlitePool, + webpush: P, events: event::Broadcaster, token_events: token::Broadcaster, } -impl App { - pub fn from(db: SqlitePool) -> Self { +impl

App

{ + 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

App

{ pub fn boot(&self) -> Boot { Boot::new(self.db.clone()) } @@ -60,8 +62,11 @@ impl App { Messages::new(self.db.clone(), self.events.clone()) } - pub fn push(&self) -> Push { - Push::new(self.db.clone()) + pub fn push(&self) -> Push

+ where + P: Clone, + { + Push::new(self.db.clone(), self.webpush.clone()) } pub fn setup(&self) -> Setup { @@ -80,58 +85,66 @@ impl App { pub fn vapid(&self) -> Vapid { Vapid::new(self.db.clone(), self.events.clone()) } + + #[cfg(test)] + pub fn webpush(&self) -> &P { + &self.webpush + } } -impl FromRef for Boot { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Boot { + fn from_ref(app: &App

) -> Self { app.boot() } } -impl FromRef for Conversations { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Conversations { + fn from_ref(app: &App

) -> Self { app.conversations() } } -impl FromRef for Invites { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Invites { + fn from_ref(app: &App

) -> Self { app.invites() } } -impl FromRef for Logins { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Logins { + fn from_ref(app: &App

) -> Self { app.logins() } } -impl FromRef for Messages { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Messages { + fn from_ref(app: &App

) -> Self { app.messages() } } -impl FromRef for Push { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Push

+where + P: Clone, +{ + fn from_ref(app: &App

) -> Self { app.push() } } -impl FromRef for Setup { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Setup { + fn from_ref(app: &App

) -> Self { app.setup() } } -impl FromRef for Tokens { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Tokens { + fn from_ref(app: &App

) -> Self { app.tokens() } } -impl FromRef for Vapid { - fn from_ref(app: &App) -> Self { +impl

FromRef> for Vapid { + fn from_ref(app: &App

) -> Self { app.vapid() } } diff --git a/src/boot/app.rs b/src/boot/app.rs index 88255b0..1ca8adb 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -79,6 +79,7 @@ pub enum Error { Name(#[from] name::Error), Ecdsa(#[from] p256::ecdsa::Error), Pkcs8(#[from] p256::pkcs8::Error), + WebPush(#[from] web_push::WebPushError), } impl From for Error { @@ -108,6 +109,7 @@ impl From for 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/cli.rs b/src/cli.rs index 8d73109..154771b 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,6 +13,7 @@ use axum::{ use clap::{CommandFactory, Parser, Subcommand}; use sqlx::sqlite::SqlitePool; use tokio::net; +use web_push::{IsahcWebPushClient, WebPushClient}; use crate::{ app::App, @@ -97,7 +98,8 @@ 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?, @@ -107,7 +109,10 @@ impl Args { Result::<_, Error>::Ok(()) } - async fn serve(self, app: App) -> Result<(), Error> { + async fn serve

(self, app: App

) -> 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())) @@ -161,4 +166,5 @@ enum 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 1e471f1..e422de9 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -99,6 +99,7 @@ pub enum Error { Name(#[from] name::Error), Ecdsa(#[from] p256::ecdsa::Error), Pkcs8(#[from] p256::pkcs8::Error), + WebPush(#[from] web_push::WebPushError), } impl From for Error { @@ -128,6 +129,7 @@ impl From for 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, +pub async fn handler

( + State(app): State>, identity: Identity, last_event_id: Option>, Query(query): Query, 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, +pub async fn middleware

( + State(app): State>, RequestedAt(expired_at): RequestedAt, req: Request, next: Next, diff --git a/src/push/app.rs b/src/push/app.rs index 358a8cc..56b9a02 100644 --- a/src/push/app.rs +++ b/src/push/app.rs @@ -1,17 +1,23 @@ +use futures::future::join_all; +use itertools::Itertools as _; use p256::ecdsa::VerifyingKey; use sqlx::SqlitePool; -use web_push::SubscriptionInfo; +use web_push::{ + ContentEncoding, PartialVapidSignatureBuilder, SubscriptionInfo, WebPushClient, WebPushError, + WebPushMessage, WebPushMessageBuilder, +}; use super::repo::Provider as _; -use crate::{token::extract::Identity, vapid, vapid::repo::Provider as _}; +use crate::{login::Login, token::extract::Identity, vapid, vapid::repo::Provider as _}; -pub struct Push { +pub struct Push

{ db: SqlitePool, + webpush: P, } -impl Push { - pub const fn new(db: SqlitePool) -> Self { - Self { db } +impl

Push

{ + pub const fn new(db: SqlitePool, webpush: P) -> Self { + Self { db, webpush } } pub async fn subscribe( @@ -60,6 +66,76 @@ impl Push { } } +impl

Push

+where + P: WebPushClient, +{ + fn prepare_ping( + signer: &PartialVapidSignatureBuilder, + subscription: &SubscriptionInfo, + ) -> Result { + 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 + // 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)] @@ -74,3 +150,29 @@ pub enum SubscribeError { // 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), +} + +impl From 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 index 86eeea0..bb58774 100644 --- a/src/push/handlers/mod.rs +++ b/src/push/handlers/mod.rs @@ -1,3 +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

( + State(push): State>, + identity: Identity, + Json(_): Json, +) -> Result +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 index d142df6..a1a5899 100644 --- a/src/push/handlers/subscribe/mod.rs +++ b/src/push/handlers/subscribe/mod.rs @@ -36,8 +36,8 @@ pub struct Keys { auth: String, } -pub async fn handler( - State(push): State, +pub async fn handler

( + State(push): State>, identity: Identity, Json(request): Json, ) -> Result { @@ -58,8 +58,7 @@ impl From for SubscriptionInfo { endpoint, keys: Keys { p256dh, auth }, } = request; - let info = SubscriptionInfo::new(endpoint, p256dh, auth); - info + SubscriptionInfo::new(endpoint, p256dh, auth) } } diff --git a/src/push/repo.rs b/src/push/repo.rs index 6c18c6e..4183489 100644 --- a/src/push/repo.rs +++ b/src/push/repo.rs @@ -37,6 +37,24 @@ impl Push<'_> { Ok(()) } + pub async fn by_login(&mut self, login: &Login) -> Result, 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, @@ -65,6 +83,23 @@ impl Push<'_> { 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#" diff --git a/src/routes.rs b/src/routes.rs index 00d9d3e..1c07e78 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -3,12 +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, push, setup, ui, vapid, }; -pub fn routes(app: &App) -> Router { +pub fn routes

(app: &App

) -> Router> +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)) @@ -46,6 +50,7 @@ pub fn routes(app: &App) -> Router { .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 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

(app: &App

, 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

(app: &App

, 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 { 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

(app: &App

, 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

(app: &App

, 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>>, +} + +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 { + 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/vapid/app.rs b/src/vapid/app.rs index ebd2446..9949aa5 100644 --- a/src/vapid/app.rs +++ b/src/vapid/app.rs @@ -101,6 +101,7 @@ pub enum Error { Database(#[from] sqlx::Error), Ecdsa(#[from] p256::ecdsa::Error), Pkcs8(#[from] p256::pkcs8::Error), + WebPush(#[from] web_push::WebPushError), } impl From for Error { @@ -110,6 +111,7 @@ impl From for 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/repo.rs b/src/vapid/repo.rs index 98b3bae..9db61e1 100644 --- a/src/vapid/repo.rs +++ b/src/vapid/repo.rs @@ -1,8 +1,11 @@ +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, @@ -118,6 +121,21 @@ impl Vapid<'_> { Ok(key) } + + pub async fn signer(&mut self) -> Result { + 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)] @@ -125,6 +143,7 @@ impl Vapid<'_> { pub enum Error { Ecdsa(#[from] p256::ecdsa::Error), Pkcs8(#[from] p256::pkcs8::Error), + WebPush(#[from] web_push::WebPushError), Database(#[from] sqlx::Error), } diff --git a/ui/lib/apiServer.js b/ui/lib/apiServer.js index f55f271..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, @@ -56,7 +56,11 @@ export async function acceptInvite(inviteId, name, password) { } export async function createPushSubscription(subscription, vapid) { - return apiServer.post('/push/subscribe', { subscription, vapid }).catch(responseError); + 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) { diff --git a/ui/lib/components/PushSubscription.svelte b/ui/lib/components/PushSubscription.svelte index a85cbb3..aab4929 100644 --- a/ui/lib/components/PushSubscription.svelte +++ b/ui/lib/components/PushSubscription.svelte @@ -1,5 +1,5 @@ -{#if vapid !== null} - {#if subscription === null} +{#if !!vapid} + {#if !subscription}

{/if} +
+ +
{:else} Waiting for VAPID key… {/if} diff --git a/ui/routes/(app)/me/+page.svelte b/ui/routes/(app)/me/+page.svelte index ddb1245..a21d160 100644 --- a/ui/routes/(app)/me/+page.svelte +++ b/ui/routes/(app)/me/+page.svelte @@ -35,11 +35,15 @@ async function subscribe() { await session.push.subscribe(); } + + async function ping() { + await api.sendPing(); + }
- +

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 @@
  • MessageRun
  • MessageInput
  • Message
  • +
  • PushSubscription
  • swatch/EventLog
  • 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 @@ + + +

    PushSubscription

    + + + +

    properties

    + +
    + +
    + interesting values: + + +
    + + +
    + +

    rendered

    + +
    + +
    + +

    events

    + + 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(), + }), + ); +}); -- cgit v1.2.3