diff --git a/.gitignore b/.gitignore index 771536c..9bf488d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target node_modules -/frontend/dist \ No newline at end of file +/frontend/dist +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 8030dcb..b8d0c9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -93,12 +93,29 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[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-stream" version = "0.3.6" @@ -161,10 +178,10 @@ checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" dependencies = [ "async-trait", "axum-core", - "base64", + "base64 0.22.1", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -199,7 +216,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -221,10 +238,10 @@ dependencies = [ "axum-core", "bytes", "cookie", - "fastrand", + "fastrand 2.3.0", "futures-util", "headers", - "http", + "http 1.4.0", "http-body", "http-body-util", "mime", @@ -242,6 +259,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -268,6 +297,18 @@ dependencies = [ "serde", ] +[[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.10.0" @@ -327,11 +368,12 @@ version = "0.1.0" dependencies = [ "axum", "axum-extra", - "base64", + "base64 0.22.1", "chrono", "cookie", "dashmap", "dotenvy", + "isahc", "jsonwebtoken", "migration", "reqwest", @@ -346,6 +388,7 @@ dependencies = [ "tracing", "tracing-subscriber", "uuid", + "web-push", ] [[package]] @@ -388,6 +431,12 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[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.55" @@ -474,6 +523,17 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "coarsetime" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +dependencies = [ + "libc", + "wasix", + "wasm-bindgen", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -489,6 +549,12 @@ 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" @@ -594,6 +660,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", + "windows-sys 0.59.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.85+curl-8.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0efa6142b5ecc05f6d3eaa39e6af4888b9d3939273fb592c92b7088a8cf3fdb" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.59.0", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -676,17 +779,50 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[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]] +name = "der" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a467a65c5e759bce6e65eaf91cc29f466cdc57cb65777bd646872a8a1fd4de" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.6.0", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", "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.5.5" @@ -757,7 +893,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", ] @@ -785,12 +921,30 @@ version = "0.16.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ - "der", + "der 0.7.10", "digest", "elliptic-curve", "rfc6979", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", +] + +[[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]] @@ -799,8 +953,18 @@ version = "2.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" dependencies = [ - "pkcs8", - "signature", + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-compact" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ce99a9e19c84beb4cc35ece85374335ccc398240712114c85038319ed709bd" +dependencies = [ + "ct-codecs", + "getrandom 0.3.4", ] [[package]] @@ -839,8 +1003,8 @@ dependencies = [ "generic-array", "group", "hkdf", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core", "sec1", "subtle", @@ -883,6 +1047,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.1" @@ -894,6 +1064,15 @@ dependencies = [ "pin-project-lite", ] +[[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" @@ -1033,6 +1212,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-sink" version = "0.3.31" @@ -1090,9 +1297,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1123,7 +1332,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -1178,10 +1387,10 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "headers-core", - "http", + "http 1.4.0", "httpdate", "mime", "sha1", @@ -1193,7 +1402,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" dependencies = [ - "http", + "http 1.4.0", ] [[package]] @@ -1232,6 +1441,30 @@ dependencies = [ "digest", ] +[[package]] +name = "hmac-sha1-compact" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b3ba31f6dc772cc8221ce81dbbbd64fa1e668255a6737d95eeace59b5a8823" + +[[package]] +name = "hmac-sha256" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f0ae375a85536cac3a243e3a9cda80a47910348abdea7e2c22f8ec556d586d" +dependencies = [ + "digest", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5075d41b75a022af043a5bbf49b89abf17665d5aebf6f6ec64ffff207d87654d" +dependencies = [ + "digest", +] + [[package]] name = "home" version = "0.5.12" @@ -1241,6 +1474,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[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.4.0" @@ -1258,7 +1502,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -1269,7 +1513,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", + "http 1.4.0", "http-body", "pin-project-lite", ] @@ -1297,7 +1541,7 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "httparse", "httpdate", @@ -1315,7 +1559,7 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", + "http 1.4.0", "hyper", "hyper-util", "rustls", @@ -1347,11 +1591,11 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http", + "http 1.4.0", "http-body", "hyper", "ipnet", @@ -1519,6 +1763,15 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1541,6 +1794,33 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[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 = "itoa" version = "1.0.17" @@ -1563,23 +1843,63 @@ version = "10.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0529410abe238729a60b108898784df8984c87f6054c9c4fcacc47e4803c1ce1" dependencies = [ - "base64", + "base64 0.22.1", "ed25519-dalek", "getrandom 0.2.17", "hmac", "js-sys", "p256", "p384", - "pem", + "pem 3.0.6", "rand", - "rsa", + "rsa 0.9.10", "serde", "serde_json", "sha2", - "signature", + "signature 2.2.0", "simple_asn1", ] +[[package]] +name = "jwt-simple" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357892bb32159d763abdea50733fadcb9a8e1c319a9aa77592db8555d05af83e" +dependencies = [ + "anyhow", + "binstring", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand", + "rsa 0.7.2", + "serde", + "serde_json", + "spki 0.6.0", + "thiserror 1.0.69", + "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 2.2.0", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1601,13 +1921,23 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" +[[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.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags", + "bitflags 2.10.0", "libc", "redox_syscall 0.7.0", ] @@ -1622,6 +1952,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d118bbf3771060e7311cc7bb0545b01d08a8b4a7de949198dec1fa0ca1c0f7" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" @@ -1713,7 +2055,7 @@ dependencies = [ "bytes", "encoding_rs", "futures-util", - "http", + "http 1.4.0", "httparse", "memchr", "mime", @@ -1744,7 +2086,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1838,7 +2180,7 @@ version = "0.10.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" dependencies = [ - "bitflags", + "bitflags 2.10.0", "cfg-if", "foreign-types", "libc", @@ -1962,16 +2304,36 @@ dependencies = [ "windows-link", ] +[[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", + "base64 0.22.1", "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d159833a9105500e0398934e205e0773f0b27529557134ecfc51c27646adac" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2014,6 +2376,26 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2026,15 +2408,37 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff33bdbdfc54cc98a2eca766ebdec3e1b8fb7387523d5c9c9a2891da856f719" +dependencies = [ + "der 0.6.1", + "pkcs8 0.9.0", + "spki 0.6.0", + "zeroize", +] + [[package]] name = "pkcs1" version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eca2c590a5f85da82668fa685c09ce2888b9430e83299debf1f34b65fd4a4ba" +dependencies = [ + "der 0.6.1", + "spki 0.6.0", ] [[package]] @@ -2043,8 +2447,8 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", ] [[package]] @@ -2053,6 +2457,22 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[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 = "potential_utf" version = "0.1.4" @@ -2216,7 +2636,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2225,7 +2645,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" dependencies = [ - "bitflags", + "bitflags 2.10.0", ] [[package]] @@ -2272,12 +2692,12 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "h2", - "http", + "http 1.4.0", "http-body", "http-body-util", "hyper", @@ -2359,22 +2779,43 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "rsa" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "094052d5470cbcef561cb848a7209968c9f12dfa6d668f4bca048ac5de51099c" +dependencies = [ + "byteorder", + "digest", + "num-bigint-dig", + "num-integer", + "num-iter", + "num-traits", + "pkcs1 0.4.1", + "pkcs8 0.9.0", + "rand_core", + "signature 1.6.4", + "smallvec", + "subtle", + "zeroize", +] + [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest", "num-bigint-dig", "num-integer", "num-traits", - "pkcs1", - "pkcs8", + "pkcs1 0.7.5", + "pkcs8 0.10.2", "rand_core", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -2410,7 +2851,7 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys", @@ -2650,20 +3091,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ "base16ct", - "der", + "der 0.7.10", "generic-array", - "pkcs8", + "pkcs8 0.10.2", "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 = "security-framework" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "core-foundation-sys", "libc", @@ -2799,6 +3251,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +dependencies = [ + "digest", + "rand_core", +] + [[package]] name = "signature" version = "2.2.0" @@ -2839,6 +3301,17 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[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.15.1" @@ -2867,6 +3340,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67cf02bbac7a337dc36e4f5a693db6c21e7863f45070f7064577eb4367a3212b" +dependencies = [ + "base64ct", + "der 0.6.1", +] + [[package]] name = "spki" version = "0.7.3" @@ -2874,7 +3357,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", ] [[package]] @@ -2896,14 +3379,14 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "base64", + "base64 0.22.1", "bigdecimal", "bytes", "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.4.1", "futures-core", "futures-intrusive", "futures-io", @@ -2976,9 +3459,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.10.0", "byteorder", "bytes", "chrono", @@ -3001,7 +3484,7 @@ dependencies = [ "once_cell", "percent-encoding", "rand", - "rsa", + "rsa 0.9.10", "rust_decimal", "serde", "sha1", @@ -3023,9 +3506,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bigdecimal", - "bitflags", + "bitflags 2.10.0", "byteorder", "chrono", "crc", @@ -3179,6 +3662,18 @@ dependencies = [ "futures-core", ] +[[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.2" @@ -3196,7 +3691,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.10.0", "core-foundation", "system-configuration-sys", ] @@ -3223,7 +3718,7 @@ version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" dependencies = [ - "fastrand", + "fastrand 2.3.0", "getrandom 0.3.4", "once_cell", "rustix", @@ -3487,9 +3982,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", - "http", + "http 1.4.0", "http-body", "http-body-util", "pin-project-lite", @@ -3503,10 +3998,10 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.10.0", "bytes", "futures-util", - "http", + "http 1.4.0", "http-body", "iri-string", "pin-project-lite", @@ -3560,6 +4055,16 @@ dependencies = [ "valuable", ] +[[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 = "tracing-log" version = "0.2.0" @@ -3604,7 +4109,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand", @@ -3718,6 +4223,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 = "want" version = "0.3.1" @@ -3748,6 +4259,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasix" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +dependencies = [ + "wasi", +] + [[package]] name = "wasm-bindgen" version = "0.2.108" @@ -3807,6 +4327,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-push" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2332e5400bb42c21bcab3ca2cd3400ab4b1d5ecbe276b533ce9acb59c56602" +dependencies = [ + "async-trait", + "base64 0.13.1", + "chrono", + "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 = "web-sys" version = "0.3.85" @@ -3933,6 +4475,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" @@ -4193,7 +4744,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure", + "synstructure 0.13.2", ] [[package]] @@ -4234,7 +4785,7 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.114", - "synstructure", + "synstructure 0.13.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index e7a13dc..9878cd9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,8 @@ tokio-cron-scheduler = "0.15.1" dashmap = "6.1.0" jsonwebtoken = { version = "10.3.0", features = ["rsa", "rust_crypto"] } base64 = "0.22.1" +web-push = { version = "0.10.0", features = ["isahc-client"] } +isahc = "1.7" axum-extra = { version = "0.9", features = ["typed-header", "cookie"] } cookie = "0.18" thiserror = "2.0.18" diff --git a/frontend/index.html b/frontend/index.html index 85feebd..edcefbf 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,7 +10,6 @@ Antigravity Agent Dashboard - Re-run Task + diff --git a/frontend/src/main.js b/frontend/src/main.js index 53bcf50..9c2dedf 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,5 +1,6 @@ import { marked } from 'marked'; import DOMPurify from 'dompurify'; +import './style.css'; const API_URL = '/api'; // These should ideally be environment-specific @@ -244,6 +245,19 @@ function selectTask(id, runId = null) { renderRunHistory(task); showTaskView(task); + // Fetch and update subscription status + fetch(`${API_URL}/tasks/${id}/subscription`) + .then(res => res.json()) + .then(data => { + const btn = document.getElementById('notify-task-btn'); + if (data.isSubscribed) { + btn.classList.add('notified'); + } else { + btn.classList.remove('notified'); + } + }) + .catch(err => console.error('Failed to fetch subscription status', err)); + // Close sidebar on mobile after selection if (window.innerWidth <= 768) { closeMobileMenu(); @@ -802,7 +816,68 @@ if (window.location.pathname === '/callback' || window.location.search.includes( if ('serviceWorker' in navigator) { window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js') - .then(reg => console.log('SW registered', reg)) + .then(reg => { + console.log('SW registered', reg); + setupPush(reg); + }) .catch(err => console.error('SW registration failed', err)); }); } + +async function setupPush(registration) { + try { + const vapidResponse = await fetch(`${API_URL}/notifications/vapid-key`); + const { publicKey } = await vapidResponse.json(); + + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey) + }); + + await fetch(`${API_URL}/notifications/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + endpoint: subscription.endpoint, + p256dh: b64(subscription.getKey('p256dh')), + auth: b64(subscription.getKey('auth')) + }) + }); + console.log('Push registered'); + } catch (err) { + console.warn('Push registration failed:', err); + } +} + +function b64(buffer) { + return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer))); +} + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/\-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + return outputArray; +} + +async function toggleTaskSubscription(taskId) { + const btn = document.getElementById('notify-task-btn'); + const isNotified = btn.classList.contains('notified'); + const method = isNotified ? 'DELETE' : 'POST'; + + try { + await fetch(`${API_URL}/tasks/${taskId}/subscribe`, { method }); + btn.classList.toggle('notified'); + showToast(isNotified ? 'Notifications disabled' : 'Notifications enabled'); + } catch (err) { + showToast('Failed to update notifications'); + } +} + +document.getElementById('notify-task-btn').addEventListener('click', () => { + if (state.selectedTaskId) toggleTaskSubscription(state.selectedTaskId); +}); diff --git a/frontend/src/style.css b/frontend/src/style.css index be8c314..c2c08b6 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -1101,6 +1101,37 @@ textarea:focus { } /* Responsive Styles */ +#notify-task-btn.notified { + color: var(--primary); + background: var(--primary-glow); +} + +#notify-task-btn.notified .notify-icon { + animation: ring 0.5s ease; +} + +@keyframes ring { + 0% { + transform: rotate(0); + } + + 25% { + transform: rotate(15deg); + } + + 50% { + transform: rotate(-15deg); + } + + 75% { + transform: rotate(10deg); + } + + 100% { + transform: rotate(0); + } +} + @media (max-width: 768px) { .menu-toggle { display: flex; diff --git a/frontend/sw.js b/frontend/sw.js index c58b0f7..c5efb91 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -24,3 +24,28 @@ self.addEventListener('fetch', (event) => { }) ); }); +self.addEventListener('push', (event) => { + const data = event.data ? event.data.json() : { title: 'Notification', body: 'New update from Agency' }; + + const options = { + body: data.body, + icon: '/icon-192.png', + badge: '/icon-192.png', + vibrate: [100, 50, 100], + data: { + dateOfArrival: Date.now(), + primaryKey: '1' + } + }; + + event.waitUntil( + self.registration.showNotification(data.title, options) + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + event.waitUntil( + clients.openWindow('/') + ); +}); diff --git a/migration/src/lib.rs b/migration/src/lib.rs index e206140..cad08e0 100644 --- a/migration/src/lib.rs +++ b/migration/src/lib.rs @@ -4,6 +4,7 @@ mod m20220101_000001_create_table; mod m20260210_000002_add_answer_column; mod m20260210_000003_separate_runs; mod m20260210_000004_add_cron_column; +mod m20260212_000005_notifications; pub struct Migrator; @@ -15,6 +16,7 @@ impl MigratorTrait for Migrator { Box::new(m20260210_000002_add_answer_column::Migration), Box::new(m20260210_000003_separate_runs::Migration), Box::new(m20260210_000004_add_cron_column::Migration), + Box::new(m20260212_000005_notifications::Migration), ] } } diff --git a/migration/src/m20260212_000005_notifications.rs b/migration/src/m20260212_000005_notifications.rs new file mode 100644 index 0000000..fdfdaac --- /dev/null +++ b/migration/src/m20260212_000005_notifications.rs @@ -0,0 +1,115 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + // Push Subscriptions table + manager + .create_table( + Table::create() + .table(PushSubscriptions::Table) + .if_not_exists() + .col( + ColumnDef::new(PushSubscriptions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(PushSubscriptions::UserSub) + .string() + .not_null(), + ) + .col( + ColumnDef::new(PushSubscriptions::Endpoint) + .string() + .not_null(), + ) + .col( + ColumnDef::new(PushSubscriptions::P256dh) + .string() + .not_null(), + ) + .col(ColumnDef::new(PushSubscriptions::Auth).string().not_null()) + .col( + ColumnDef::new(PushSubscriptions::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .to_owned(), + ) + .await?; + + // Task Subscriptions table + manager + .create_table( + Table::create() + .table(TaskSubscriptions::Table) + .if_not_exists() + .col( + ColumnDef::new(TaskSubscriptions::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col( + ColumnDef::new(TaskSubscriptions::UserSub) + .string() + .not_null(), + ) + .col(ColumnDef::new(TaskSubscriptions::TaskId).uuid().not_null()) + .col( + ColumnDef::new(TaskSubscriptions::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk-task-subscription-task-id") + .from(TaskSubscriptions::Table, TaskSubscriptions::TaskId) + .to(Tasks::Table, Tasks::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(TaskSubscriptions::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(PushSubscriptions::Table).to_owned()) + .await + } +} + +#[derive(DeriveIden)] +enum PushSubscriptions { + Table, + Id, + UserSub, + Endpoint, + P256dh, + Auth, + CreatedAt, +} + +#[derive(DeriveIden)] +enum TaskSubscriptions { + Table, + Id, + UserSub, + TaskId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Tasks { + Table, + Id, +} diff --git a/src/config.rs b/src/config.rs index c7a714e..5a1d6d6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,8 @@ pub struct Config { pub cookie_secure: bool, pub agent_max_turns: u32, pub agent_max_duration_secs: u64, + pub vapid_private_key: String, + pub vapid_public_key: String, } impl Config { @@ -54,6 +56,11 @@ impl Config { .and_then(|s| s.parse().ok()) .unwrap_or(120); + let vapid_private_key = env::var("VAPID_PRIVATE_KEY") + .map_err(|_| AppError::Config("VAPID_PRIVATE_KEY must be set".into()))?; + let vapid_public_key = env::var("VAPID_PUBLIC_KEY") + .map_err(|_| AppError::Config("VAPID_PUBLIC_KEY must be set".into()))?; + Ok(Config { database_url, port, @@ -66,6 +73,8 @@ impl Config { cookie_secure, agent_max_turns, agent_max_duration_secs, + vapid_private_key, + vapid_public_key, }) } } diff --git a/src/domain/mod.rs b/src/domain/mod.rs index 7982060..70e887f 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -1,3 +1,4 @@ pub mod agent; pub mod auth; +pub mod notifications; pub mod tasks; diff --git a/src/domain/notifications/mod.rs b/src/domain/notifications/mod.rs new file mode 100644 index 0000000..ec19098 --- /dev/null +++ b/src/domain/notifications/mod.rs @@ -0,0 +1 @@ +pub mod push; diff --git a/src/domain/notifications/push.rs b/src/domain/notifications/push.rs new file mode 100644 index 0000000..5aaa54a --- /dev/null +++ b/src/domain/notifications/push.rs @@ -0,0 +1,101 @@ +use crate::error::AppResult; +use serde::{Deserialize, Serialize}; +use web_push::*; + +pub struct PushSender { + pub private_key: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct PushSubscription { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +fn ensure_pem(input: &str) -> String { + let input = input.trim(); + if input.contains("-----BEGIN") { + return input.to_string(); + } + + if input.starts_with("MHc") { + format!( + "-----BEGIN EC PRIVATE KEY-----\n{}\n-----END EC PRIVATE KEY-----", + input + ) + } else { + format!( + "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----", + input + ) + } +} + +impl PushSender { + pub fn new(private_key_pem: &str) -> AppResult { + let pem = ensure_pem(private_key_pem); + // Validate key immediately to catch config errors early + let _ = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&pem)).map_err(|e| { + crate::error::AppError::Internal(format!("Invalid VAPID private key: {}", e)) + })?; + + Ok(Self { private_key: pem }) + } + + pub fn get_public_key(&self) -> AppResult> { + let builder = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + Ok(builder.get_public_key()) + } + + pub async fn send_notification( + &self, + subscription: &PushSubscription, + title: &str, + body: &str, + ) -> AppResult<()> { + let subscription_info = SubscriptionInfo::new( + subscription.endpoint.clone(), + subscription.p256dh.clone(), + subscription.auth.clone(), + ); + + let builder = + VapidSignatureBuilder::from_pem_no_sub(std::io::Cursor::new(&self.private_key)) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let vapid_signature = builder + .add_sub_info(&subscription_info) + .build() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let mut builder = WebPushMessageBuilder::new(&subscription_info); + + builder.set_vapid_signature(vapid_signature); + + let payload = serde_json::to_vec(&serde_json::json!({ + "title": title, + "body": body, + })) + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + builder.set_payload(ContentEncoding::Aes128Gcm, &payload); + + let message = builder + .build() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + let client = IsahcWebPushClient::new() + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + client + .send(message) + .await + .map_err(|e| crate::error::AppError::Internal(e.to_string()))?; + + Ok(()) + } +} diff --git a/src/domain/tasks.rs b/src/domain/tasks.rs index 6e7962d..65d3090 100644 --- a/src/domain/tasks.rs +++ b/src/domain/tasks.rs @@ -1,5 +1,5 @@ use chrono::Utc; -use sea_orm::{ActiveModelTrait, DatabaseConnection, EntityTrait, Set}; +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use uuid::Uuid; @@ -8,6 +8,7 @@ use crate::config::Config; use crate::domain::agent::Agent; use crate::entities::task::Entity as Task; use crate::entities::task_run::{self, Entity as TaskRun}; +use crate::entities::{push_subscription, task_subscription}; use crate::scheduler::Scheduler; #[derive(Deserialize)] @@ -119,7 +120,7 @@ pub async fn execute_agent_run( let mut run = run; run.logs = Set(logs.clone()); run.answer = Set(answer.clone()); - run.status = Set(status); + run.status = Set(status.clone()); run.update(db) .await @@ -132,6 +133,38 @@ pub async fn execute_agent_run( task_response.clone(), )); + // Send Push Notifications to subscribers + let subscriptions = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id)) + .all(db) + .await + .map_err(crate::error::AppError::Database)?; + + for sub in subscriptions { + let push_subs = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .all(db) + .await + .map_err(crate::error::AppError::Database)?; + + for push_sub in push_subs { + let sender = _scheduler.push_sender.clone(); + let goal = task_response.goal.clone(); + let status = status.clone(); + let sub_data = crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; + + tokio::spawn(async move { + let _ = sender + .send_notification(&sub_data, &format!("Task Completed: {}", status), &goal) + .await; + }); + } + } + Ok(task_response) } diff --git a/src/entities/mod.rs b/src/entities/mod.rs index 7ff42bf..304ddb0 100644 --- a/src/entities/mod.rs +++ b/src/entities/mod.rs @@ -1,2 +1,4 @@ +pub mod push_subscription; pub mod task; pub mod task_run; +pub mod task_subscription; diff --git a/src/entities/push_subscription.rs b/src/entities/push_subscription.rs new file mode 100644 index 0000000..c306114 --- /dev/null +++ b/src/entities/push_subscription.rs @@ -0,0 +1,19 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "push_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub endpoint: String, + pub p256dh: String, + pub auth: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entities/task_subscription.rs b/src/entities/task_subscription.rs new file mode 100644 index 0000000..b7011ee --- /dev/null +++ b/src/entities/task_subscription.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)] +#[sea_orm(table_name = "task_subscriptions")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub user_sub: String, + pub task_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::task::Entity", + from = "Column::TaskId", + to = "super::task::Column::Id", + on_update = "NoAction", + on_delete = "Cascade" + )] + Task, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Task.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/scheduler.rs b/src/scheduler.rs index 9aad91b..3b5b0a8 100644 --- a/src/scheduler.rs +++ b/src/scheduler.rs @@ -14,6 +14,7 @@ pub struct Scheduler { tasks_to_jobs: DashMap, config: Arc, pub tx: tokio::sync::broadcast::Sender, + pub push_sender: Arc, } impl Scheduler { @@ -21,6 +22,7 @@ impl Scheduler { db: DatabaseConnection, config: Arc, tx: tokio::sync::broadcast::Sender, + push_sender: Arc, ) -> AppResult { let scheduler = JobScheduler::new() .await @@ -35,6 +37,7 @@ impl Scheduler { tasks_to_jobs: DashMap::new(), config, tx, + push_sender, }) } @@ -47,13 +50,15 @@ impl Scheduler { let db = self.db.clone(); let config = self.config.clone(); let tx = self.tx.clone(); + let push_sender = self.push_sender.clone(); let job = Job::new_async(cron_expr, move |_uuid, _l| { let db = db.clone(); let config = config.clone(); let tx = tx.clone(); + let push_sender = push_sender.clone(); Box::pin(async move { - if let Err(e) = Self::run_task(db, config, tx, task_id).await { + if let Err(e) = Self::run_task(db, config, tx, push_sender, task_id).await { tracing::error!("Error in scheduled task {}: {}", task_id, e); } }) @@ -87,6 +92,7 @@ impl Scheduler { db: DatabaseConnection, config: Arc, tx: tokio::sync::broadcast::Sender, + push_sender: Arc, task_id: Uuid, ) -> AppResult<()> { let task = Task::find_by_id(task_id) @@ -147,7 +153,7 @@ impl Scheduler { let run_complete = task_run::ActiveModel { id: Set(run_id), - status: Set(status), + status: Set(status.clone()), logs: Set(logs), answer: Set(answer), ..Default::default() @@ -162,8 +168,46 @@ impl Scheduler { if let Ok(task_response) = crate::domain::tasks::get_task_inner(task_id, &db).await { let _ = tx.send(crate::server::notifications::WsEvent::RunFinished( - task_response, + task_response.clone(), )); + + // Push notifications for scheduled runs + use crate::entities::{push_subscription, task_subscription}; + use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; + if let Ok(subscriptions) = task_subscription::Entity::find() + .filter(task_subscription::Column::TaskId.eq(task_id)) + .all(&db) + .await + { + for sub in subscriptions { + if let Ok(push_subs) = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(sub.user_sub)) + .all(&db) + .await + { + for push_sub in push_subs { + let sender = push_sender.clone(); + let sub_data = + crate::domain::notifications::push::PushSubscription { + endpoint: push_sub.endpoint, + p256dh: push_sub.p256dh, + auth: push_sub.auth, + }; + let goal = task_response.goal.clone(); + let status = status.clone(); + tokio::spawn(async move { + let _ = sender + .send_notification( + &sub_data, + &format!("Scheduled Task Completed: {}", status), + &goal, + ) + .await; + }); + } + } + } + } } }); diff --git a/src/server/mod.rs b/src/server/mod.rs index 6979d6e..6a9d998 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -35,8 +35,12 @@ pub async fn start(config: crate::config::Config) -> AppResult<()> { let (tx, _) = tokio::sync::broadcast::channel(100); + let push_sender = Arc::new(crate::domain::notifications::push::PushSender::new( + &config.vapid_private_key.clone(), + )?); + let scheduler = Arc::new( - Scheduler::new(db.clone(), config.clone(), tx.clone()) + Scheduler::new(db.clone(), config.clone(), tx.clone(), push_sender.clone()) .await .map_err(|e| crate::error::AppError::Internal(e.to_string()))?, ); @@ -128,6 +132,10 @@ fn build_app(state: Arc, config: &crate::config::Config) -> Router { .route("/api/auth/logout", post(auth::auth_logout)) .route("/api/chat", post(chat::chat_handler)) .route("/api/ws", get(notifications::ws_handler)) + .route("/api/notifications/register", post(notifications::push_handlers::register_push)) + .route("/api/notifications/vapid-key", get(notifications::push_handlers::get_vapid_key)) + .route("/api/tasks/:id/subscription", get(notifications::push_handlers::get_subscription_status)) + .route("/api/tasks/:id/subscribe", post(notifications::push_handlers::subscribe_task).delete(notifications::push_handlers::unsubscribe_task)) .layer(cors) .layer(tower_http::set_header::SetResponseHeaderLayer::overriding( axum::http::header::CONTENT_SECURITY_POLICY, diff --git a/src/server/notifications.rs b/src/server/notifications.rs index dcc62a3..0786d0f 100644 --- a/src/server/notifications.rs +++ b/src/server/notifications.rs @@ -1,3 +1,5 @@ +pub mod push_handlers; + use crate::domain::tasks::{RecentRunResponse, TaskResponse}; use crate::server::AppState; use axum::{ diff --git a/src/server/notifications/push_handlers.rs b/src/server/notifications/push_handlers.rs new file mode 100644 index 0000000..00860c4 --- /dev/null +++ b/src/server/notifications/push_handlers.rs @@ -0,0 +1,128 @@ +use axum::{ + Json, + extract::{Path, State}, +}; +use chrono::Utc; +use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set}; +use std::sync::Arc; +use uuid::Uuid; + +use crate::entities::{push_subscription, task_subscription}; +use crate::error::{AppError, AppResult}; +use crate::server::AppState; +use crate::server::auth::AuthenticatedUser; +use base64::Engine; + +#[derive(serde::Deserialize)] +pub struct RegisterPushRequest { + pub endpoint: String, + pub p256dh: String, + pub auth: String, +} + +pub async fn register_push( + State(state): State>, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + let user_sub = user.0.sub; + + // Check if subscription exists + let existing = push_subscription::Entity::find() + .filter(push_subscription::Column::UserSub.eq(user_sub.clone())) + .filter(push_subscription::Column::Endpoint.eq(payload.endpoint.clone())) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + if existing.is_none() { + let new_sub = push_subscription::ActiveModel { + id: Set(Uuid::new_v4()), + user_sub: Set(user_sub), + endpoint: Set(payload.endpoint), + p256dh: Set(payload.p256dh), + auth: Set(payload.auth), + created_at: Set(Utc::now().into()), + }; + new_sub + .insert(&state.db) + .await + .map_err(AppError::Database)?; + } + + Ok(Json(serde_json::json!({ "status": "registered" }))) +} + +pub async fn subscribe_task( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + let existing = task_subscription::Entity::find() + .filter(task_subscription::Column::UserSub.eq(user_sub.clone())) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + if existing.is_none() { + let new_sub = task_subscription::ActiveModel { + id: Set(Uuid::new_v4()), + user_sub: Set(user_sub), + task_id: Set(task_id), + created_at: Set(Utc::now().into()), + }; + new_sub + .insert(&state.db) + .await + .map_err(AppError::Database)?; + } + + Ok(Json(serde_json::json!({ "status": "subscribed" }))) +} + +pub async fn unsubscribe_task( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + task_subscription::Entity::delete_many() + .filter(task_subscription::Column::UserSub.eq(user_sub)) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .exec(&state.db) + .await + .map_err(AppError::Database)?; + + Ok(Json(serde_json::json!({ "status": "unsubscribed" }))) +} + +pub async fn get_vapid_key( + State(state): State>, +) -> AppResult> { + let public_key = state.scheduler.push_sender.get_public_key()?; + let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(public_key); + Ok(Json(serde_json::json!({ "publicKey": encoded }))) +} + +pub async fn get_subscription_status( + State(state): State>, + user: AuthenticatedUser, + Path(task_id): Path, +) -> AppResult> { + let user_sub = user.0.sub; + + let existing = task_subscription::Entity::find() + .filter(task_subscription::Column::UserSub.eq(user_sub)) + .filter(task_subscription::Column::TaskId.eq(task_id)) + .one(&state.db) + .await + .map_err(AppError::Database)?; + + Ok(Json( + serde_json::json!({ "isSubscribed": existing.is_some() }), + )) +}