diff --git a/Cargo.lock b/Cargo.lock index ba6605d..46897a6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,62 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher 0.4.4", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb277bec05f56a0e0591f155a484cbd0f4f07ff2905051a48c72f004f7ed58" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes 0.8.4", + "cipher 0.4.4", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "aes-keywrap" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10b6f24a1f796bc46415a1d0d18dc0a8203ccba088acf5def3291c4f61225522" +dependencies = [ + "aes 0.9.2", + "byteorder", +] + [[package]] name = "ahash" version = "0.8.12" @@ -105,9 +161,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -157,7 +213,7 @@ dependencies = [ "proc-macro2", "quote", "rustc-hash", - "syn", + "syn 2.0.117", ] [[package]] @@ -188,7 +244,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -273,6 +329,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base64" version = "0.22.1" @@ -285,6 +347,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "binstring" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0669d5a35b64fdb5ab7fb19cae13148b6b5cbdf4b8247faf54ece47f699c8cef" + [[package]] name = "bitflags" version = "2.11.1" @@ -300,7 +368,18 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", ] [[package]] @@ -326,6 +405,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -402,6 +490,26 @@ dependencies = [ "phf 0.12.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.6.1" @@ -433,7 +541,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -442,6 +550,23 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[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 = "codemap" version = "0.1.3" @@ -469,6 +594,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.4.2" @@ -561,7 +692,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -606,9 +737,15 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -666,6 +803,18 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -673,9 +822,45 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "getrandom 0.4.2", + "hybrid-array", + "rand_core 0.10.1", +] + +[[package]] +name = "ct-codecs" +version = "1.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49fb0c6640b4507ebd99ff67677009e381ba5eee1d14df78de4a3d16eb123c39" + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher 0.4.4", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.11" @@ -707,7 +892,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -720,7 +905,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", ] [[package]] @@ -731,7 +916,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -742,7 +927,7 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -751,11 +936,21 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.8" @@ -784,7 +979,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -794,7 +989,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", ] [[package]] @@ -816,7 +1011,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.117", "unicode-xid", ] @@ -826,12 +1021,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -840,7 +1047,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -849,6 +1056,41 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature 2.2.0", + "spki 0.7.3", +] + +[[package]] +name = "ece-native" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6911cc97c62febd8cbcb2cb49ed21c58ccd418a80c43f20df4973ee08484e65d" +dependencies = [ + "aes-gcm", + "hkdf 0.12.4", + "sha2 0.10.9", +] + +[[package]] +name = "ed25519-compact" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c24599140dc39d7a81e4476e7573d41bbc18e07c803900298e522a5fbcfbfb6" +dependencies = [ + "ct-codecs", + "getrandom 0.4.2", +] + [[package]] name = "either" version = "1.15.0" @@ -858,6 +1100,27 @@ dependencies = [ "serde", ] +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "hkdf 0.12.4", + "pem-rfc7468", + "pkcs8 0.10.2", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "email_address" version = "0.2.9" @@ -923,6 +1186,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1038,7 +1311,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1078,6 +1351,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1114,10 +1388,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", +] + +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", ] [[package]] @@ -1149,6 +1436,17 @@ dependencies = [ "phf 0.11.3", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1203,7 +1501,16 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", ] [[package]] @@ -1212,7 +1519,40 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[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.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac-sha512" +version = "1.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "019ece39bbefc17f13f677a690328cb978dbf6790e141a3c24e66372cb38588b" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1226,9 +1566,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1275,6 +1615,16 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "ctutils", + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1520,7 +1870,25 @@ checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", ] [[package]] @@ -1563,6 +1931,56 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jwt-simple" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06a4207b9d1f423858853fb9ee8b34aebc2b661f3b446ca4efda46c4b3700a2f" +dependencies = [ + "anyhow", + "binstring", + "blake2b_simd", + "coarsetime", + "ct-codecs", + "ed25519-compact", + "hmac-sha1-compact", + "hmac-sha256", + "hmac-sha512", + "k256", + "p256", + "p384", + "rand 0.8.6", + "serde", + "serde_json", + "superboring", + "thiserror", + "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 0.10.9", + "signature 2.2.0", +] + +[[package]] +name = "keccak" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd9697dc4a9a62e2da93389f34400b77a28f0287711263cabb203b3ccb9c0e4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", +] + [[package]] name = "lasso" version = "0.7.3" @@ -1670,7 +2088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", ] [[package]] @@ -1716,6 +2134,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.0-rc.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163f15320f3fba11760c373af52d7f69d638482c2c350d877fb06513b1c3137c" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "sha3", + "signature 3.0.0", +] + +[[package]] +name = "module-lattice" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c61b87c9683ab7cb1c6871d261ad5479b6b10ceb52c4352aaca3b5d35a8febe" +dependencies = [ + "ctutils", + "hybrid-array", + "num-traits", +] + [[package]] name = "moxcms" version = "0.8.1" @@ -1825,6 +2270,36 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[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 0.10.9", +] + [[package]] name = "parking" version = "2.2.1" @@ -1931,7 +2406,7 @@ dependencies = [ "phf_shared 0.11.3", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1964,9 +2439,9 @@ 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]] @@ -1975,8 +2450,18 @@ 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]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -2004,6 +2489,18 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2035,7 +2532,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", ] [[package]] @@ -2203,6 +2709,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -2276,6 +2788,16 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -2296,16 +2818,17 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "sha2 0.10.9", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] @@ -2405,6 +2928,20 @@ dependencies = [ "sqlx", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "semver" version = "1.0.28" @@ -2413,9 +2950,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -2423,22 +2960,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -2456,9 +2993,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -2507,7 +3044,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", ] [[package]] @@ -2518,7 +3055,28 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sha3" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" +dependencies = [ + "digest 0.11.3", + "keccak", ] [[package]] @@ -2552,10 +3110,20 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -2609,7 +3177,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -2651,7 +3229,7 @@ dependencies = [ "percent-encoding", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror", "tokio", @@ -2670,7 +3248,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2688,12 +3266,12 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2711,7 +3289,7 @@ dependencies = [ "bytes", "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -2720,8 +3298,8 @@ dependencies = [ "futures-util", "generic-array", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "itoa", "log", "md-5", @@ -2732,7 +3310,7 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2759,8 +3337,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.12.4", + "hmac 0.12.1", "home", "itoa", "log", @@ -2770,7 +3348,7 @@ dependencies = [ "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", @@ -2833,6 +3411,22 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "superboring" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32799c1e73c2c5467e43bfaab8a9e28c5feed8b5ea59beb53376977f39971ead" +dependencies = [ + "aes-gcm", + "aes-keywrap", + "getrandom 0.2.17", + "hmac-sha256", + "hmac-sha512", + "ml-dsa", + "rand 0.8.6", + "rsa", +] + [[package]] name = "syn" version = "2.0.117" @@ -2844,6 +3438,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2861,7 +3466,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2881,7 +3486,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2973,7 +3578,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3171,7 +3776,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3270,6 +3875,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -3375,6 +3990,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasix" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" +dependencies = [ + "wasi", +] + [[package]] name = "wasm-bindgen" version = "0.2.120" @@ -3417,7 +4041,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3466,8 +4090,9 @@ dependencies = [ [[package]] name = "web-petting" -version = "1.0.1" +version = "1.0.2" dependencies = [ + "async-trait", "base64", "chrono", "chrono-tz", @@ -3485,6 +4110,24 @@ dependencies = [ "tracing-subscriber", "urlencoding", "uuid", + "web-push-native", +] + +[[package]] +name = "web-push-native" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7628c93e39aeee4c0bca50ae333052b4f174c653e8fca1d0a8e34f9bc4879c0c" +dependencies = [ + "aes-gcm", + "base64ct", + "ece-native", + "hkdf 0.13.0", + "http", + "jwt-simple", + "p256", + "serde", + "sha2 0.11.0", ] [[package]] @@ -3547,7 +4190,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3558,7 +4201,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3852,7 +4495,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3868,7 +4511,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3935,7 +4578,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3956,7 +4599,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3976,15 +4619,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -4016,7 +4659,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index ef1f9c0..ace51da 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ name = "web-petting" version = "1.0.2" edition = "2024" +default-run = "web-petting" [dependencies] cot = { version = "0.6.0", default-features = false, features = ["postgres", "json"] } @@ -15,9 +16,11 @@ serde_json = "1" multer = "3" futures = "0.3" image = { version = "0.25", default-features = false, features = ["jpeg", "png", "webp"] } -tokio = { version = "1", features = ["fs"] } +tokio = { version = "1", features = ["fs", "rt-multi-thread"] } uuid = { version = "1", features = ["v4"] } base64 = "0.22" urlencoding = "2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +web-push-native = "0.5" +async-trait = "0.1" diff --git a/src/admin.rs b/src/admin.rs index 8adf93d..c4e9649 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -15,7 +15,7 @@ use serde::Deserialize; use std::io::Cursor; use crate::i18n::{Lang, Translations}; -use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; +use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit}; use crate::telegram; const SESSION_USER_ID: &str = "user_id"; @@ -155,6 +155,9 @@ async fn save_uploaded_image( crate::uploads::write_db_file(&path, &encoded) .await .map_err(|e| cot::Error::internal(e.to_string()))?; + crate::uploads::ensure_thumbnail(&path) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; Ok(path) } else { let path = crate::uploads::join_db_path(upload_dir, &format!("{file_id}.{ext}")); @@ -336,6 +339,54 @@ struct SettingsTemplate<'a> { saved: bool, auth_password_checked: bool, auth_sso_checked: bool, + client_notifications_checked: bool, + push_subscribers: Vec, +} + +#[derive(Debug)] +struct PushSubscriberItem { + client_name: String, + device_count: usize, + languages: String, + last_updated: String, +} + +async fn load_push_subscribers(db: &Database) -> cot::Result> { + let clients = Client::objects().all(db).await?; + let subscriptions = PushSubscription::objects().all(db).await?; + let mut items = Vec::new(); + for client in clients { + let client_id = client.id.unwrap(); + let active: Vec<_> = subscriptions + .iter() + .filter(|subscription| { + subscription.status == "active" + && subscription.client_id.primary_key().unwrap() == client_id + }) + .collect(); + if active.is_empty() { + continue; + } + let mut languages: Vec<&str> = active + .iter() + .map(|subscription| subscription.language.as_str()) + .collect(); + languages.sort_unstable(); + languages.dedup(); + let last_updated = active + .iter() + .map(|subscription| subscription.updated_at) + .max() + .unwrap(); + items.push(PushSubscriberItem { + client_name: client.name, + device_count: active.len(), + languages: languages.join(", "), + last_updated: last_updated.format("%d.%m.%Y %H:%M").to_string(), + }); + } + items.sort_by(|a, b| a.client_name.cmp(&b.client_name)); + Ok(items) } #[derive(Debug, Template)] @@ -1196,6 +1247,11 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot: .find(|s| s.key == "auth_sso_enabled") .map(|s| s.value == "true") .unwrap_or(false); + let client_notifications_checked = settings + .iter() + .find(|s| s.key == "client_notifications_enabled") + .map(|s| s.value == "true") + .unwrap_or(false); let body = SettingsTemplate { t: lang.t(), lang, @@ -1204,6 +1260,8 @@ async fn settings_page(request: Request, session: Session, db: Database) -> cot: saved: false, auth_password_checked, auth_sso_checked, + client_notifications_checked, + push_subscribers: load_push_subscribers(&db).await?, } .render()?; html_response(body, lang) @@ -1283,10 +1341,15 @@ struct SettingsForm { oidc_client_id: String, oidc_client_secret: String, oidc_allowed_groups: String, + vapid_public_key: String, + vapid_private_key: String, + vapid_subject: String, #[serde(default)] auth_password_enabled: Option, #[serde(default)] auth_sso_enabled: Option, + #[serde(default)] + client_notifications_enabled: Option, } async fn save_settings(request: Request, session: Session, db: Database) -> cot::Result { @@ -1296,6 +1359,20 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: Err(resp) => return Ok(resp), }; + let old_settings = Setting::objects().all(&db).await?; + let old_value = |key: &str| { + old_settings + .iter() + .find(|setting| setting.key == key) + .map(|setting| setting.value.as_str()) + .unwrap_or("") + }; + let had_vapid_keys = !old_value("vapid_public_key").trim().is_empty() + || !old_value("vapid_private_key").trim().is_empty(); + let vapid_keys_changed = had_vapid_keys + && (old_value("vapid_public_key").trim() != form.vapid_public_key.trim() + || old_value("vapid_private_key").trim() != form.vapid_private_key.trim()); + for (key, value) in [ ("telegram_bot_token", form.telegram_bot_token), ("contact_info", form.contact_info), @@ -1309,6 +1386,9 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: ("oidc_client_id", form.oidc_client_id), ("oidc_client_secret", form.oidc_client_secret), ("oidc_allowed_groups", form.oidc_allowed_groups), + ("vapid_public_key", form.vapid_public_key), + ("vapid_private_key", form.vapid_private_key), + ("vapid_subject", form.vapid_subject), ( "auth_password_enabled", if form.auth_password_enabled.is_some() { @@ -1325,6 +1405,14 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: "false".to_string() }, ), + ( + "client_notifications_enabled", + if form.client_notifications_enabled.is_some() { + "true".to_string() + } else { + "false".to_string() + }, + ), ] { let k = key.to_string(); let existing = query!(Setting, $key == k).get(&db).await?; @@ -1346,6 +1434,16 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: } } + if vapid_keys_changed { + for mut subscription in PushSubscription::objects().all(&db).await? { + if subscription.status == "active" { + subscription.status = "archived".to_string(); + subscription.updated_at = now_utc(); + subscription.save(&db).await?; + } + } + } + let settings = Setting::objects().all(&db).await?; let auth_password_checked = settings .iter() @@ -1357,6 +1455,11 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: .find(|s| s.key == "auth_sso_enabled") .map(|s| s.value == "true") .unwrap_or(false); + let client_notifications_checked = settings + .iter() + .find(|s| s.key == "client_notifications_enabled") + .map(|s| s.value == "true") + .unwrap_or(false); let rendered = SettingsTemplate { t: lang.t(), lang, @@ -1365,6 +1468,8 @@ async fn save_settings(request: Request, session: Session, db: Database) -> cot: saved: true, auth_password_checked, auth_sso_checked, + client_notifications_checked, + push_subscribers: load_push_subscribers(&db).await?, } .render()?; html_response(rendered, lang) @@ -1895,6 +2000,7 @@ async fn schedule_edit_submit( if visit.status == "deleted" { return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); } + let became_completed = visit.status != "completed" && form.status == "completed"; visit.user_id = ForeignKey::PrimaryKey(Auto::fixed(form.user_id)); if let Ok(d) = chrono::NaiveDate::parse_from_str(&form.visit_date, "%Y-%m-%d") { visit.visit_date = d; @@ -1906,6 +2012,9 @@ async fn schedule_edit_submit( visit.public_notes = form.public_notes.filter(|s| !s.trim().is_empty()); visit.updated_at = now_utc(); visit.save(&db).await?; + if became_completed { + crate::web_push::notify_visit_completed(&db, &visit).await; + } } Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response() } @@ -1942,9 +2051,13 @@ async fn visit_set_done( if visit.status == "deleted" { return Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response(); } + let became_completed = visit.status != "completed"; visit.status = "completed".to_string(); visit.updated_at = now_utc(); visit.save(&db).await?; + if became_completed { + crate::web_push::notify_visit_completed(&db, &visit).await; + } } Redirect::new(format!("/admin/schedule?lang={}", lang.code())).into_response() } @@ -2261,6 +2374,12 @@ async fn media_delete( "failed to remove uploaded file" ); } + let thumbnail_path = crate::uploads::thumbnail_db_path(&file_path); + if let Err(error) = crate::uploads::remove_db_file(&thumbnail_path).await { + if error.kind() != std::io::ErrorKind::NotFound { + tracing::warn!(%error, %thumbnail_path, "failed to remove media thumbnail"); + } + } } let redirect_url = referer .filter(|r| r.contains("/schedule/") && r.contains("/edit")) @@ -2268,6 +2387,44 @@ async fn media_delete( Redirect::new(redirect_url).into_response() } +async fn serve_upload_thumbnail( + request: Request, + session: Session, + db: Database, + Path(media_id): Path, +) -> cot::Result { + let lang = detect_lang(&request); + if require_auth(&session, lang).await.is_err() { + return Redirect::new(format!("/admin/login?lang={}", lang.code())).into_response(); + } + let media = match query!(Media, $id == media_id).get(&db).await? { + Some(media) if media.status == "active" && media.file_type == "photo" => media, + _ => return Html::new("404").into_response(), + }; + match crate::uploads::ensure_thumbnail(&media.file_path).await { + Ok(path) => { + let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None) + .await + .map_err(|error| cot::Error::internal(error.to_string()))?; + response.headers_mut().insert( + "cache-control", + "private, max-age=31536000, immutable".parse().unwrap(), + ); + Ok(response) + } + Err(error) => { + tracing::warn!(media_id, %error, "failed to create media thumbnail"); + crate::uploads::ranged_file_response( + &media.file_path, + crate::uploads::content_type_for_path(&media.file_path), + None, + ) + .await + .map_err(|error| cot::Error::internal(error.to_string())) + } + } +} + /// Serve uploaded files by media ID. async fn serve_upload( request: Request, @@ -2767,6 +2924,11 @@ pub fn admin_router() -> Router { "admin-media-delete", ), Route::with_handler_and_name("/uploads/{media_id}", serve_upload, "admin-uploads"), + Route::with_handler_and_name( + "/uploads/{media_id}/thumbnail", + serve_upload_thumbnail, + "admin-upload-thumbnail", + ), Route::with_handler_and_name("/testimonials", testimonials_page, "admin-testimonials"), Route::with_handler_and_name( "/testimonials/add", diff --git a/src/bin/generate_vapid.rs b/src/bin/generate_vapid.rs new file mode 100644 index 0000000..4960427 --- /dev/null +++ b/src/bin/generate_vapid.rs @@ -0,0 +1,23 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use web_push_native::jwt_simple::algorithms::ES256KeyPair; +use web_push_native::p256::SecretKey; +use web_push_native::p256::elliptic_curve::sec1::ToEncodedPoint; + +fn main() { + let key_pair = ES256KeyPair::generate(); + let private = key_pair.to_bytes(); + let public = SecretKey::from_slice(&private) + .expect("generated key must be valid") + .public_key() + .to_encoded_point(false); + println!( + "WEB_PETTING_VAPID_PRIVATE_KEY={}", + URL_SAFE_NO_PAD.encode(&private) + ); + println!( + "WEB_PETTING_VAPID_PUBLIC_KEY={}", + URL_SAFE_NO_PAD.encode(public.as_bytes()) + ); + println!("WEB_PETTING_VAPID_SUBJECT=mailto:admin@example.com"); +} diff --git a/src/i18n.rs b/src/i18n.rs index d1ef239..b0f4394 100644 --- a/src/i18n.rs +++ b/src/i18n.rs @@ -151,6 +151,19 @@ pub struct Translations { pub settings_section_captcha: &'static str, pub settings_section_oidc: &'static str, pub settings_section_general: &'static str, + pub settings_client_notifications_enabled: &'static str, + pub settings_client_notifications_help: &'static str, + pub settings_vapid_public_key: &'static str, + pub settings_vapid_private_key: &'static str, + pub settings_vapid_subject: &'static str, + pub settings_vapid_warning: &'static str, + pub settings_vapid_generate: &'static str, + pub settings_push_subscribers: &'static str, + pub settings_push_no_subscribers: &'static str, + pub settings_push_client: &'static str, + pub settings_push_devices: &'static str, + pub settings_push_language: &'static str, + pub settings_push_updated: &'static str, pub landing_contact_label: &'static str, pub landing_pricing_title: &'static str, @@ -292,6 +305,15 @@ pub struct Translations { pub portal_feedback_submit: &'static str, pub portal_feedback_thanks: &'static str, pub portal_link: &'static str, + pub portal_notifications: &'static str, + pub portal_notifications_text: &'static str, + pub portal_notifications_enable: &'static str, + pub portal_notifications_disable: &'static str, + pub portal_notifications_denied: &'static str, + pub portal_calendar: &'static str, + pub portal_future_visit: &'static str, + pub portal_previous: &'static str, + pub portal_next: &'static str, // Common pub no_value: &'static str, @@ -386,6 +408,19 @@ static RU: Translations = Translations { settings_section_captcha: "Защита от ботов", settings_section_oidc: "Единый вход (SSO / OIDC)", settings_section_general: "Сайт", + settings_client_notifications_enabled: "Разрешить клиентам браузерные уведомления", + settings_client_notifications_help: "Показывает клиентам настройку уведомлений о завершённых визитах.", + settings_vapid_public_key: "VAPID — публичный ключ", + settings_vapid_private_key: "VAPID — приватный ключ", + settings_vapid_subject: "VAPID — контакт (например mailto:admin@example.com)", + settings_vapid_warning: "Важно: смена пары VAPID-ключей сделает недействительными подписки всех клиентов. Им потребуется снова включить уведомления.", + settings_vapid_generate: "Для генерации новой пары ключей выполните:", + settings_push_subscribers: "Активные подписки клиентов", + settings_push_no_subscribers: "Активных подписок пока нет.", + settings_push_client: "Клиент", + settings_push_devices: "Устройства", + settings_push_language: "Язык", + settings_push_updated: "Обновлено", landing_contact_label: "Или свяжитесь с нами напрямую", landing_pricing_title: "Стоимость", @@ -416,6 +451,15 @@ static RU: Translations = Translations { portal_feedback_submit: "Отправить", portal_feedback_thanks: "Спасибо за отзыв!", portal_link: "Ссылка клиента", + portal_notifications: "Уведомления", + portal_notifications_text: "Получайте уведомления о завершённых визитах, даже когда страница закрыта. На iPhone сначала добавьте сайт на экран «Домой» и откройте его оттуда.", + portal_notifications_enable: "Включить уведомления", + portal_notifications_disable: "Отключить уведомления", + portal_notifications_denied: "Уведомления заблокированы в настройках браузера.", + portal_calendar: "Календарь визитов", + portal_future_visit: "Будущий визит", + portal_previous: "Назад", + portal_next: "Далее", login_title: "Вход в систему", login_button: "Войти", @@ -611,6 +655,19 @@ static EN: Translations = Translations { settings_section_captcha: "Bot protection", settings_section_oidc: "Single Sign-On (SSO / OIDC)", settings_section_general: "Site", + settings_client_notifications_enabled: "Allow client browser notifications", + settings_client_notifications_help: "Shows clients the completed-visit notification setting.", + settings_vapid_public_key: "VAPID public key", + settings_vapid_private_key: "VAPID private key", + settings_vapid_subject: "VAPID contact (for example mailto:admin@example.com)", + settings_vapid_warning: "Important: changing the VAPID key pair invalidates every client subscription. Clients will need to enable notifications again.", + settings_vapid_generate: "To generate a new key pair, run:", + settings_push_subscribers: "Active client subscriptions", + settings_push_no_subscribers: "There are no active subscriptions yet.", + settings_push_client: "Client", + settings_push_devices: "Devices", + settings_push_language: "Language", + settings_push_updated: "Updated", landing_contact_label: "Or contact us directly", landing_pricing_title: "Pricing", @@ -641,6 +698,15 @@ static EN: Translations = Translations { portal_feedback_submit: "Submit", portal_feedback_thanks: "Thank you for your feedback!", portal_link: "Client link", + portal_notifications: "Notifications", + portal_notifications_text: "Receive completed-visit notifications even when this page is closed. On iPhone, first add this site to the Home Screen and open it from there.", + portal_notifications_enable: "Enable notifications", + portal_notifications_disable: "Disable notifications", + portal_notifications_denied: "Notifications are blocked in your browser settings.", + portal_calendar: "Visit calendar", + portal_future_visit: "Future visit", + portal_previous: "Previous", + portal_next: "Next", login_title: "Sign In", login_button: "Sign In", diff --git a/src/main.rs b/src/main.rs index 98e4708..941791a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,6 +7,7 @@ mod telegram; mod turnstile; mod tz; mod uploads; +mod web_push; use tracing_subscriber; @@ -17,7 +18,7 @@ use cot::config::{ }; use cot::db::migrations::SyncDynMigration; use cot::middleware::SessionMiddleware; -use cot::project::{MiddlewareContext, RegisterAppsContext, RootHandler}; +use cot::project::{MiddlewareContext, ProjectContext, RegisterAppsContext, RootHandler}; use cot::router::Router; use cot::session::db::SessionApp; use cot::{App, AppBuilder, Project}; @@ -40,11 +41,17 @@ impl App for PettingApp { struct PublicApp; +#[async_trait::async_trait] impl App for PublicApp { fn name(&self) -> &'static str { "public" } + async fn init(&self, context: &mut ProjectContext) -> cot::Result<()> { + web_push::initialize(context.database()).await; + Ok(()) + } + fn router(&self) -> Router { public::public_router() } @@ -73,7 +80,13 @@ fn debug_enabled(config_name: &str) -> bool { fn database_url() -> String { std::env::var("WEB_PETTING_DATABASE_URL") .or_else(|_| std::env::var("DATABASE_URL")) - .unwrap_or_else(|_| "postgresql://postgres:postgres@localhost:5432/web_petting".to_string()) + .unwrap_or_else(|_| { + eprintln!( + "WEB_PETTING_DATABASE_URL and DATABASE_URL are not set; using the local default \ + postgresql://postgres:postgres@localhost:5432/web_petting" + ); + "postgresql://postgres:postgres@localhost:5432/web_petting".to_string() + }) } impl Project for PettingProject { @@ -120,10 +133,32 @@ impl Project for PettingProject { } } -#[cot::main] -fn main() -> impl Project { +fn main() { let filter = tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")); let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init(); - PettingProject + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("failed to build Tokio runtime"); + if let Err(error) = runtime.block_on(cot::run_cli(PettingProject)) { + let message = error.to_string(); + let details = format!("{error:?}"); + eprintln!("Failed to start web-petting: {message}\nDetails: {details}"); + if details.contains("28P01") || details.contains("password authentication failed") { + eprintln!( + "\nPostgreSQL rejected the configured username or password.\n\ + Set the connection string before starting the application, for example:\n\n \ + WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run\n\n\ + WEB_PETTING_DATABASE_URL takes priority over DATABASE_URL.\n\ + Check the current value with: printenv WEB_PETTING_DATABASE_URL" + ); + } else if message.to_ascii_lowercase().contains("database") { + eprintln!( + "\nConfigure PostgreSQL with WEB_PETTING_DATABASE_URL or DATABASE_URL.\n\ + Example:\n\n WEB_PETTING_DATABASE_URL='postgresql://postgres:YOUR_PASSWORD@localhost:5432/web_petting' cargo run" + ); + } + std::process::exit(1); + } } diff --git a/src/migrations.rs b/src/migrations.rs index b0ab240..1a8d353 100644 --- a/src/migrations.rs +++ b/src/migrations.rs @@ -3,5 +3,9 @@ //! Squashed for the PostgreSQL migration on 2026-07-11. pub mod m_0001_initial; +pub mod m_0002_push_subscription; /// The list of migrations for current app. -pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[&m_0001_initial::Migration]; +pub const MIGRATIONS: &[&::cot::db::migrations::SyncDynMigration] = &[ + &m_0001_initial::Migration, + &m_0002_push_subscription::Migration, +]; diff --git a/src/migrations/m_0002_push_subscription.rs b/src/migrations/m_0002_push_subscription.rs new file mode 100644 index 0000000..81c2875 --- /dev/null +++ b/src/migrations/m_0002_push_subscription.rs @@ -0,0 +1,67 @@ +//! Store browser Web Push subscriptions for client devices. + +#[derive(Debug, Copy, Clone)] +pub(super) struct Migration; + +impl ::cot::db::migrations::Migration for Migration { + const APP_NAME: &'static str = "web-petting"; + const MIGRATION_NAME: &'static str = "m_0002_push_subscription"; + const DEPENDENCIES: &'static [::cot::db::migrations::MigrationDependency] = + &[::cot::db::migrations::MigrationDependency::migration( + "web-petting", + "m_0001_initial", + )]; + const OPERATIONS: &'static [::cot::db::migrations::Operation] = &[ + ::cot::db::migrations::Operation::create_model() + .table_name(::cot::db::Identifier::new("web_petting__push_subscription")) + .fields(&[ + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("id"), + as ::cot::db::DatabaseField>::TYPE, + ) + .auto() + .primary_key() + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("client_id"), + as ::cot::db::DatabaseField>::TYPE, + ) + .foreign_key( + ::TABLE_NAME, + ::PRIMARY_KEY_NAME, + ::cot::db::ForeignKeyOnDeletePolicy::Restrict, + ::cot::db::ForeignKeyOnUpdatePolicy::Restrict, + ) + .set_null( as ::cot::db::DatabaseField>::NULLABLE), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("endpoint"), + ::TYPE, + ).set_null(false).unique(), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("p256dh"), + ::TYPE, + ).set_null(false), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("auth"), + ::TYPE, + ).set_null(false), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("language"), + ::TYPE, + ).set_null(false), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("status"), + ::TYPE, + ).set_null(false), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("created_at"), + ::TYPE, + ).set_null(false), + ::cot::db::migrations::Field::new( + ::cot::db::Identifier::new("updated_at"), + ::TYPE, + ).set_null(false), + ]) + .build(), + ]; +} diff --git a/src/models.rs b/src/models.rs index 2358821..f982eae 100644 --- a/src/models.rs +++ b/src/models.rs @@ -217,3 +217,21 @@ pub struct Setting { pub value: String, pub updated_at: chrono::NaiveDateTime, } + +/// A browser Web Push subscription belonging to a client device. +#[derive(Debug, Clone)] +#[model] +pub struct PushSubscription { + #[model(primary_key)] + pub id: Auto, + pub client_id: ForeignKey, + #[model(unique)] + pub endpoint: String, + pub p256dh: String, + pub auth: String, + pub language: String, + /// active | archived + pub status: String, + pub created_at: chrono::NaiveDateTime, + pub updated_at: chrono::NaiveDateTime, +} diff --git a/src/public.rs b/src/public.rs index a83aa3e..19e198b 100644 --- a/src/public.rs +++ b/src/public.rs @@ -1,5 +1,6 @@ +use chrono::Datelike; use cot::Template; -use cot::db::{Auto, Database, Model}; +use cot::db::{Auto, Database, ForeignKey, Model}; use cot::html::Html; use cot::request::Request; use cot::request::extractors::Path; @@ -11,7 +12,7 @@ use tracing::info; use cot::db::query; use crate::i18n::{Lang, Translations}; -use crate::models::{Client, Lead, Media, Setting, Testimonial, User, Visit}; +use crate::models::{Client, Lead, Media, PushSubscription, Setting, Testimonial, User, Visit}; use crate::telegram; fn detect_lang(request: &Request) -> Lang { @@ -215,6 +216,21 @@ struct PortalVisit { media: Vec, } +#[derive(Debug)] +struct CalendarDay { + number: u32, + class_name: &'static str, + href: Option, + title: String, +} + +#[derive(Debug)] +struct CalendarMonth { + label: String, + leading_blanks: Vec, + days: Vec, +} + #[derive(Debug, Template)] #[template(path = "client_portal.html")] struct ClientPortalTemplate<'a> { @@ -225,6 +241,62 @@ struct ClientPortalTemplate<'a> { past: Vec, feedback_sent: bool, turnstile_site_key: String, + notifications_enabled: bool, + vapid_public_key: String, + calendar_months: Vec, + page: usize, + total_pages: usize, + has_previous_page: bool, + has_next_page: bool, +} + +const PORTAL_VISITS_PER_PAGE: usize = 10; + +fn query_page(request: &Request) -> usize { + request + .uri() + .query() + .and_then(|query| { + query.split('&').find_map(|part| { + part.strip_prefix("page=") + .and_then(|value| value.parse::().ok()) + .filter(|page| *page > 0) + }) + }) + .unwrap_or(1) +} + +fn month_label(year: i32, month: u32, lang: Lang) -> String { + const RU: [&str; 12] = [ + "Январь", + "Февраль", + "Март", + "Апрель", + "Май", + "Июнь", + "Июль", + "Август", + "Сентябрь", + "Октябрь", + "Ноябрь", + "Декабрь", + ]; + const EN: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + let names = if lang == Lang::Ru { RU } else { EN }; + format!("{} {year}", names[(month - 1) as usize]) } async fn client_portal( @@ -238,6 +310,7 @@ async fn client_portal( .query() .map(|q| q.split('&').any(|p| p == "feedback=ok")) .unwrap_or(false); + let requested_page = query_page(&request); let client = match query!(Client, $media_token == token).get(&db).await? { Some(c) if c.status != "deleted" => c, @@ -291,16 +364,108 @@ async fn client_portal( } }; - let mut upcoming = Vec::new(); - let mut past = Vec::new(); + let mut upcoming_visits = Vec::new(); + let mut past_visits = Vec::new(); for v in visits { if v.visit_date >= today && v.status == "scheduled" { - upcoming.push(build_portal_visit(v)); + upcoming_visits.push(v); } else { - past.push(build_portal_visit(v)); + past_visits.push(v); } } - past.reverse(); // newest first + past_visits.reverse(); // newest first + + let total_pages = past_visits.len().div_ceil(PORTAL_VISITS_PER_PAGE).max(1); + let page = requested_page.min(total_pages); + let page_start = (page - 1) * PORTAL_VISITS_PER_PAGE; + let page_end = (page_start + PORTAL_VISITS_PER_PAGE).min(past_visits.len()); + let past = past_visits[page_start..page_end] + .iter() + .cloned() + .map(&build_portal_visit) + .collect(); + let upcoming: Vec<_> = upcoming_visits + .iter() + .cloned() + .map(&build_portal_visit) + .collect(); + + let mut month_keys: Vec<(i32, u32)> = past_visits + .iter() + .chain(upcoming_visits.iter()) + .map(|visit| (visit.visit_date.year(), visit.visit_date.month())) + .collect(); + month_keys.sort(); + month_keys.dedup(); + month_keys.reverse(); + let calendar_months = month_keys + .into_iter() + .map(|(year, month)| { + let first = chrono::NaiveDate::from_ymd_opt(year, month, 1).unwrap(); + let next_month = if month == 12 { + chrono::NaiveDate::from_ymd_opt(year + 1, 1, 1).unwrap() + } else { + chrono::NaiveDate::from_ymd_opt(year, month + 1, 1).unwrap() + }; + let days_in_month = (next_month - first).num_days() as u32; + let leading_blanks = vec![0; first.weekday().num_days_from_monday() as usize]; + let days = (1..=days_in_month) + .map(|day| { + let date = chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap(); + if let Some(_visit) = upcoming_visits + .iter() + .find(|visit| visit.visit_date == date) + { + CalendarDay { + number: day, + class_name: "future", + href: None, + title: lang.t().portal_future_visit.to_string(), + } + } else if let Some((index, visit)) = past_visits + .iter() + .enumerate() + .find(|(_, visit)| visit.visit_date == date) + { + CalendarDay { + number: day, + class_name: "past", + href: Some(format!( + "?page={}#visit-{}", + index / PORTAL_VISITS_PER_PAGE + 1, + visit.id.unwrap() + )), + title: lang.t().visit_status(&visit.status).to_string(), + } + } else { + CalendarDay { + number: day, + class_name: "empty", + href: None, + title: String::new(), + } + } + }) + .collect(); + CalendarMonth { + label: month_label(year, month, lang), + leading_blanks, + days, + } + }) + .collect(); + + let notification_key = "client_notifications_enabled".to_string(); + let vapid_public_key = crate::web_push::load_config(&db) + .await + .map(|config| config.public_key) + .unwrap_or_default(); + let notifications_enabled = !vapid_public_key.is_empty() + && query!(Setting, $key == notification_key) + .get(&db) + .await? + .map(|setting| setting.value == "true") + .unwrap_or(false); let turnstile_site_key = crate::turnstile::get_site_key(&db).await?; let body = ClientPortalTemplate { @@ -311,11 +476,170 @@ async fn client_portal( past, feedback_sent, turnstile_site_key, + notifications_enabled, + vapid_public_key, + calendar_months, + page, + total_pages, + has_previous_page: page > 1, + has_next_page: page < total_pages, } .render()?; html_response(body, lang) } +#[derive(Deserialize)] +struct PushKeysForm { + p256dh: String, + auth: String, +} + +#[derive(Deserialize)] +struct PushSubscriptionForm { + endpoint: String, + keys: PushKeysForm, + #[serde(default)] + language: String, +} + +async fn portal_push_subscribe( + request: Request, + db: Database, + Path(token): Path, +) -> cot::Result { + if crate::web_push::load_config(&db).await.is_none() { + return Html::new("404").into_response(); + } + let client = match query!(Client, $media_token == token).get(&db).await? { + Some(client) if client.status != "deleted" => client, + _ => return Html::new("404").into_response(), + }; + let bytes = request.into_body().into_bytes().await?; + let form: PushSubscriptionForm = + serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?; + if !form.endpoint.starts_with("https://") + || form.endpoint.len() > 4096 + || form.keys.p256dh.len() > 512 + || form.keys.auth.len() > 256 + { + return Html::new("400").into_response(); + } + let endpoint = form.endpoint.clone(); + let now = chrono::Utc::now().naive_utc(); + if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint) + .get(&db) + .await? + { + subscription.client_id = ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())); + subscription.p256dh = form.keys.p256dh; + subscription.auth = form.keys.auth; + subscription.language = if form.language == "ru" { "ru" } else { "en" }.to_string(); + subscription.status = "active".to_string(); + subscription.updated_at = now; + subscription.save(&db).await?; + } else { + let mut subscription = PushSubscription { + id: Auto::auto(), + client_id: ForeignKey::PrimaryKey(Auto::fixed(client.id.unwrap())), + endpoint: form.endpoint, + p256dh: form.keys.p256dh, + auth: form.keys.auth, + language: if form.language == "ru" { "ru" } else { "en" }.to_string(), + status: "active".to_string(), + created_at: now, + updated_at: now, + }; + subscription.save(&db).await?; + } + let mut response = Response::new(cot::Body::fixed("{\"ok\":true}")); + response + .headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + Ok(response) +} + +async fn portal_push_unsubscribe( + request: Request, + db: Database, + Path(token): Path, +) -> cot::Result { + let client = match query!(Client, $media_token == token).get(&db).await? { + Some(client) if client.status != "deleted" => client, + _ => return Html::new("404").into_response(), + }; + let bytes = request.into_body().into_bytes().await?; + let value: serde_json::Value = + serde_json::from_slice(&bytes).map_err(|error| cot::Error::internal(error.to_string()))?; + let endpoint = value + .get("endpoint") + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(); + if let Some(mut subscription) = query!(PushSubscription, $endpoint == endpoint) + .get(&db) + .await? + { + if subscription.client_id.primary_key().unwrap() == client.id.unwrap() { + subscription.status = "archived".to_string(); + subscription.updated_at = chrono::Utc::now().naive_utc(); + subscription.save(&db).await?; + } + } + let mut response = Response::new(cot::Body::fixed("{\"ok\":true}")); + response + .headers_mut() + .insert("content-type", "application/json".parse().unwrap()); + Ok(response) +} + +async fn web_manifest(_request: Request, Path(token): Path) -> cot::Result { + let manifest = serde_json::json!({ + "id": format!("/client/{token}"), + "name": "Pet Sitting Visits", + "short_name": "Pet Visits", + "start_url": format!("/client/{token}"), + "display": "standalone", + "background_color": "#f8f7ff", + "theme_color": "#7c6cff", + "icons": [{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" }] + }); + let mut response = Response::new(cot::Body::fixed(manifest.to_string())); + response + .headers_mut() + .insert("content-type", "application/manifest+json".parse().unwrap()); + Ok(response) +} + +async fn service_worker(_request: Request) -> cot::Result { + let script = r#" +self.addEventListener('push', function(event) { + var data = event.data ? event.data.json() : {}; + event.waitUntil(self.registration.showNotification(data.title || 'Pet Visits', { + body: data.body || '', tag: data.tag || 'visit', data: { url: data.url || '/' }, + icon: '/favicon.svg', badge: '/favicon.svg' + })); +}); +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + var target = new URL(event.notification.data.url || '/', self.location.origin).href; + event.waitUntil(clients.matchAll({ type: 'window', includeUncontrolled: true }).then(function(list) { + for (var i = 0; i < list.length; i++) { + if ('focus' in list[i]) { list[i].navigate(target); return list[i].focus(); } + } + return clients.openWindow ? clients.openWindow(target) : undefined; + })); +}); +"#; + let mut response = Response::new(cot::Body::fixed(script)); + response + .headers_mut() + .insert("content-type", "application/javascript".parse().unwrap()); + response + .headers_mut() + .insert("service-worker-allowed", "/".parse().unwrap()); + Ok(response) +} + #[derive(Deserialize)] struct FeedbackForm { feedback: String, @@ -430,6 +754,56 @@ async fn portal_media( } } +async fn portal_media_thumbnail( + _request: Request, + db: Database, + Path((token, media_id)): Path<(String, i64)>, +) -> cot::Result { + let client = match query!(Client, $media_token == token).get(&db).await? { + Some(client) if client.status != "deleted" => client, + _ => return Html::new("404").into_response(), + }; + let media = match query!(Media, $id == media_id).get(&db).await? { + Some(media) + if media.client_id.primary_key().unwrap() == client.id.unwrap() + && media.status == "active" + && media.file_type == "photo" => + { + media + } + _ => return Html::new("404").into_response(), + }; + if let Some(visit) = &media.visit_id { + let visit_id = visit.primary_key().unwrap(); + match query!(Visit, $id == visit_id).get(&db).await? { + Some(visit) if visit.status != "deleted" => {} + _ => return Html::new("404").into_response(), + } + } + match crate::uploads::ensure_thumbnail(&media.file_path).await { + Ok(path) => { + let mut response = crate::uploads::ranged_file_response(&path, "image/jpeg", None) + .await + .map_err(|error| cot::Error::internal(error.to_string()))?; + response.headers_mut().insert( + "cache-control", + "private, max-age=31536000, immutable".parse().unwrap(), + ); + Ok(response) + } + Err(error) => { + tracing::warn!(media_id, %error, "failed to create portal media thumbnail"); + crate::uploads::ranged_file_response( + &media.file_path, + crate::uploads::content_type_for_path(&media.file_path), + None, + ) + .await + .map_err(|error| cot::Error::internal(error.to_string())) + } + } +} + async fn serve_testimonial_image( _request: Request, db: Database, @@ -578,6 +952,12 @@ pub fn public_router() -> Router { Router::with_urls([ Route::with_handler_and_name("/", landing_page, "landing"), Route::with_handler_and_name("/favicon.svg", favicon, "favicon"), + Route::with_handler_and_name( + "/client/{token}/manifest.webmanifest", + web_manifest, + "web-manifest", + ), + Route::with_handler_and_name("/service-worker.js", service_worker, "service-worker"), Route::with_handler_and_name("/static/{filename}", serve_static, "static-file"), Route::with_handler_and_name("/robots.txt", robots_txt, "robots-txt"), Route::with_handler_and_name("/sitemap.xml", sitemap_xml, "sitemap-xml"), @@ -588,6 +968,16 @@ pub fn public_router() -> Router { "testimonial-image", ), Route::with_handler_and_name("/client/{token}", client_portal, "client-portal"), + Route::with_handler_and_name( + "/client/{token}/push/subscribe", + portal_push_subscribe, + "client-push-subscribe", + ), + Route::with_handler_and_name( + "/client/{token}/push/unsubscribe", + portal_push_unsubscribe, + "client-push-unsubscribe", + ), Route::with_handler_and_name( "/client/{token}/{visit_id}/feedback", submit_feedback, @@ -598,5 +988,10 @@ pub fn public_router() -> Router { portal_media, "client-media", ), + Route::with_handler_and_name( + "/client/{token}/media/{media_id}/thumbnail", + portal_media_thumbnail, + "client-media-thumbnail", + ), ]) } diff --git a/src/uploads.rs b/src/uploads.rs index 12bf609..d254836 100644 --- a/src/uploads.rs +++ b/src/uploads.rs @@ -4,6 +4,9 @@ use cot::response::Response; use cot::{Body, StatusCode}; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +const THUMBNAIL_MAX_DIMENSION: u32 = 360; +const THUMBNAIL_JPEG_QUALITY: u8 = 70; + const DEFAULT_UPLOAD_DIR: &str = "uploads"; const UPLOAD_DIR_ENV: &str = "WEB_PETTING_UPLOAD_DIR"; @@ -57,6 +60,46 @@ pub async fn read_db_file(db_path: &str) -> std::io::Result> { tokio::fs::read(resolve_db_path(db_path)).await } +pub fn thumbnail_db_path(db_path: &str) -> String { + match db_path.rsplit_once('.') { + Some((stem, _)) => format!("{stem}.thumb.jpg"), + None => format!("{db_path}.thumb.jpg"), + } +} + +pub fn content_type_for_path(path: &str) -> &'static str { + match path.rsplit('.').next().unwrap_or("") { + "jpg" | "jpeg" => "image/jpeg", + "png" => "image/png", + "heic" | "heif" => "image/heic", + "webp" => "image/webp", + "mp4" => "video/mp4", + "mov" => "video/quicktime", + "avi" => "video/x-msvideo", + "mkv" => "video/x-matroska", + "webm" => "video/webm", + _ => "application/octet-stream", + } +} + +pub async fn ensure_thumbnail(db_path: &str) -> std::io::Result { + let thumbnail_path = thumbnail_db_path(db_path); + if tokio::fs::try_exists(resolve_db_path(&thumbnail_path)).await? { + return Ok(thumbnail_path); + } + let data = read_db_file(db_path).await?; + let image = image::load_from_memory(&data) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?; + let thumbnail = image.thumbnail(THUMBNAIL_MAX_DIMENSION, THUMBNAIL_MAX_DIMENSION); + let rgb = thumbnail.to_rgb8(); + let mut encoded = Vec::new(); + let mut encoder = + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, THUMBNAIL_JPEG_QUALITY); + encoder.encode_image(&rgb).map_err(std::io::Error::other)?; + write_db_file(&thumbnail_path, &encoded).await?; + Ok(thumbnail_path) +} + enum ByteRange { Full, Partial { start: u64, end: u64 }, diff --git a/src/web_push.rs b/src/web_push.rs new file mode 100644 index 0000000..464b9ea --- /dev/null +++ b/src/web_push.rs @@ -0,0 +1,157 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use cot::db::{Database, Model, query}; +use serde_json::json; +use web_push_native::jwt_simple::algorithms::ES256KeyPair; +use web_push_native::p256::PublicKey; +use web_push_native::{Auth, WebPushBuilder}; + +use crate::models::{Client, PushSubscription, Setting, Visit}; + +pub struct VapidConfig { + pub public_key: String, + private_key: String, + subject: String, +} + +pub async fn load_config(db: &Database) -> Option { + let settings = Setting::objects().all(db).await.ok()?; + let value = |key: &str| { + settings + .iter() + .find(|setting| setting.key == key) + .map(|setting| setting.value.trim().to_string()) + .filter(|value| !value.is_empty()) + }; + let public_key = value("vapid_public_key")?; + let private_key = value("vapid_private_key")?; + let subject = value("vapid_subject").unwrap_or_else(|| "mailto:admin@localhost".to_string()); + Some(VapidConfig { + public_key, + private_key, + subject, + }) +} + +pub async fn initialize(db: &Database) { + if load_config(db).await.is_some() { + tracing::info!("VAPID configuration loaded from database"); + } else { + tracing::info!("VAPID configuration is not set; client Web Push is disabled"); + } +} + +pub async fn notify_visit_completed(db: &Database, visit: &Visit) { + let setting_key = "client_notifications_enabled".to_string(); + let enabled = query!(Setting, $key == setting_key) + .get(db) + .await + .ok() + .flatten() + .map(|setting| setting.value == "true") + .unwrap_or(false); + let Some(config) = load_config(db).await else { + return; + }; + if !enabled { + return; + } + + let client_id = visit.client_id.primary_key().unwrap(); + let client = match query!(Client, $id == client_id).get(db).await { + Ok(Some(client)) => client, + _ => return, + }; + let active = "active".to_string(); + let subscriptions = match query!(PushSubscription, $status == active).all(db).await { + Ok(items) => items + .into_iter() + .filter(|item| item.client_id.primary_key().unwrap() == client_id) + .collect::>(), + Err(error) => { + tracing::warn!(%error, "failed to load Web Push subscriptions"); + return; + } + }; + let mut visible_visits = match Visit::objects().all(db).await { + Ok(visits) => visits + .into_iter() + .filter(|item| { + item.client_id.primary_key().unwrap() == client_id + && item.status != "cancelled" + && item.status != "deleted" + }) + .collect::>(), + Err(_) => Vec::new(), + }; + visible_visits.sort_by(|a, b| { + b.visit_date + .cmp(&a.visit_date) + .then(b.time_start.cmp(&a.time_start)) + }); + let page = visible_visits + .iter() + .position(|item| item.id.unwrap() == visit.id.unwrap()) + .map(|index| index / 10 + 1) + .unwrap_or(1); + + for mut subscription in subscriptions { + let is_ru = subscription.language == "ru"; + let date = visit.visit_date.format("%d.%m.%Y"); + let body = if is_ru { + format!("Визит {date} завершён. Нажмите для просмотра медиа и комментариев.") + } else { + format!("Visit {date} is complete. Click to view media and comments.") + }; + let payload = json!({ + "title": if is_ru { "Визит завершён" } else { "Visit completed" }, + "body": body, + "url": format!("/client/{}?page={}#visit-{}", client.media_token, page, visit.id.unwrap()), + "tag": format!("visit-{}", visit.id.unwrap()), + }); + + match send(&subscription, payload.to_string().into_bytes(), &config).await { + Ok(status) + if status == reqwest::StatusCode::NOT_FOUND + || status == reqwest::StatusCode::GONE => + { + subscription.status = "archived".to_string(); + subscription.updated_at = chrono::Utc::now().naive_utc(); + if let Err(error) = subscription.save(db).await { + tracing::warn!(%error, "failed to archive expired Web Push subscription"); + } + } + Ok(status) if status.is_success() => {} + Ok(status) => tracing::warn!(%status, "Web Push gateway rejected notification"), + Err(error) => tracing::warn!(%error, "failed to send Web Push notification"), + } + } +} + +async fn send( + subscription: &PushSubscription, + content: Vec, + config: &VapidConfig, +) -> Result> { + let private = URL_SAFE_NO_PAD.decode(&config.private_key)?; + let key_pair = ES256KeyPair::from_bytes(&private)?; + let p256dh = URL_SAFE_NO_PAD.decode(&subscription.p256dh)?; + let auth = URL_SAFE_NO_PAD.decode(&subscription.auth)?; + let builder = WebPushBuilder::new( + subscription.endpoint.parse()?, + PublicKey::from_sec1_bytes(&p256dh)?, + Auth::clone_from_slice(&auth), + ) + .with_vapid(&key_pair, &config.subject); + let request = builder.build(content)?; + let (parts, body) = request.into_parts(); + let response = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(10)) + .build()? + .request(parts.method, parts.uri.to_string()) + .headers(parts.headers) + .body(body) + .send() + .await?; + Ok(response.status()) +} diff --git a/templates/admin/media.html b/templates/admin/media.html index 964a1ca..4058b27 100644 --- a/templates/admin/media.html +++ b/templates/admin/media.html @@ -28,7 +28,7 @@
{% if item.media.file_type == "photo" %} - + {% else %} diff --git a/templates/admin/schedule_edit.html b/templates/admin/schedule_edit.html index e51b190..7018e3e 100644 --- a/templates/admin/schedule_edit.html +++ b/templates/admin/schedule_edit.html @@ -114,7 +114,7 @@

{{ t.settings_section_notifications }}

+
+ +

{{ t.settings_client_notifications_help }}

+
+
+

{{ t.settings_vapid_warning }}

+

{{ t.settings_vapid_generate }}

+ cargo run --bin generate_vapid +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + {{ t.settings_push_subscribers }} ({{ push_subscribers.len() }}) + +
+ {% if push_subscribers.is_empty() %} +

{{ t.settings_push_no_subscribers }}

+ {% else %} + + + + + + + + + {% for subscriber in &push_subscribers %} + + + + + + + {% endfor %} + +
{{ t.settings_push_client }}{{ t.settings_push_devices }}{{ t.settings_push_language }}{{ t.settings_push_updated }}
{{ subscriber.client_name }}{{ subscriber.device_count }}{{ subscriber.languages }}{{ subscriber.last_updated }}
+ {% endif %} +
+
diff --git a/templates/client_portal.html b/templates/client_portal.html index 775b58a..e1b02b3 100644 --- a/templates/client_portal.html +++ b/templates/client_portal.html @@ -5,6 +5,9 @@ {{ t.portal_title }} — {{ client.name }} + + + {% if !turnstile_site_key.is_empty() %} {% endif %} @@ -17,12 +20,15 @@ padding: 0 0 2rem; } .portal-header { + position: relative; background: linear-gradient(135deg, #7c6cff, #b06cff); color: #fff; padding: 2rem 1.5rem 1.5rem; text-align: center; } .portal-header h1 { font-size: 1.5rem; font-weight: 700; } .portal-header .sub { opacity: 0.85; font-size: 0.9rem; margin-top: 0.25rem; } - .container { max-width: 700px; margin: 0 auto; padding: 0 1rem; } + .container { max-width: 1100px; margin: 0 auto; padding: 0 1rem; } + .portal-grid { display: grid; grid-template-columns: minmax(0, 700px) 320px; gap: 1.25rem; align-items: start; } + .portal-settings { position: absolute; right: 1rem; bottom: 1rem; width: 38px; height: 38px; border: 0; border-radius: 50%; background: rgba(255,255,255,.2); color: #fff; font-size: 1.1rem; cursor: pointer; } .section-title { font-size: 1.15rem; font-weight: 700; margin: 1.5rem 0 0.75rem; padding-bottom: 0.4rem; border-bottom: 2px solid #ede7f6; @@ -110,6 +116,33 @@ font-weight: 700; min-width: 5.5rem; } .upcoming-row .up-time { color: #7a7599; } + .calendar-panel { position: sticky; top: 1rem; margin-top: 1.5rem; background: #fff; border: 1px solid #eee; border-radius: 12px; padding: .85rem; } + .calendar-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: .65rem; } + .calendar-head button { border: 0; background: #f0edff; color: #6255c7; width: 30px; height: 30px; border-radius: 50%; cursor: pointer; } + .calendar-title { font-size: .95rem; font-weight: 700; } + .calendar-weekdays, .calendar-days { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; text-align: center; } + .calendar-weekdays { color: #999; font-size: .68rem; margin-bottom: 3px; } + .calendar-day { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 7px; font-size: .78rem; color: #aaa; } + .calendar-day.past { background: #ede9ff; color: #5145a6; font-weight: 700; text-decoration: none; } + .calendar-day.past:hover { background: #dcd5ff; } + .calendar-day.future { background: #f3f3f3; color: #bbb; border: 1px dashed #ddd; } + .calendar-month[hidden] { display: none; } + .calendar-legend { margin-top: .65rem; font-size: .72rem; color: #999; } + .pagination { display: flex; justify-content: center; align-items: center; gap: .75rem; margin: 1rem 0; font-size: .85rem; } + .pagination a { color: #6558c8; text-decoration: none; padding: .35rem .7rem; background: #fff; border: 1px solid #e5e1ff; border-radius: 8px; } + .modal-bg { display: none; position: fixed; inset: 0; z-index: 1000; background: rgba(20,18,40,.55); align-items: center; justify-content: center; padding: 1rem; } + .modal-bg.open { display: flex; } + .notification-modal { width: min(420px, 100%); background: #fff; border-radius: 14px; padding: 1.2rem; box-shadow: 0 15px 50px rgba(0,0,0,.25); } + .notification-modal h2 { font-size: 1.15rem; margin-bottom: .45rem; } + .notification-modal p { font-size: .88rem; color: #777; } + .notification-actions { display: flex; gap: .5rem; margin-top: 1rem; } + .notification-actions button { border: 0; border-radius: 8px; padding: .55rem .9rem; cursor: pointer; } + .notification-primary { background: #7567e8; color: #fff; } + @media (max-width: 800px) { + .portal-grid { display: flex; flex-direction: column; } + .calendar-panel { position: static; order: -1; width: 100%; margin-top: 1rem; } + .visits-column { width: 100%; } + } @@ -121,6 +154,7 @@

{{ t.portal_title }}

{{ client.name }}
+ {% if notifications_enabled %}{% endif %}
@@ -129,13 +163,15 @@
{{ t.portal_feedback_thanks }}
{% endif %} +
+

{{ t.portal_past }}

{% if past.is_empty() %}

{{ t.portal_no_past }}

{% else %} {% for pv in &past %} -
+
{% endfor %} + {% if total_pages > 1 %} + + {% endif %} {% endif %} @@ -209,14 +252,55 @@ {% for pv in &upcoming %}
{{ pv.visit.visit_date }} - {{ pv.visit.time_start }} — {{ pv.visit.time_end }} + {{ t.portal_future_visit }}
{% endfor %}
{% endif %} +
+ +{% if !calendar_months.is_empty() %} + +{% endif %} +
+{% if notifications_enabled %} + +{% endif %} + {% include "partials/lightbox.html" %}