From 317fcd4203b0fd1773aa106f79effb92e0f9dce2 Mon Sep 17 00:00:00 2001 From: Pakin Date: Tue, 16 Jun 2026 10:47:00 +0700 Subject: [PATCH] feat: 0.0.1-dev - expose shared config get/update endpoint - interceptor for reporting changes from user - task[recipe]: optimize recipe send flow, add material action (create/update, modify[not test]) - add secured session, in addition to auth message, this is required to use for newer client (expect ^0.0.2 for client) - disable plugin mode - optimize ram/cpu usages (reduce from 300MB to ~80MB) Signed-off-by: Pakin --- Cargo.lock | 1075 +++++++++++++++++++++++++-- Cargo.toml | 16 +- Dockerfile | 20 +- build.sh | 1 + push.sh | 1 + src/app.rs | 496 ++++++++++-- src/main.rs | 31 +- src/stream/model.rs | 15 +- src/summary.rs | 22 + src/websocket/core.rs | 73 +- src/websocket/handler.rs | 194 ++++- src/websocket/helper.rs | 17 +- src/websocket/interceptor/client.rs | 229 ++++++ src/websocket/interceptor/config.rs | 67 ++ src/websocket/interceptor/mod.rs | 30 + src/websocket/mod.rs | 2 + src/websocket/model.rs | 143 ++++ src/websocket/rw.rs | 488 +++++++----- src/websocket/session.rs | 161 ++++ src/websocket/tasks/auth.rs | 18 - src/websocket/tasks/price.rs | 12 +- src/websocket/tasks/recipe.rs | 755 +++++++++++++++++-- 22 files changed, 3443 insertions(+), 423 deletions(-) create mode 100755 build.sh create mode 100755 push.sh create mode 100644 src/summary.rs create mode 100644 src/websocket/interceptor/client.rs create mode 100644 src/websocket/interceptor/config.rs create mode 100644 src/websocket/interceptor/mod.rs create mode 100644 src/websocket/session.rs diff --git a/Cargo.lock b/Cargo.lock index fbab7bb..3dea774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,13 +2,22 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli 0.32.3", +] + [[package]] name = "addr2line" version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "59317f77929f0e679d39364702289274de2f0f0b22cbf50b2b8cff2169a0b27a" dependencies = [ - "gimli", + "gimli 0.33.0", ] [[package]] @@ -17,6 +26,16 @@ 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" @@ -25,7 +44,34 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "ghash", + "subtle", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", ] [[package]] @@ -37,6 +83,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "alloc-no-stdlib" version = "2.0.4" @@ -144,6 +199,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03918c3dbd7701a85c6b9887732e2921175f26c350b4563841d0958c21d57e6d" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-compression" version = "0.4.42" @@ -292,6 +353,27 @@ dependencies = [ "tokio-stream", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line 0.25.1", + "cfg-if", + "libc", + "miniz_oxide", + "object 0.37.3", + "rustc-demangle", + "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" @@ -304,6 +386,12 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -322,6 +410,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "brotli" version = "8.0.2" @@ -352,6 +449,12 @@ dependencies = [ "allocator-api2", ] +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -485,6 +588,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -494,6 +608,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -514,7 +629,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] @@ -527,6 +642,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "cobs" version = "0.3.0" @@ -587,6 +708,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.3.1" @@ -637,6 +764,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "cranelift-assembler-x64" version = "0.131.1" @@ -691,7 +827,7 @@ dependencies = [ "cranelift-control", "cranelift-entity", "cranelift-isle", - "gimli", + "gimli 0.33.0", "hashbrown 0.16.1", "libm", "log", @@ -848,6 +984,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" @@ -855,9 +1003,64 @@ 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 = [ + "hybrid-array", +] + +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "darling" version = "0.20.11" @@ -920,7 +1123,7 @@ 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", ] @@ -982,12 +1185,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.0", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "directories-next" version = "2.0.0" @@ -1038,6 +1253,44 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -1047,6 +1300,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", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embedded-io" version = "0.4.0" @@ -1091,6 +1365,26 @@ dependencies = [ "log", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1139,6 +1433,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "fallible-iterator" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + [[package]] name = "fastrand" version = "2.4.1" @@ -1156,6 +1456,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[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 = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.29" @@ -1172,6 +1488,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -1197,7 +1525,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1364,7 +1692,7 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25234f20a3ec0a962a61770cfe39ecf03cb529a6e474ad8cff025ed497eda557" dependencies = [ - "bitflags", + "bitflags 2.11.1", "debugid", "rustc-hash", "serde", @@ -1380,6 +1708,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -1391,7 +1720,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -1418,10 +1747,27 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] +[[package]] +name = "ghash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1" +dependencies = [ + "opaque-debug", + "polyval", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "gimli" version = "0.33.0" @@ -1440,7 +1786,7 @@ version = "0.20.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "libgit2-sys", "log", @@ -1449,6 +1795,17 @@ dependencies = [ "url", ] +[[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 = "h2" version = "0.4.14" @@ -1514,6 +1871,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -1526,7 +1889,7 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", ] [[package]] @@ -1535,7 +1898,16 @@ 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]] @@ -1592,6 +1964,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1805,6 +2186,24 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inferno" +version = "0.11.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" +dependencies = [ + "ahash", + "indexmap", + "is-terminal", + "itoa", + "log", + "num-format", + "once_cell", + "quick-xml 0.26.0", + "rgb", + "str_stack", +] + [[package]] name = "inout" version = "0.1.4" @@ -1836,12 +2235,32 @@ version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -1972,13 +2391,37 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "base64", + "ed25519-dalek", + "getrandom 0.2.17", + "hmac 0.12.1", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.6", + "rsa", + "serde", + "serde_json", + "sha2 0.10.9", + "signature", + "simple_asn1", + "zeroize", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", ] [[package]] @@ -2031,7 +2474,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.5", @@ -2072,7 +2515,7 @@ dependencies = [ "git2", "indexmap", "log", - "quick-xml", + "quick-xml 0.39.4", "rand 0.9.4", "rayon", "serde", @@ -2140,7 +2583,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c60a23ffb90d527e23192f1246b14746e2f7f071cb84476dd879071696c18a4a" dependencies = [ "crc", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2171,7 +2614,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -2189,6 +2642,15 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "memmap2" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +dependencies = [ + "libc", +] + [[package]] name = "mime" version = "0.3.17" @@ -2222,10 +2684,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "libc", +] + [[package]] name = "num-bigint" version = "0.4.6" @@ -2269,6 +2748,16 @@ dependencies = [ "syn", ] +[[package]] +name = "num-format" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a652d9771a63711fd3c3deb670acfbe5c30a4072e664d7a3bf5a9e1056ac72c3" +dependencies = [ + "arrayvec", + "itoa", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -2299,6 +2788,33 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + [[package]] name = "object" version = "0.39.1" @@ -2323,13 +2839,19 @@ 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 = "openssl" version = "0.10.80" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cfg-if", "foreign-types", "libc", @@ -2382,6 +2904,30 @@ dependencies = [ "vcpkg", ] +[[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" @@ -2417,8 +2963,18 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", ] [[package]] @@ -2546,6 +3102,18 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[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 = "portable-atomic" version = "1.13.1" @@ -2573,6 +3141,35 @@ dependencies = [ "serde", ] +[[package]] +name = "postgres-protocol" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc" +dependencies = [ + "base64", + "byteorder", + "bytes", + "fallible-iterator", + "hmac 0.13.0", + "md-5 0.11.0", + "memchr", + "rand 0.10.1", + "sha2 0.11.0", + "stringprep", +] + +[[package]] +name = "postgres-types" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186" +dependencies = [ + "bytes", + "fallible-iterator", + "postgres-protocol", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2594,6 +3191,32 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efca4c95a19a79d1c98f791f10aebd5c1363b473244630bb7dbde1dc98455a24" +[[package]] +name = "pprof" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" +dependencies = [ + "aligned-vec", + "backtrace", + "cfg-if", + "findshlibs", + "inferno", + "libc", + "log", + "nix", + "once_cell", + "prost 0.12.6", + "prost-build", + "prost-derive 0.12.6", + "sha2 0.10.9", + "smallvec", + "spin 0.10.0", + "symbolic-demangle", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -2613,6 +3236,15 @@ dependencies = [ "syn", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2622,6 +3254,82 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes", + "prost-derive 0.12.6", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive 0.14.4", +] + +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes", + "heck", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.6", + "prost-types", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", +] + [[package]] name = "pulley-interpreter" version = "44.0.1" @@ -2645,6 +3353,15 @@ dependencies = [ "syn", ] +[[package]] +name = "quick-xml" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -2752,6 +3469,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2790,6 +3518,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 = "rayon" version = "1.12.0" @@ -2845,7 +3579,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2854,7 +3588,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2939,6 +3673,8 @@ dependencies = [ "rustls", "rustls-pki-types", "rustls-platform-verifier", + "serde", + "serde_json", "sync_wrapper", "tokio", "tokio-rustls", @@ -2951,6 +3687,25 @@ dependencies = [ "web-sys", ] +[[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 = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + [[package]] name = "ring" version = "0.17.14" @@ -2971,8 +3726,8 @@ 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", @@ -3012,7 +3767,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -3025,7 +3780,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -3155,13 +3910,27 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3267,18 +4036,27 @@ dependencies = [ name = "server-mark2-dev" version = "0.1.0" dependencies = [ + "aes-gcm", "async-compression", "axum", "axum-streams", + "base64", "celes", "chrono", "crossbeam-queue", "dotenv", "env_logger", "futures", + "futures-util", + "jsonwebtoken", "libtbr", "log", "openssl", + "p256", + "pprof", + "prost 0.14.4", + "rand 0.10.1", + "rand_core 0.6.4", "rayon", "redis", "reqwest", @@ -3287,8 +4065,11 @@ dependencies = [ "sqlx", "tokio", "tokio-cron-scheduler", + "tokio-postgres", "tokio-stream", + "tokio-tungstenite", "tokio-util", + "tracing", "uuid", "wasmtime", "wasmtime-wasi", @@ -3302,8 +4083,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3319,8 +4100,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "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]] @@ -3345,7 +4137,7 @@ 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", ] @@ -3371,6 +4163,18 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + [[package]] name = "siphasher" version = "1.0.3" @@ -3411,6 +4215,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -3460,7 +4273,7 @@ dependencies = [ "rustls", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "thiserror 2.0.18", "tokio", @@ -3498,7 +4311,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -3516,11 +4329,11 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -3530,10 +4343,10 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", @@ -3541,13 +4354,13 @@ dependencies = [ "rsa", "serde", "sha1", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -3558,7 +4371,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "crc", "dotenvy", @@ -3568,23 +4381,23 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.6", "serde", "serde_json", - "sha2", + "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", "thiserror 2.0.18", "tracing", - "whoami", + "whoami 1.6.1", ] [[package]] @@ -3617,6 +4430,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "str_stack" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" + [[package]] name = "stringprep" version = "0.1.5" @@ -3661,6 +4480,29 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symbolic-common" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" +dependencies = [ + "debugid", + "memmap2", + "stable_deref_trait", + "uuid", +] + +[[package]] +name = "symbolic-demangle" +version = "12.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "912017718eb4d21930546245af9a3475c9dccf15675a5c215664e76621afc471" +dependencies = [ + "cpp_demangle", + "rustc-demangle", + "symbolic-common", +] + [[package]] name = "syn" version = "2.0.117" @@ -3698,7 +4540,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -3719,7 +4561,7 @@ version = "0.27.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cap-fs-ext", "cap-std", "fd-lock", @@ -3815,10 +4657,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", + "itoa", "num-conv", "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3827,6 +4671,16 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3896,6 +4750,32 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-postgres" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce" +dependencies = [ + "async-trait", + "byteorder", + "bytes", + "fallible-iterator", + "futures-channel", + "futures-util", + "log", + "parking_lot", + "percent-encoding", + "phf 0.13.1", + "pin-project-lite", + "postgres-protocol", + "postgres-types", + "rand 0.10.1", + "socket2", + "tokio", + "tokio-util", + "whoami 2.1.2", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4003,7 +4883,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", @@ -4132,6 +5012,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" @@ -4210,6 +5100,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" version = "1.0.3+wasi-0.2.9" @@ -4234,6 +5133,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" version = "0.2.121" @@ -4354,7 +5262,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -4366,7 +5274,7 @@ version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.16.1", "indexmap", "semver", @@ -4379,7 +5287,7 @@ version = "0.249.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30538cae9a794215f490b532df01c557e2e2bfac92569482554acd0992a102ea" dependencies = [ - "bitflags", + "bitflags 2.11.1", "indexmap", "semver", ] @@ -4401,22 +5309,22 @@ version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" dependencies = [ - "addr2line", + "addr2line 0.26.1", "async-trait", - "bitflags", + "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", "encoding_rs", "futures", "fxprof-processed-profile", - "gimli", + "gimli 0.33.0", "ittapi", "libc", "log", "mach2", "memfd", - "object", + "object 0.39.1", "once_cell", "postcard", "pulley-interpreter", @@ -4459,17 +5367,17 @@ dependencies = [ "cranelift-bforest", "cranelift-bitset", "cranelift-entity", - "gimli", + "gimli 0.33.0", "hashbrown 0.16.1", "indexmap", "log", - "object", + "object 0.39.1", "postcard", "rustc-demangle", "semver", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "smallvec", "target-lexicon", "wasm-encoder 0.246.2", @@ -4492,7 +5400,7 @@ dependencies = [ "rustix 1.1.4", "serde", "serde_derive", - "sha2", + "sha2 0.10.9", "toml", "wasmtime-environ", "windows-sys 0.61.2", @@ -4544,10 +5452,10 @@ dependencies = [ "cranelift-entity", "cranelift-frontend", "cranelift-native", - "gimli", - "itertools", + "gimli 0.33.0", + "itertools 0.14.0", "log", - "object", + "object 0.39.1", "pulley-interpreter", "smallvec", "target-lexicon", @@ -4581,7 +5489,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" dependencies = [ "cc", - "object", + "object 0.39.1", "rustix 1.1.4", "wasmtime-internal-versioned-export-macros", ] @@ -4607,7 +5515,7 @@ dependencies = [ "cfg-if", "cranelift-codegen", "log", - "object", + "object 0.39.1", "wasmtime-environ", ] @@ -4629,9 +5537,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95f439b70ba3855a8c808d2cd798eef79bcd389f78aa48a8a694ea8e2904410c" dependencies = [ "cranelift-codegen", - "gimli", + "gimli 0.33.0", "log", - "object", + "object 0.39.1", "target-lexicon", "wasmparser 0.246.2", "wasmtime-environ", @@ -4646,7 +5554,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "17c7ced16dc16d2027f9f8d3a503e191dcce0f53fe9218e7990135b31f8f6fdb" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "heck", "indexmap", "wit-parser 0.246.2", @@ -4659,7 +5567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d3d57dd833d0c3ea2016a2aa54c6c517bf8dad9e79d8a593b0252c12bc961e3" dependencies = [ "async-trait", - "bitflags", + "bitflags 2.11.1", "bytes", "cap-fs-ext", "cap-net-ext", @@ -4803,7 +5711,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" dependencies = [ "libredox", - "wasite", + "wasite 0.1.0", +] + +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite 1.0.2", + "web-sys", ] [[package]] @@ -4812,7 +5733,7 @@ version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f878b066ad36054ad6e7724230f28ea7f981f44e595e39946d5225fd9e87755" dependencies = [ - "bitflags", + "bitflags 2.11.1", "thiserror 2.0.18", "tracing", "wasmtime", @@ -4885,7 +5806,7 @@ checksum = "6da7c536f3cfe5ff63537f795902fed56b8b5adcc7a87843a86dd8d4e57a7946" dependencies = [ "cranelift-assembler-x64", "cranelift-codegen", - "gimli", + "gimli 0.33.0", "regalloc2", "smallvec", "target-lexicon", @@ -5215,7 +6136,7 @@ version = "0.36.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "windows-sys 0.59.0", ] @@ -5283,7 +6204,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -5497,7 +6418,7 @@ dependencies = [ "deflate64", "flate2", "getrandom 0.3.4", - "hmac", + "hmac 0.12.1", "indexmap", "lzma-rust2", "memchr", diff --git a/Cargo.toml b/Cargo.toml index f18833b..27fa925 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,25 +8,37 @@ async-compression = { version = "0.4.39", features = ["tokio", "brotli"] } axum = { version = "0.8.8", features = ["ws"] } axum-streams = { version = "0.24.0", features = ["json"] } celes = "2.6.0" -chrono = "0.4.43" +chrono = { version = "0.4.43", features = ["serde"] } crossbeam-queue = "0.3.12" dotenv = "0.15.0" env_logger = "0.11.9" futures = "0.3.32" +futures-util = { version = "0.3.32", optional = true } libtbr = { git = "https://pakin-inspiron-15-3530.tail110d9.ts.net/pakin/libtbr.git", version = "0.1.1" } log = "0.4.29" openssl = { version = "0.10.80", features = ["vendored"] } rayon = "1.11.0" redis = { version = "1.0.2", features = ["tls-rustls-webpki-roots", "tokio-comp", "tokio-rustls-comp"] } -reqwest = { version = "0.13.1", features = ["multipart", "rustls"] } +reqwest = { version = "0.13.1", features = ["multipart", "rustls", "json"] } +tracing = "0.1.41" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" sqlx = { version = "0.8.6", features = ["runtime-tokio", "tls-rustls", "sqlite"] } tokio = { version = "1.49.0", features = ["full"] } tokio-cron-scheduler = "0.15.1" tokio-stream = "0.1.18" +tokio-tungstenite = { version = "0.29.0", features = ["connect"], optional = true } tokio-util = "0.7.18" uuid = { version = "1.20.0", features = ["v4"] } wasmtime = { version = "44.0.1", features = ["async"] } wasmtime-wasi = "44.0.1" wasmtime-wasi-http = "44.0.1" +tokio-postgres = "0.7.17" +pprof = { version = "0.15.0", features = ["flamegraph", "prost-codec"] } +prost = "0.14.4" +jsonwebtoken = { version = "10.4.0", features = ["rsa", "rust_crypto"] } +aes-gcm = "0.10.3" +base64 = "0.22.1" +p256 = { version = "0.13.2", features = ["ecdh"] } +rand_core = { version = "=0.6.4", features = ["getrandom"] } +rand = "0.10.1" diff --git a/Dockerfile b/Dockerfile index 2d1650f..64c26c8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ FROM chef AS builder # Capture Docker's target platform variables ARG TARGETPLATFORM ARG TARGETARCH +ARG TARGETVARIANT # Install host tools needed for compilation (including cmake and clang for aws-lc-sys) RUN apt-get update && apt-get install -y \ @@ -43,6 +44,9 @@ RUN apt-get update && \ echo "CXX=/usr/bin/x86_64-linux-gnu-g++" >> /env_config; \ echo "CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/bin/x86_64-linux-gnu-gcc" >> /env_config; \ echo "OPENSSL_DIR=/usr" >> /env_config; \ + if [ "$TARGETVARIANT" = "v3" ]; then \ + echo "export RUSTFLAGS='-C target-cpu=x86-64-v3'" >> /env_config; \ + fi \ elif [ "$TARGETARCH" = "arm64" ]; then \ apt-get install -y gcc-aarch64-linux-gnu g++-aarch64-linux-gnu libssl-dev; \ echo "TARGET_TRIPLE=aarch64-unknown-linux-gnu" >> /env_config; \ @@ -87,7 +91,17 @@ RUN . /env_config && \ # ----------------------------------------------------------------------- # Stage 3: Minimal Runtime # ----------------------------------------------------------------------- -FROM debian:bookworm-slim AS runtime +# We dynamically anchor the platform to a base architecture to stop Docker +# from panicking over micro-variants like v3 during runtime container setup. +FROM --platform=$TARGETPLATFORM debian:bookworm-slim AS runtime-base + +# Trick Buildx: force the runner to resolve back to basic broad platforms +# so it can execute native container processes like apt-get without errors. +FROM --platform=linux/amd64 debian:bookworm-slim AS runtime-amd64 +FROM --platform=linux/arm64 debian:bookworm-slim AS runtime-arm64 + +# Select the clean runtime environment matching your target layout +FROM runtime-$TARGETARCH AS final-runtime WORKDIR /app # Install runtime dependencies if needed (like ca-certificates or openssl) @@ -98,6 +112,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /server-mark2-dev /usr/local/bin/server-mark2-dev COPY --from=builder /app/.env /usr/local/bin/.env +COPY --from=builder /app/.env /app/.env COPY --from=builder /app/sheet-api.json /usr/local/bin/sheet-api.json +COPY --from=builder /app/sheet-api.json /app/sheet-api.json +COPY --from=builder /app/shared-configures.json /usr/local/bin/shared-configures.json +COPY --from=builder /app/shared-configures.json /app/shared-configures.json COPY --from=builder /app/plugins /usr/local/bin/plugins CMD ["server-mark2-dev"] \ No newline at end of file diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..bb998e3 --- /dev/null +++ b/build.sh @@ -0,0 +1 @@ +docker build --pull --platform linux/amd64,linux/arm64 -t pakin-inspiron-15-3530.tail110d9.ts.net/pakin/server-m2 . \ No newline at end of file diff --git a/push.sh b/push.sh new file mode 100755 index 0000000..53bbea8 --- /dev/null +++ b/push.sh @@ -0,0 +1 @@ +docker push pakin-inspiron-15-3530.tail110d9.ts.net/pakin/server-m2:latest diff --git a/src/app.rs b/src/app.rs index d84ef74..e07fbd9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,11 +1,18 @@ -use crate::websocket::{core::*, helper::read_sheet_config, model::*}; +use crate::websocket::{ + core::*, + helper::{read_shared_configures, read_sheet_config}, + model::*, + session::refresh_jwk_cache, +}; +use axum::body::Body; use axum::{ Router, extract::DefaultBodyLimit, routing::{get, post}, serve::ListenerExt, }; -use log::{error, info}; +use log::{error, info, warn}; +use pprof::ProfilerGuardBuilder; use redis::TypedCommands; use reqwest::{StatusCode, multipart}; use std::{ @@ -15,6 +22,7 @@ use std::{ time::Duration, }; use tokio::sync::{Mutex, mpsc::Sender}; +use tokio_postgres::NoTls; #[derive(Clone)] pub struct Hub { @@ -27,8 +35,8 @@ pub struct DevConfig { pub api_domain: String, pub api_recipe_service: String, pub api_redis_url: String, - pub api_resolver: String, pub api_sheet_endpoints: Arc>>, + pub shared_configures: Arc>, pub allowed_origins: Vec, } @@ -38,16 +46,16 @@ impl DevConfig { domain: String, rp_service: String, api_redis_url: String, - api_resolver: String, api_sheet_endpoints: Arc>>, + shared_configures: Arc>, ) -> DevConfig { DevConfig { api_key: key, api_domain: domain, api_recipe_service: rp_service, api_redis_url, - api_resolver, api_sheet_endpoints, + shared_configures, allowed_origins: Vec::new(), } } @@ -81,10 +89,6 @@ impl DevConfig { ("X-API-Key".to_string(), self.api_key.clone()) } - pub fn get_yuki_resolver(&self) -> String { - format!("{}/resolve", self.api_resolver) - } - pub fn check_sheet_endpoints(&self, service_endpoint: &str) -> bool { self.api_sheet_endpoints .try_lock() @@ -95,42 +99,178 @@ impl DevConfig { pub fn load_sheet_endpoints_runtime(&self, new_config: Vec) { *self.api_sheet_endpoints.try_lock().unwrap() = new_config; } + + pub fn get_shared_config_by_type(&self, type_name: String) -> serde_json::Value { + let c = { + let lock = self.shared_configures.read().unwrap(); + lock.as_object().cloned().unwrap_or_default() + }; + + if c.contains_key(&type_name) { + c.get(&type_name).cloned().unwrap() + } else { + serde_json::Value::Null + } + } + + pub fn load_shared_config_runtime( + &self, + update_payload: serde_json::Value, + ) -> Result, Box> { + if let Some(kv) = &update_payload.as_object() { + let new_update_keys: Vec> = kv.keys().map(Arc::new).collect(); + + let mut cs = { + let mut write_perm = self.shared_configures.write().unwrap(); + write_perm.as_object_mut().cloned().unwrap() + }; + + let mut result_update = Vec::new(); + // overwrite + for new_up_key in new_update_keys.to_owned() { + // new config + if let Some(cfg_val) = update_payload + .as_object() + .unwrap() + .get(&new_up_key.to_string()) + { + let old_config = cs.insert(new_up_key.to_string(), cfg_val.to_owned()); + + info!("[config] updating {new_up_key}: from {old_config:?} to {cfg_val:?}"); + + result_update.push(new_up_key.to_string()); + } + } + + { + let mut write_perm = self.shared_configures.write().unwrap(); + + *write_perm = serde_json::Value::Object(cs); + info!("[config] successfully update!"); + } + + Ok(result_update) + } else { + Err(format!("unexpected type format").into()) + } + } + + /// helper function for getting country code + pub fn get_country_config_from_short_name(&self, short_name: &str) -> Option { + let new_short_name_static = short_name.to_string(); + let short_name_arc = Arc::new(&new_short_name_static); + // expect country setting + match self.get_shared_config_by_type("country".to_string()) { + serde_json::Value::Object(m) => { + let keys: Vec> = m.keys().map(Arc::new).collect(); + + if keys.contains(&short_name_arc) + && let Some(ccfg) = m.get(short_name_arc.as_str()) + && let Some(prefix) = ccfg.as_object().unwrap().get("prefix") + && let Some(prefix_i) = prefix.as_i64() + { + return Some(prefix_i); + } else { + return None; + } + } + _ => { + // not found + return None; + } + } + } } pub struct AppState { pub dev_config: DevConfig, pub redis_cli: redis::Client, + pub postgres_cli: Arc>, pub system_tx: tokio::sync::broadcast::Sender, // saved client uid:client uuid pub connectors_mapping: Arc>, + pub interceptor: Arc>, + pub http_client: reqwest::Client, + pub debug: bool, + /// Google public keys for decode Firebase JWT + pub jwk_encoding_keys: RwLock>, + pub firebase_project_id: String, } impl AppState { - pub fn get_cfg(&self) -> DevConfig { - self.dev_config.clone() - } + // pub fn get_cfg(&self) -> DevConfig { + // self.dev_config.clone() + // } pub async fn new( dev_config: DevConfig, redis_cli: redis::Client, + postgres_cli: tokio_postgres::Client, system_tx: tokio::sync::broadcast::Sender, mut system_rx: tokio::sync::broadcast::Receiver, ) -> Arc { let redis_cli_clone = redis_cli.clone(); let tx_new = system_tx.clone(); + + let mut interceptor = crate::websocket::interceptor::create_interceptor_client(&dev_config); + if let Some(ref mut ic) = interceptor { + ic.start(); + info!("Interceptor initialized and started"); + } else { + info!("Interceptor disabled or not configured"); + } + let interceptor = Arc::new(interceptor); + + // Create shared HTTP client with connection pool limits + let http_pool_max_idle = env::var("HTTP_POOL_MAX_IDLE") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(4); + let http_pool_idle_timeout = env::var("HTTP_POOL_IDLE_TIMEOUT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(30); + let http_client = reqwest::Client::builder() + .pool_max_idle_per_host(http_pool_max_idle) + .pool_idle_timeout(Duration::from_secs(http_pool_idle_timeout)) + .timeout(Duration::from_secs(60)) + .build() + .expect("Failed to create HTTP client"); + + let debug = env::var("DEBUG") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(false); + + let firebase_project_id = env::var("FIREBASE_PROJECT_ID").expect("Project id not found"); + let result = Arc::new(AppState { dev_config: dev_config.clone(), redis_cli, + postgres_cli: Arc::new(Mutex::new(postgres_cli)), system_tx, connectors_mapping: Arc::new(RwLock::new(Hub { clients: HashMap::new(), })), + interceptor, + http_client, + debug, + firebase_project_id, + jwk_encoding_keys: RwLock::new(Vec::new()), }); - - // backup job - let dev_config_backup = dev_config.clone(); // NOTE: removed backup process, let each app handled by themselves + // Background task for refresh Google's keys daily + let self_clone = result.clone(); + tokio::spawn(async move { + loop { + if let Err(e) = refresh_jwk_cache(Arc::clone(&self_clone)).await { + error!("Failed tp updating background JWKS keys: {e:?}"); + } + tokio::time::sleep(Duration::from_secs(86400)).await; + } + }); + tokio::spawn(async move { let mut lredis = redis_cli_clone.clone(); let current_queue: crossbeam_queue::ArrayQueue = @@ -238,15 +378,173 @@ impl AppState { } } +async fn pprof_profile() -> axum::response::Response { + pprof_profile_internal(10).await +} + +async fn pprof_profile_internal(seconds: u64) -> axum::response::Response { + let mut guard = ProfilerGuardBuilder::default() + .frequency(1000) + .blocklist(&["libc", "libgcc", "pthread", "vdso"]) + .build() + .unwrap(); + + info!("Starting CPU profile for {} seconds...", seconds); + tokio::time::sleep(std::time::Duration::from_secs(seconds)).await; + info!("CPU profile collection complete"); + + let report = match guard.report().build() { + Ok(r) => { + info!("Report built successfully, samples: {}", r.data.len()); + r + } + Err(e) => { + error!("Failed to build report: {:?}", e); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(axum::body::Body::from(format!( + "Failed to build report: {:?}", + e + ))) + .unwrap(); + } + }; + + // Use flamegraph to generate SVG (simpler and works reliably) + let mut buf = Vec::new(); + if let Err(e) = report.flamegraph(&mut buf) { + error!("Failed to generate flamegraph: {:?}", e); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(axum::body::Body::from(format!( + "Failed to generate flamegraph: {:?}", + e + ))) + .unwrap(); + } + + info!("flamegraph SVG size: {} bytes", buf.len()); + + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "image/svg+xml") + .body(axum::body::Body::from(buf)) + .unwrap() +} + +async fn pprof_profile_with_duration( + axum::extract::Path(seconds): axum::extract::Path, +) -> axum::response::Response { + // Clamp duration between 1 and 60 seconds + let duration = seconds.clamp(1, 60); + pprof_profile_internal(duration).await +} + +async fn pprof_heap() -> impl axum::response::IntoResponse { + // Heap profiling requires jemalloc or similar allocator + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Heap profiling requires jemalloc allocator. Use /debug/pprof/profile for CPU profiling." + .to_string(), + ) +} + +async fn pprof_growth() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Heap growth profiling requires jemalloc allocator. Use /debug/pprof/profile for CPU profiling.".to_string(), + ) +} + +async fn pprof_cmdline() -> impl axum::response::IntoResponse { + let args: Vec = std::env::args().collect(); + let buf = args.join("\0"); + + ([(axum::http::header::CONTENT_TYPE, "text/plain")], buf) +} + +async fn pprof_symbol() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Symbol endpoint - use with pprof tool".to_string(), + ) +} + +async fn pprof_trace() -> axum::response::Response { + let mut guard = ProfilerGuardBuilder::default() + .frequency(1000) + .build() + .unwrap(); + + info!("Starting CPU trace for 5 seconds..."); + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + info!("CPU trace collection complete"); + + let report = match guard.report().build() { + Ok(r) => { + info!("Trace report built successfully, samples: {}", r.data.len()); + r + } + Err(e) => { + error!("Failed to build trace report: {:?}", e); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(axum::body::Body::from(format!( + "Failed to build trace report: {:?}", + e + ))) + .unwrap(); + } + }; + + // Use flamegraph to generate SVG (simpler and works reliably) + let mut buf = Vec::new(); + if let Err(e) = report.flamegraph(&mut buf) { + error!("Failed to generate trace flamegraph: {:?}", e); + return axum::response::Response::builder() + .status(axum::http::StatusCode::INTERNAL_SERVER_ERROR) + .body(axum::body::Body::from(format!( + "Failed to generate trace flamegraph: {:?}", + e + ))) + .unwrap(); + } + + info!("trace flamegraph SVG size: {} bytes", buf.len()); + + axum::response::Response::builder() + .header(axum::http::header::CONTENT_TYPE, "image/svg+xml") + .body(axum::body::Body::from(buf)) + .unwrap() +} + +async fn pprof_allocs() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Allocation profiling requires jemalloc allocator. Use /debug/pprof/profile for CPU profiling.".to_string(), + ) +} + +async fn pprof_mutex() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Mutex profiling not available in this build.".to_string(), + ) +} + +async fn pprof_block() -> impl axum::response::IntoResponse { + ( + [(axum::http::header::CONTENT_TYPE, "text/plain")], + "Block profiling not available in this build.".to_string(), + ) +} + pub async fn invoke_checkout_request( + http_client: &reqwest::Client, config: DevConfig, path: String, ) -> Result> { - let client = reqwest::Client::new(); - let req_path = config.get_file_from_recipe_repo(path); - // println!("dbg: {req_path}"); - let res = client.get(req_path).send().await?; + let res = http_client.get(req_path).send().await?; match res.text().await { Ok(raw) => Ok(raw), @@ -256,17 +554,13 @@ pub async fn invoke_checkout_request( /// Invoke git pull, may takes sometime pub async fn invoke_pull_sync_request( + http_client: &reqwest::Client, config: DevConfig, ) -> Result> { - let client = reqwest::Client::new(); - let req_path = config.get_pull_recipe_repo(); - // println!("dbg: {req_path}"); - let res = client.get(req_path).send().await?; + let res = http_client.get(req_path).send().await?; if res.status() != StatusCode::OK { - // pull fail - error!( "invoke pull fail: [{}] {:?}", res.status(), @@ -283,21 +577,21 @@ pub async fn invoke_pull_sync_request( /// Invoke sending from server to server for committing pub async fn invoke_commit_request( + http_client: &reqwest::Client, config: DevConfig, payload: CommitPayload, -) -> Result<(), Box> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(60)) - .build()?; + tx: Sender, + uid: String, +) -> Result> { let commit_path = config.get_post_file_to_recipe_repo(); let filename = payload.path.split("/").last().unwrap_or("temp").to_string(); info!("committing {}", filename); let form = multipart::Form::new() - .text("message", payload.message) - .text("signature_username", payload.signature_username) - .text("signature_email", payload.signature_email) + .text("message", payload.message.clone()) + .text("signature_username", payload.signature_username.clone()) + .text("signature_email", payload.signature_email.clone()) .text("path", payload.path) .part( "file", @@ -306,25 +600,60 @@ pub async fn invoke_commit_request( .mime_str("application/octet-stream") .unwrap(), ); - let response = client.post(commit_path).multipart(form).send().await?; + let response = http_client.post(commit_path).multipart(form).send().await?; - info!( - "commit status: {}, {:?}", - response.status(), - response.text().await - ); + info!("commit status: {}", response.status()); - Ok(()) + let status = response.status(); + let body = response.text().await; + + // if status == StatusCode::OK + // && let Ok(txt) = body + // && let Ok(res) = serde_json::from_str::(&txt) + // { + // info!("commit success") + // } else { + // warn!("status: {status}, response: {body:?}"); + // } + + match body { + Ok(b) if status == StatusCode::OK => { + if let Ok(res) = serde_json::from_str::(&b) + && let Some(cid) = res.get("result") + && let Some(cid_str) = cid.as_str() + { + // + info!("response commit id: {cid_str}"); + if let Err(e) = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "save_recipe", + "payload": { + "to": uid, + "user": payload.signature_username, + "email": payload.signature_email, + "summary": payload.message + } + }))) + .await + {} + } else { + error!("failed to create json from body on commit response\n{b:#?}"); + } + } + other => { + error!("status not ok, {:?}", other); + } + } + + Ok("empty".to_string()) } /// Invoke sending from server to server for committing case multiple files pub async fn invoke_commit_multiple_files_request( + http_client: &reqwest::Client, config: DevConfig, payloads: Vec, ) -> Result<(), Box> { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(60)) - .build()?; let commit_path = config.get_post_file_to_recipe_repo(); let mut form = multipart::Form::new() .text("message", payloads.first().unwrap().message.clone()) @@ -355,23 +684,21 @@ pub async fn invoke_commit_multiple_files_request( ); } - let response = client.post(commit_path).multipart(form).send().await?; + let response = http_client.post(commit_path).multipart(form).send().await?; info!("commit status: {}", response.status()); Ok(()) } -pub async fn invoke_push_request(config: DevConfig) -> Result> { - let client = reqwest::Client::new(); - +pub async fn invoke_push_request( + http_client: &reqwest::Client, + config: DevConfig, +) -> Result> { let req_path = config.get_push_recipe_repo(); - // println!("dbg: {req_path}"); - let res = client.get(req_path).send().await?; + let res = http_client.get(req_path).send().await?; if res.status() != StatusCode::OK { - // pull fail - error!( "invoke push fail: [{}] {:?}", res.status(), @@ -400,48 +727,101 @@ pub async fn initialize() -> Result<(), Box> { let api_redis = env::var("DEV_API_REDIS").unwrap_or("0.0.0.0".to_string()); let api_redis_port = env::var("DEV_API_REDIS_PORT").unwrap_or("6379".to_string()); - let api_resolver = env::var("RESOLVER_SERVICE_URL").expect("no available resolver"); + // No need for resolver + // let api_resolver = env::var("RESOLVER_SERVICE_URL").expect("no available resolver"); let allowed_origins = env::var("ALLOWED_ORIGINS").expect("allowed origin not provided"); + let postgres_connection_config = env::var("POSTGRES_CONN") + .expect("postgres connection not provided") + .replace('\"', "") + .replace('"', ""); + // read up sheet config // let sheet_endpoint_config = read_sheet_config()?; + let shared_configures = read_shared_configures()?; let mut dev_cfg = crate::app::DevConfig::new( api_key, api_domain, api_recipe_service, format!("redis://{api_redis}:{api_redis_port}"), - api_resolver, Arc::new(Mutex::new(sheet_endpoint_config)), + Arc::new(RwLock::new(shared_configures)), ); dev_cfg = dev_cfg.with_allowed_origins(&allowed_origins).clone(); - // test_send(dev_cfg).await?; - // let redis_cli = redis::Client::open(dev_cfg.api_redis_url.clone())?; - let (sys_tx, sys_rx) = tokio::sync::broadcast::channel::(16); + let (mut client, connection) = + tokio_postgres::connect(&postgres_connection_config, NoTls).await?; + tokio::spawn(async move { + if let Err(e) = connection.await { + error!("connection postgres error: {e}"); + } + }); - let app_state = AppState::new(dev_cfg.clone(), redis_cli, sys_tx, sys_rx).await; + info!("[SETUP] create material table ..."); + if let Err(error_create_material_table) = client.batch_execute(CREATE_MATERIAL_TABLE).await { + error!("[SETUP] error while creating material table: {error_create_material_table}"); + } + + // Reduced broadcast channel capacity from 16 to 4 + let (sys_tx, sys_rx) = tokio::sync::broadcast::channel::(4); + + let app_state = AppState::new(dev_cfg.clone(), redis_cli, client, sys_tx, sys_rx).await; let rp_router = create_recipe_repo_router().await; // let doc_router = create_tx_patcher_route().await; - let app = Router::new() + let mut app = Router::new() // .route("/sessionLogin", post(session_login)) .route( "/syscb", post(crate::websocket::handler::post_from_other_system), ) .route("/users", get(crate::websocket::handler::get_online_users)) + .route( + "/interceptor/health", + get(crate::websocket::handler::interceptor_health), + ) .route("/load-config", post(crate::websocket::handler::post_config)) + .route( + "/config/{key}", + get(crate::websocket::handler::get_shared_config), + ) + // for shared ref + .route( + "/config", + post(crate::websocket::handler::update_shared_config), + ) // .route("/regas", post(request_api_session_key)) .nest("/recipe", rp_router) // .nest("/docs", doc_router) .layer(DefaultBodyLimit::max(100 * 1024 * 1024)) - .with_state(app_state); + .with_state(app_state.clone()); + + // Conditionally add debug profiling endpoints + if app_state.debug { + app = app + .route("/debug/pprof/profile", get(pprof_profile)) + .route( + "/debug/pprof/profile/{seconds}", + get(pprof_profile_with_duration), + ) + .route("/debug/pprof/heap", get(pprof_heap)) + .route("/debug/pprof/growth", get(pprof_growth)) + .route("/debug/pprof/cmdline", get(pprof_cmdline)) + .route("/debug/pprof/symbol", get(pprof_symbol)) + .route("/debug/pprof/trace", get(pprof_trace)) + .route("/debug/pprof/allocs", get(pprof_allocs)) + .route("/debug/pprof/mutex", get(pprof_mutex)) + .route("/debug/pprof/block", get(pprof_block)); + info!("Debug profiling endpoints enabled"); + } else { + info!("Debug profiling endpoints disabled (set DEBUG=true to enable)"); + } // feature: no delay, full throttle let nodelay_listener = || async { diff --git a/src/main.rs b/src/main.rs index 6c86f9f..e754b49 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ -// mod cold_start; mod app; mod stream; -// mod tx; +mod summary; mod websocket; // features @@ -9,16 +8,34 @@ mod websocket; // - store in redis // - cron job fetch update -#[tokio::main] -async fn main() -> Result<(), Box> { +fn main() -> Result<(), Box> { dotenv::dotenv().ok(); env_logger::builder() .filter_level(log::LevelFilter::Info) .init(); - // send req to repo service - app::initialize().await?; + // Configure tokio runtime with limited worker threads from env + let worker_threads = std::env::var("TOKIO_WORKER_THREADS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(4); - Ok(()) + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(worker_threads) + .enable_all() + .build()?; + + // Configure Rayon thread pool from env + let rayon_threads = std::env::var("RAYON_NUM_THREADS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(4); + rayon::ThreadPoolBuilder::new() + .num_threads(rayon_threads) + .build_global()?; + + runtime.block_on(async { + app::initialize().await + }) } diff --git a/src/stream/model.rs b/src/stream/model.rs index 7b61e6e..021c299 100644 --- a/src/stream/model.rs +++ b/src/stream/model.rs @@ -184,9 +184,22 @@ where impl StreamDataExtra where - T: Serialize + Clone, + T: Serialize, { pub fn new(exid: &str, extp: &str, data: Vec, to: String) -> Self { + Self { + exid: exid.to_string(), + extp: extp.to_string(), + payload: data, + to, + } + } + + /// Create from slice - avoids Clone bound and intermediate allocation + pub fn from_slice(exid: &str, extp: &str, data: &[T], to: String) -> Self + where + T: Clone, + { Self { exid: exid.to_string(), extp: extp.to_string(), diff --git a/src/summary.rs b/src/summary.rs new file mode 100644 index 0000000..78a5dac --- /dev/null +++ b/src/summary.rs @@ -0,0 +1,22 @@ +pub fn get_summarized_text(text: &str, payload: Option) -> String { + let mut result = String::new(); + + if text.eq("notify") + && let Some(payload) = payload.clone() + && let Some(summary_text) = payload.get("summary") + { + result = summary_text.as_str().unwrap_or_default().to_string(); + } else if text.ne("notify") { + match text { + "save_recipe" + if let Some(payload) = payload.clone() + && let Some(summary_text) = payload.get("summary") => + { + result = summary_text.as_str().unwrap_or_default().to_string(); + } + _ => {} + } + } + + result +} diff --git a/src/websocket/core.rs b/src/websocket/core.rs index b15f4c0..2fc37fc 100644 --- a/src/websocket/core.rs +++ b/src/websocket/core.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{pin::Pin, time::Duration}; use serde::Deserialize; @@ -16,6 +16,77 @@ pub const LAST_CHANGE_DATE_FORMAT: &str = "%v %T"; /// CONFIG: websocket size limit pub const WEBSOCKET_MAX_BYTES: usize = 2 * 1024 * 1024; +/// CONFIG: shared configures known name for every services +pub const SHARED_CONFIGURES_FILE: &str = "shared-configures.json"; + +pub const CREATE_MATERIAL_TABLE: &str = "CREATE TABLE IF NOT EXISTS material_setting ( + -- Primary key + id INTEGER PRIMARY KEY, + + -- Basic identification + id_alternate INTEGER NOT NULL DEFAULT 0, + is_use BOOLEAN NOT NULL DEFAULT true, + material_name VARCHAR(255) NOT NULL, + material_other_name VARCHAR(255), + path_other_name VARCHAR(255), + + -- Channel type (mutually exclusive in practice) + bean_channel BOOLEAN NOT NULL DEFAULT false, + syrup_channel BOOLEAN NOT NULL DEFAULT false, + powder_channel BOOLEAN NOT NULL DEFAULT false, + fresh_syrup_channel BOOLEAN NOT NULL DEFAULT false, + frozen_fruit_channel BOOLEAN NOT NULL DEFAULT false, + ice_scream_bingsu_channel BOOLEAN NOT NULL DEFAULT false, + soda_channel BOOLEAN NOT NULL DEFAULT false, + leaves_channel BOOLEAN NOT NULL DEFAULT false, + item_channel BOOLEAN NOT NULL DEFAULT false, + is_equipment BOOLEAN NOT NULL DEFAULT false, + + -- Canister/Container configuration + canister_type VARCHAR(100) NOT NULL, -- 'BeanType', 'Bag In Box', 'PowderType', 'Tank', 'Machine', '1,Valve' + + -- Operational parameters + alarm_id_when_offline INTEGER NOT NULL DEFAULT 0, + drain_timer INTEGER NOT NULL DEFAULT 0, + low_to_offline INTEGER NOT NULL DEFAULT 0, + material_status INTEGER NOT NULL DEFAULT 0, -- 0=normal, 2=? + schedule_drain_type INTEGER NOT NULL DEFAULT 0, + pay_retry_max_count INTEGER NOT NULL DEFAULT 0, + + -- Refill units (mutually exclusive) + refill_unit_gram BOOLEAN NOT NULL DEFAULT false, + refill_unit_milliliters BOOLEAN NOT NULL DEFAULT false, + refill_unit_pcs BOOLEAN NOT NULL DEFAULT false, + + -- Feed mode (for syrups) + feed_mode VARCHAR(50), -- 'mode=1', 'mode=2' + + -- Material parameters (optional, for specific types) + material_parameter TEXT, + + -- Unit tracking + raw_material_unit VARCHAR(255), -- 'refill=$bag,sum=#gram,rec=$gram' etc. + + -- Error messages (localized, fixed 8 slots) + str_text_show_error TEXT[], -- array of 8 strings + + -- Timestamps + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +-- Indexes for common queries +CREATE INDEX IF NOT EXISTS idx_material_setting_channel_type ON material_setting + (bean_channel, syrup_channel, powder_channel, fresh_syrup_channel, frozen_fruit_channel, ice_scream_bingsu_channel) + WHERE is_use = true; + +CREATE INDEX IF NOT EXISTS idx_material_setting_name ON material_setting (material_name) WHERE is_use = true; +CREATE INDEX IF NOT EXISTS idx_material_setting_canister_type ON material_setting (canister_type);"; + +pub const SHARED_CONFIG_CHANNEL_NAME: &str = "shared_config/update"; + +pub const GOOGLE_PUBLIC_ENDPOINT: &str = + "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"; + #[derive(Clone)] pub enum TxControlMessage { Payload(serde_json::Value), diff --git a/src/websocket/handler.rs b/src/websocket/handler.rs index 4a4e0e9..f52e30b 100644 --- a/src/websocket/handler.rs +++ b/src/websocket/handler.rs @@ -1,27 +1,36 @@ use axum::{ Json, body::Bytes, - extract::{Request, State, WebSocketUpgrade, ws::WebSocket}, + extract::{ + Path, Request, State, WebSocketUpgrade, + ws::{Message, WebSocket}, + }, response::IntoResponse, }; -use futures::StreamExt; +use futures::{SinkExt, StreamExt}; use log::{error, info, warn}; use redis::TypedCommands; use std::{ fs::File, io::BufWriter, sync::{Arc, RwLock}, + time::Duration, }; use tokio::{ sync::{Mutex, mpsc}, - time::Instant, + time::{Instant, timeout}, }; use uuid::Uuid; use super::{core::*, model::*}; use crate::{ app::{AppState, Hub}, - websocket::helper::read_sheet_config, + websocket::{ + helper::read_sheet_config, + session::{ + HandshakeAck, HandshakePayload, SecureSession, execute_dh_handshake, verify_token, + }, + }, }; pub async fn post_from_other_system( @@ -160,6 +169,61 @@ pub async fn post_config( return (axum::http::StatusCode::OK, "load config success").into_response(); } +/// Endpoint for service calling to get configures +pub async fn get_shared_config( + State(state): State>, + Path(key): Path, +) -> impl IntoResponse { + let result = state.dev_config.get_shared_config_by_type(key); + + if result.is_null() { + return ( + axum::http::StatusCode::NOT_FOUND, + serde_json::json!({}).to_string(), + ) + .into_response(); + } + + // do return value of requested config + return (axum::http::StatusCode::OK, result.to_string()).into_response(); +} + +/// Endpoint for updating config on runtime (Only for shared configurations) +pub async fn update_shared_config( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + let new_key_updates = match state.dev_config.load_shared_config_runtime(payload) { + Ok(keys) => keys, + Err(e) => { + error!("config update fail, {e}"); + return (axum::http::StatusCode::BAD_REQUEST, "unexpected request").into_response(); + } + }; + + // Broadcast to channel + let mut rcl = state.redis_cli.clone(); + match rcl.publish( + SHARED_CONFIG_CHANNEL_NAME, + format!("{new_key_updates:?}").to_string(), + ) { + Ok(_) => { + info!("broadcast success"); + } + Err(e) => { + error!("broadcast fail: {e}"); + return ( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + "fail to broadcast", + ) + .into_response(); + } + } + + return (axum::http::StatusCode::OK, "success").into_response(); +} + +#[deprecated] pub async fn request_api_session_key( State(state): State>, Json(msg): Json, @@ -193,6 +257,32 @@ pub async fn get_online_users(State(state): State>) -> impl IntoRe .into_response() } +pub async fn interceptor_health(State(state): State>) -> impl IntoResponse { + let interceptor_status = match &*state.interceptor { + Some(ic) => serde_json::json!({ + "enabled": true, + "endpoint": ic.config.endpoint, + "async_mode": ic.config.async_mode, + "batch_size": ic.config.batch_size, + "timeout_ms": ic.config.timeout_ms, + }), + None => serde_json::json!({ + "enabled": false, + "reason": "not configured or disabled" + }), + }; + + ( + axum::http::StatusCode::OK, + serde_json::json!({ + "interceptor": interceptor_status, + "timestamp": chrono::Utc::now().to_rfc3339(), + }) + .to_string(), + ) + .into_response() +} + /// Main websocket handler pub async fn websocket_handler( State(state): State>, @@ -201,6 +291,7 @@ pub async fn websocket_handler( ) -> impl IntoResponse { let state_clone = Arc::clone(&state); let hub_clone = Arc::clone(&state_clone.connectors_mapping); + let interceptor_clone = Arc::clone(&state_clone.interceptor); let origin = req .headers() @@ -222,15 +313,20 @@ pub async fn websocket_handler( ws.max_frame_size(WEBSOCKET_MAX_BYTES) .max_message_size(WEBSOCKET_MAX_BYTES) .on_failed_upgrade(|error| println!("Error upgrading websocket: {}", error)) - .on_upgrade(async |s| handle_socket(s, state_clone, hub_clone).await.unwrap_or(())) + .on_upgrade(async |s| { + handle_socket(s, state_clone, hub_clone, interceptor_clone) + .await + .unwrap_or(()) + }) } async fn handle_socket( socket: WebSocket, state: Arc, hub: Arc>, + interceptor: Arc>, ) -> Result<(), Box> { - let (sender, receiver) = socket.split(); + let (mut sender, mut receiver) = socket.split(); // internal channel let (tx, rx) = mpsc::channel::(2); @@ -255,11 +351,6 @@ async fn handle_socket( let temp_session = user.lock().await.to_string(); info!("{} connected", temp_session); - { - let mut h = hub.write().unwrap(); - h.clients.insert(temp_session.clone(), tx.clone()); - } - // NOTE: disable from cause system tx could directly send to client rx // without sending to system rx. // let user_sys_rx = state.system_tx.subscribe(); @@ -271,8 +362,85 @@ async fn handle_socket( let hub_for_write = hub.clone(); let hub_for_read = hub.clone(); + let interceptor_for_write = interceptor.clone(); + let interceptor_for_read = interceptor.clone(); - let sender = tokio::spawn(super::rw::write(sender, rx, user.clone(), hub_for_write)); + // New 2s auth & key exchange gate + let state_clone = state.clone(); + let auth_result = timeout(Duration::from_secs(2), async { + if let Some(Ok(Message::Text(text))) = receiver.next().await { + let handshake: HandshakePayload = serde_json::from_str(&text)?; + info!("handshake ok!"); + // Offline JWT validation using memory cache + let uid = verify_token(&handshake.token, state_clone).await?; + info!("uid: {uid}"); + // Execute Ephemeral Elliptic Curve DF Key Exchange + let (server_pub_b64, cipher) = execute_dh_handshake(&handshake.client_public_key)?; + + // confirm payload + let ack_payload = serde_json::to_string(&HandshakeAck { + status: "authenticated".to_string(), + server_public_key: server_pub_b64, + })?; + + // info!("ack sending ... {ack_payload}"); + + sender.send(Message::Text(ack_payload.into())).await?; + + return Ok(SecureSession { + uid, + cipher, + key_established_at: Instant::now(), + }); + } + + Err(Box::::from( + "No initial handshake received", + )) + }) + .await; + + // Evaluate handshake state + let session = match auth_result { + Ok(Ok(valid_session)) => Arc::new(valid_session), + _ => { + warn!("Connection dropped: Handshake timeout or authentication failed"); + let _ = sender + .send(Message::Close(Some(axum::extract::ws::CloseFrame { + code: 4001, + reason: std::borrow::Cow::Borrowed("Unauthorized Handshake Failure") + .to_string() + .into(), + }))) + .await; + return Ok(()); + } + }; + + let valid_uid = session.clone().uid.to_string(); + if !session.uid.is_empty() { + // already has uid + // + { + let mut ulock = user.lock().await; + *ulock = valid_uid.clone(); + } + info!("update user uid"); + } + + { + let mut h = hub.write().unwrap(); + h.clients.insert(valid_uid, tx.clone()); + } + + let sender = tokio::spawn(super::rw::write( + sender, + rx, + user.clone(), + hub_for_write, + interceptor_for_write, + session.clone(), + )); let reader = tokio::spawn(super::rw::read( state, receiver, @@ -280,6 +448,8 @@ async fn handle_socket( reader_last_seen, user.clone(), hub_for_read, + interceptor_for_read, + session.clone(), )); // let callback_to_client = super::rw::recv_sys_msg_send_back_client(tx.clone(), user_sys_rx); diff --git a/src/websocket/helper.rs b/src/websocket/helper.rs index 4788c9a..a9a48f0 100644 --- a/src/websocket/helper.rs +++ b/src/websocket/helper.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use std::{cmp::Ordering, collections::HashMap, fs::File, io::BufReader}; -use crate::websocket::core::{LAST_CHANGE_DATE_FORMAT, safe_deserialize}; +use crate::websocket::core::{LAST_CHANGE_DATE_FORMAT, SHARED_CONFIGURES_FILE, safe_deserialize}; use super::model::*; use axum::extract::ws::{CloseFrame, Message, WebSocket}; @@ -129,6 +129,21 @@ pub fn read_sheet_config() -> Result, Box> { Ok(res) } +pub fn read_shared_configures() -> Result> { + let expected_path = format!("./{}", SHARED_CONFIGURES_FILE); + let config_file = match File::open(expected_path) { + Ok(f) => f, + Err(_) => { + return Ok(serde_json::json!({})); + } + }; + let mut buf = BufReader::new(config_file); + + let val: serde_json::Value = serde_json::from_reader(&mut buf)?; + + Ok(val) +} + pub fn parse_date_from_string(date: &str, fmt: Option<&str>) -> Option { let fmt = match fmt { Some(fm) => fm, diff --git a/src/websocket/interceptor/client.rs b/src/websocket/interceptor/client.rs new file mode 100644 index 0000000..675282f --- /dev/null +++ b/src/websocket/interceptor/client.rs @@ -0,0 +1,229 @@ +use crate::websocket::interceptor::config::InterceptorConfig; +use chrono::{DateTime, Utc}; +use reqwest::{ + Client, ClientBuilder, + header::{HeaderMap, HeaderName, HeaderValue}, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterceptedEvent { + pub direction: Direction, + pub user_id: String, + pub action_type: String, + pub payload: Value, + #[serde(with = "chrono::serde::ts_seconds")] + pub timestamp: DateTime, + pub connection_id: String, + pub summary: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Direction { + Incoming, + Outgoing, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct InterceptorResponse { + pub success: bool, + pub message: Option, +} + +pub struct InterceptorClient { + pub config: InterceptorConfig, + http_client: Client, + sender: Option>, + worker_handle: Option>, +} + +impl InterceptorClient { + pub fn new( + config: InterceptorConfig, + ) -> Result> { + let mut headers = HeaderMap::new(); + for (k, v) in &config.headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::from_bytes(k.as_bytes()), + HeaderValue::from_str(v), + ) { + headers.insert(name, value); + } + } + + let http_client = ClientBuilder::new() + .timeout(config.timeout()) + .default_headers(headers) + .build()?; + + Ok(Self { + config, + http_client, + sender: None, + worker_handle: None, + }) + } + + pub fn start(&mut self) { + if !self.config.async_mode { + return; + } + + let (tx, mut rx) = mpsc::channel::(self.config.batch_size * 2); + let config = self.config.clone(); + let http_client = self.http_client.clone(); + + let handle = tokio::spawn(async move { + let mut batch = Vec::new(); + let mut interval = tokio::time::interval(config.batch_timeout()); + + loop { + tokio::select! { + Some(event) = rx.recv() => { + batch.push(event); + if batch.len() >= config.batch_size { + Self::send_batch(&http_client, &config, &mut batch).await; + } + } + _ = interval.tick() => { + if !batch.is_empty() { + Self::send_batch(&http_client, &config, &mut batch).await; + } + } + else => break, + } + } + + if !batch.is_empty() { + Self::send_batch(&http_client, &config, &mut batch).await; + } + }); + + self.sender = Some(tx); + self.worker_handle = Some(handle); + } + + async fn send_batch( + http_client: &Client, + config: &InterceptorConfig, + batch: &mut Vec, + ) { + if batch.is_empty() { + return; + } + + let payload = serde_json::json!({ + "events": batch, + "count": batch.len(), + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + + for attempt in 0..=config.retry_count { + match http_client + .post(&config.endpoint) + .json(&payload) + .send() + .await + { + Ok(response) => { + if response.status().is_success() { + debug!("Interceptor: sent batch of {} events", batch.len()); + batch.clear(); + return; + } else { + warn!( + "Interceptor: failed to send batch (attempt {}/{}), status: {}", + attempt + 1, + config.retry_count + 1, + response.status() + ); + } + } + Err(e) => { + warn!( + "Interceptor: error sending batch (attempt {}/{}): {}", + attempt + 1, + config.retry_count + 1, + e + ); + } + } + + if attempt < config.retry_count { + tokio::time::sleep(Duration::from_millis(100 * (attempt + 1) as u64)).await; + } + } + + error!( + "Interceptor: failed to send batch after {} retries, dropping {} events", + config.retry_count, + batch.len() + ); + batch.clear(); + } + + pub async fn send_async(&self, event: InterceptedEvent) { + if !self.config.async_mode { + return; + } + + if let Some(sender) = &self.sender { + info!("sender found"); + if let Err(e) = sender.send(event).await { + if matches!(e, mpsc::error::SendError(_)) { + warn!("Interceptor: async queue full, dropping event"); + } else { + error!("Interceptor Error: {e}"); + } + } + } + } + + pub async fn send_sync( + &self, + event: InterceptedEvent, + ) -> Result> { + let payload = serde_json::json!({ + "events": [event], + "count": 1, + "timestamp": chrono::Utc::now().to_rfc3339(), + }); + + let response = self + .http_client + .post(&self.config.endpoint) + .json(&payload) + .send() + .await?; + + if response.status().is_success() { + Ok(InterceptorResponse { + success: true, + message: Some("delivered".to_string()), + }) + } else { + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + Err(format!("HTTP {}: {}", status, text).into()) + } + } + + pub fn should_intercept(&self, action_type: &str) -> bool { + self.config.should_intercept(action_type) + } + + pub async fn shutdown(&mut self) { + if let Some(sender) = self.sender.take() { + drop(sender); + } + if let Some(handle) = self.worker_handle.take() { + let _ = handle.await; + } + } +} diff --git a/src/websocket/interceptor/config.rs b/src/websocket/interceptor/config.rs new file mode 100644 index 0000000..771c7cb --- /dev/null +++ b/src/websocket/interceptor/config.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::time::Duration; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterceptorConfig { + pub enabled: bool, + pub endpoint: String, + pub timeout_ms: u64, + pub retry_count: u32, + pub async_mode: bool, + pub batch_size: usize, + pub batch_timeout_ms: u64, + pub headers: HashMap, + pub filter: InterceptorFilter, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InterceptorFilter { + pub include_types: Vec, + pub exclude_types: Vec, +} + +impl InterceptorConfig { + pub fn timeout(&self) -> Duration { + Duration::from_millis(self.timeout_ms) + } + + pub fn batch_timeout(&self) -> Duration { + Duration::from_millis(self.batch_timeout_ms) + } + + pub fn should_intercept(&self, action_type: &str) -> bool { + if !self.enabled { + return false; + } + + if self.filter.exclude_types.contains(&action_type.to_string()) { + return false; + } + + if self.filter.include_types.is_empty() { + return true; + } + + self.filter.include_types.contains(&action_type.to_string()) + } +} + +impl Default for InterceptorConfig { + fn default() -> Self { + Self { + enabled: false, + endpoint: "".to_string(), + timeout_ms: 5000, + retry_count: 3, + async_mode: true, + batch_size: 10, + batch_timeout_ms: 1000, + headers: HashMap::new(), + filter: InterceptorFilter { + include_types: vec![], + exclude_types: vec!["heartbeat".to_string(), "auth".to_string()], + }, + } + } +} \ No newline at end of file diff --git a/src/websocket/interceptor/mod.rs b/src/websocket/interceptor/mod.rs new file mode 100644 index 0000000..cced1bc --- /dev/null +++ b/src/websocket/interceptor/mod.rs @@ -0,0 +1,30 @@ +pub mod client; +pub mod config; + +use self::client::InterceptorClient; +use self::config::InterceptorConfig; +use crate::app::DevConfig; + +pub fn create_interceptor_client(dev_config: &DevConfig) -> Option { + let interceptor_config: InterceptorConfig = { + let lock = dev_config.shared_configures.read().unwrap(); + lock.get("interceptor") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default() + }; + + if !interceptor_config.enabled || interceptor_config.endpoint.is_empty() { + return None; + } + + let mut resolved_headers = std::collections::HashMap::new(); + for (k, v) in &interceptor_config.headers { + let resolved = v.replace("{{INTERCEPTOR_API_KEY}}", &dev_config.api_key); + resolved_headers.insert(k.clone(), resolved); + } + + let mut config = interceptor_config.clone(); + config.headers = resolved_headers; + + InterceptorClient::new(config).ok() +} diff --git a/src/websocket/mod.rs b/src/websocket/mod.rs index e17c7a2..fc5c179 100644 --- a/src/websocket/mod.rs +++ b/src/websocket/mod.rs @@ -1,7 +1,9 @@ pub mod core; pub mod handler; pub mod helper; +pub mod interceptor; pub mod model; pub mod plugins; mod rw; +pub mod session; mod tasks; diff --git a/src/websocket/model.rs b/src/websocket/model.rs index 6604083..a20f59f 100644 --- a/src/websocket/model.rs +++ b/src/websocket/model.rs @@ -39,6 +39,8 @@ pub struct WebsocketMessageRequest { #[serde(rename = "type")] pub type_w: String, pub payload: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub action_id: Option, } /// Recipe request payload struct @@ -150,6 +152,7 @@ impl From for WebsocketMessageRequest { "commit": value, "plugin": "example-js" })), + action_id: None, } } } @@ -164,3 +167,143 @@ pub struct RequestMenuListPayload { /// box id pub boxid: String, } + +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)] +pub enum AvailableMaterialAction { + #[serde(rename = "create")] + Create, + #[serde(rename = "modify")] + Modify, + /// Compute available material id + #[serde(rename = "new_id")] + GetNewMaterialId, + /// Query material in database + #[serde(rename = "query")] + QueryParam, + /// Force to update materials in db + #[serde(rename = "update")] + Update, +} + +/// For interact with materials +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct RequestMaterialActionPayload { + /// User info expect at least id, token, name + pub user_info: serde_json::Value, + /// target country to get recipe, version will always use latest + pub country: String, + /// Available action, reject unexpected scope + pub action: AvailableMaterialAction, + /// Payload data required for some action + pub data: Option, +} + +#[allow(non_snake_case)] +fn BlankString() -> Option { + Some("".to_string()) +} + +#[allow(non_snake_case)] +fn BlankBool() -> Option { + Some(false) +} + +#[allow(non_snake_case)] +fn BlankOtherStrShowTextError() -> Option> { + Some(vec![ + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + "".to_string(), + ]) +} + +/// Request material creation, this will check if material is creatable on criteria +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct CreateMaterial(pub libtbr::models::recipe::MaterialSetting); + +/// Request material edit, the material must existed in database. Otherwise, return fail in tx. +/// Modifying `id` field is prohibited, user must request remove first then insert new. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct ModifyMaterial { + #[serde(rename = "AlarmIDWhenOffline")] + pub alarm_id_when_offline: Option, + #[serde(rename = "BeanChannel")] + pub bean_channel: bool, + #[serde(default = "BlankString", rename = "CanisterType")] + pub canister_type: Option, + #[serde(rename = "DrainTimer")] + pub drain_timer: Option, + #[serde(default = "BlankBool", rename = "IceScreamBingsuChannel")] + pub ice_scream_bingsu_channel: Option, + #[serde(rename = "IsEquipment")] + pub is_equipment: Option, + #[serde(rename = "LeavesChannel")] + pub leaves_channel: Option, + #[serde(rename = "LowToOffline")] + pub low_to_offline: Option, + #[serde(default = "BlankString", rename = "MaterialDescription")] + pub material_description: Option, + #[serde(rename = "MaterialStatus")] + pub material_status: Option, + #[serde(rename = "PowderChannel")] + pub powder_channel: Option, + #[serde(rename = "RefillUnitGram")] + pub refill_unit_gram: Option, + #[serde(rename = "RefillUnitMilliliters")] + pub refill_unit_milliliters: Option, + #[serde(rename = "RefillUnitPCS")] + pub refill_unit_pcs: Option, + #[serde(rename = "ScheduleDrainType")] + pub schedule_drain_type: Option, + #[serde(rename = "SodaChannel")] + pub soda_channel: Option, + #[serde(default = "BlankOtherStrShowTextError", rename = "StrTextShowError")] + pub str_text_show_error: Option>, + #[serde(rename = "SyrupChannel")] + pub syrup_channel: Option, + pub id: i32, + #[serde(rename = "idAlternate")] + pub id_alternate: Option, + #[serde(rename = "isUse")] + pub is_use: Option, + #[serde(default = "BlankString", rename = "materialOtherName")] + pub material_other_name: Option, + #[serde(default = "BlankString", rename = "materialName")] + pub material_name: Option, + #[serde(default = "BlankString", rename = "pathOtherName")] + pub path_other_name: Option, + pub pay_rettry_max_count: Option, + #[serde(default = "BlankString", rename = "RawMaterialUnit")] + pub raw_material_unit: Option, + #[serde(default = "BlankString", rename = "MaterialParameter")] + pub material_parameter: Option, + #[serde(flatten)] + pub extra: std::collections::HashMap, +} + +impl CreateMaterial { + pub fn check_valid(&mut self, country_code: Option) -> bool { + let mat_type = self.0.get_definition_type(country_code); + + return (self.0.BeanChannel + && matches!(mat_type, libtbr::models::recipe::MaterialType::Bean)) + || (self.0.PowderChannel + && matches!(mat_type, libtbr::models::recipe::MaterialType::Powder)) + || (self.0.SyrupChannel + && matches!(mat_type, libtbr::models::recipe::MaterialType::Syrup)) + || (self.0.SodaChannel + && matches!(mat_type, libtbr::models::recipe::MaterialType::Soda)) + || (self.0.IsEquipment + && matches!( + mat_type, + libtbr::models::recipe::MaterialType::Lid + | libtbr::models::recipe::MaterialType::Cup + | libtbr::models::recipe::MaterialType::Straw + )); + } +} diff --git a/src/websocket/rw.rs b/src/websocket/rw.rs index edd134b..018f47c 100644 --- a/src/websocket/rw.rs +++ b/src/websocket/rw.rs @@ -1,8 +1,15 @@ use super::{core::*, helper::*, model::*}; use crate::{ app::*, - websocket::{plugins::call_plugin_if_existed, tasks}, + summary::get_summarized_text, + websocket::{ + interceptor::client::{Direction, InterceptedEvent, InterceptorClient}, + plugins::call_plugin_if_existed, + session::{EncryptedFrame, SecureSession, decrypt_message, encrypt_server_message}, + tasks, + }, }; +use serde_json::Value; use std::{ collections::HashMap, sync::{Arc, RwLock}, @@ -24,7 +31,6 @@ use tokio::{ task::JoinHandle, time::Instant, }; -use wasmtime::{Config, Engine}; pub async fn read( // redis: redis::Client, @@ -34,6 +40,8 @@ pub async fn read( last_seen: Arc>, // cmd_atom: crossbeam_queue::ArrayQueue, uid: Arc>, hub: Arc>, + interceptor: Arc>, + session: Arc, ) -> Result<(), Box> { let redis = state.redis_cli.clone(); let config = state.dev_config.clone(); @@ -43,159 +51,232 @@ pub async fn read( // Plugins // - let engine = Engine::new(Config::new().wasm_component_model(true)).unwrap(); + // let engine = Engine::new(Config::new().wasm_component_model(true)).unwrap(); while let Some(Ok(msg)) = receiver.next().await { match msg { Message::Text(t) => { - let mut req: WebsocketMessageRequest = serde_json::from_str(t.as_str())?; + if let Ok(frame) = serde_json::from_str::(&t) { + match decrypt_message(&session.cipher, &frame) { + Ok(plain_bytes) => { + let plain_text = String::from_utf8_lossy(&plain_bytes); + let req: WebsocketMessageRequest = serde_json::from_str(&plain_text)?; - req = call_plugin_if_existed(req, engine.clone()).await; + // req = call_plugin_if_existed(req, engine.clone()).await; - // info!("get msg: {}", req.type_w); - match req.type_w.as_str() { - "recipe" if req.payload.is_some() => { - if tasks::recipe::handle_recipe_request( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; - } - } - "recipe_versions" if req.payload.is_some() => { - if tasks::recipe::handle_recipe_versions_list_request( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; - } - } - "price" if req.payload.is_some() => { - if tasks::price::handle_price_request( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; - } - } - "command" if req.payload.is_some() => { - if tasks::command::handle_command_request(state.clone(), tx.clone(), req) - .await - .is_err() - { - continue; - } - } - "heartbeat" => { - let new_updated_time = Instant::now(); - let uidd = uid.lock().await.clone(); - *last_seen.lock().await = new_updated_time; + if let Some(ic) = interceptor.as_ref() + && ic.should_intercept(&req.type_w) + { + info!("intercept message ..."); - info!("{}: active", uidd.to_string()); + let uidd_clone = uid.lock().await.clone(); - // send back response to keep alive - // user can now know if server is active or not - let _ = tx - .send_timeout( - TxControlMessage::Payload(serde_json::json!({ - "type": "heartbeat", - "payload": { - "active": true, - "refresh_time": format!("{:?}", new_updated_time), - "to": uidd - } - })), - Duration::from_secs(3), - ) - .await; - } - "sheet" if req.payload.is_some() => { - if tasks::sheet::handle_sheet_request( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; - } - } - "log_report" if let Some(log_payload) = req.payload => { - let log_report_payload: LogReportPayload = - match safe_deserialize(&log_payload) { - Ok(lreq) => lreq, - Err(e) => { - error!("error deserialize body log request: {e:?} ---> Skip"); - continue; + let event = InterceptedEvent { + direction: Direction::Incoming, + user_id: uidd_clone.clone(), + action_type: req.type_w.clone(), + payload: req.payload.clone().unwrap_or_default(), + timestamp: chrono::Utc::now(), + connection_id: uidd_clone.clone(), + summary: None, + }; + ic.send_async(event).await; + } + + // info!("get msg: {}", req.type_w); + let http_client = state.http_client.clone(); + match req.type_w.as_str() { + "recipe" if req.payload.is_some() => { + if tasks::recipe::handle_recipe_request( + &http_client, + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } } - }; - // generate timestamp - // - let now = Instant::now(); - } - "save_recipe" if req.payload.is_some() => { - if tasks::recipe::handle_recipe_save_change_request( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; - } - } - "auth" if req.payload.is_some() => { - tasks::auth::handle_auth_request( - state.clone(), - tx.clone(), - req, - hub.clone(), - uid_clone.clone(), - ) - .await?; - } + "recipe_versions" if req.payload.is_some() => { + if tasks::recipe::handle_recipe_versions_list_request( + &http_client, + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + "price" if req.payload.is_some() => { + if tasks::price::handle_price_request( + &http_client, + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + "command" if req.payload.is_some() => { + if tasks::command::handle_command_request( + state.clone(), + tx.clone(), + req, + ) + .await + .is_err() + { + continue; + } + } + "heartbeat" => { + let new_updated_time = Instant::now(); + let uidd = uid.lock().await.clone(); + *last_seen.lock().await = new_updated_time; - "list_menu" if req.payload.is_some() => { - if tasks::recipe::handle_request_list_menu_recipe( - config.clone(), - redis.clone(), - tx.clone(), - req, - uid_clone.clone(), - ) - .await - .is_err() - { - continue; + // info!("{}: active", uidd.to_string()); + + // send back response to keep alive + // user can now know if server is active or not + let _ = tx + .send_timeout( + TxControlMessage::Payload(serde_json::json!({ + "type": "heartbeat", + "payload": { + "active": true, + "refresh_time": format!("{:?}", new_updated_time), + "to": uidd + } + })), + Duration::from_secs(3), + ) + .await; + } + "sheet" if req.payload.is_some() => { + if tasks::sheet::handle_sheet_request( + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + "log_report" if let Some(log_payload) = req.payload => { + let log_report_payload: LogReportPayload = + match safe_deserialize(&log_payload) { + Ok(lreq) => lreq, + Err(e) => { + error!( + "error deserialize body log request: {e:?} ---> Skip" + ); + continue; + } + }; + // generate timestamp + // + let now = Instant::now(); + } + "save_recipe" if req.payload.is_some() => { + if tasks::recipe::handle_recipe_save_change_request( + &http_client, + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + "auth" if req.payload.is_some() => { + tasks::auth::handle_auth_request( + state.clone(), + tx.clone(), + req, + hub.clone(), + uid_clone.clone(), + ) + .await?; + } + + "list_menu" if req.payload.is_some() => { + if tasks::recipe::handle_request_list_menu_recipe( + &http_client, + config.clone(), + redis.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + "material" if req.payload.is_some() => { + if tasks::recipe::handle_request_material_action( + &http_client, + config.clone(), + redis.clone(), + state.postgres_cli.clone(), + tx.clone(), + req, + uid_clone.clone(), + ) + .await + .is_err() + { + continue; + } + } + _ => { + let uidd = uid.lock().await.clone(); + // not implemented + let _ = tx + .send_timeout( + TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": "not implemented or missing params" + } + })), + Duration::from_secs(3), + ) + .await; + } + } + } + + Err(_) => { + error!("Decryption failed for data sent from UID: {}", session.uid); } } - _ => { - // not implemented - } + } else { + warn!("unexpected encrypted frame: {t:?}"); } } Message::Ping(_) => { @@ -248,6 +329,8 @@ pub async fn write( mut rx: Receiver, uid: Arc>, hub: Arc>, + interceptor: Arc>, + session: Arc, ) -> Result<(), Box> { // only allow each stream type for 1 request let pending_stream_requests = Arc::new(RwLock::new(HashMap::new())); @@ -279,7 +362,8 @@ pub async fn write( && let Some(recv_ident) = res_payload_val.get("to") && let Some(recv_ident_str) = recv_ident.as_str() && (current_uid.to_string().eq(recv_ident_str) - || recv_ident_str.to_string().eq("*")) + || recv_ident_str.to_string().eq("*") + || recv_ident_str.to_string().eq("devws")) { let payload_size = res.to_string().len(); @@ -381,7 +465,14 @@ pub async fn write( } }); - let _ = sender.send(header.to_string().into()).await?; + // let _ = sender.send(header.to_string().into()).await?; + let _ = send_encrypted_message( + &mut sender, + session.clone(), + header.to_string(), + SenderMode::Send, + ) + .await?; for (idx, raw_payload) in split.iter().enumerate() { let raw_chunk_payload = serde_json::json!({ @@ -393,7 +484,15 @@ pub async fn write( "request_id": stream_chunk_id } }); - let _ = sender.feed(raw_chunk_payload.to_string().into()).await; + // let _ = sender.feed(raw_chunk_payload.to_string().into()).await; + + let _ = send_encrypted_message( + &mut sender, + session.clone(), + raw_chunk_payload.to_string(), + SenderMode::Feed, + ) + .await?; } if let Err(e) = sender.flush().await { @@ -411,11 +510,51 @@ pub async fn write( ); } - let _ = sender.send(footer.to_string().into()).await; + // let _ = sender.send(footer.to_string().into()).await; + let _ = send_encrypted_message( + &mut sender, + session.clone(), + footer.to_string(), + SenderMode::Send, + ) + .await?; continue; } else { - if let Err(e) = sender.send(res.to_string().into()).await { + if let Some(ic) = interceptor.as_ref() { + let action_type = res_n + .get("type") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + if ic.should_intercept(action_type) { + let summarized_message = get_summarized_text( + action_type, + Some(Value::Object(res_payload_val.clone())), + ); + let event = InterceptedEvent { + direction: Direction::Outgoing, + user_id: current_uid.to_string(), + action_type: action_type.to_string(), + payload: Value::Object(res_payload_val.clone()), + timestamp: chrono::Utc::now(), + connection_id: current_uid.to_string(), + summary: Some(summarized_message), + }; + ic.send_async(event).await; + } + } + // if let Err(e) = sender.send(res.to_string().into()).await { + // error!("[write] send payload fail; len={payload_size}, reason: {e}"); + // } + + if let Err(e) = send_encrypted_message( + &mut sender, + session.clone(), + res.to_string(), + SenderMode::Send, + ) + .await + { error!("[write] send payload fail; len={payload_size}, reason: {e}"); } } @@ -466,31 +605,30 @@ pub async fn write( Ok(()) } -pub async fn recv_sys_msg_send_back_client( - tx: Sender, - mut system_rx: tokio::sync::broadcast::Receiver, -) -> JoinHandle<()> { - let tx_to_client = tx.clone(); - tokio::spawn(async move { - loop { - match system_rx.recv().await { - Ok(s_msg) => { - if convert_sys_msg_command(&s_msg).is_some() - && let Some(err) = tx_to_client - .send(TxControlMessage::Payload(s_msg)) - .await - .err() - { - error!("[SYS] failed to send back to client: {err}"); - } - } - Err(_) => { - // maybe channel closed - break; - } - } - } - - info!("[sysrx-cli] ending client system rx"); - }) +enum SenderMode { + Feed, + Send, +} + +async fn send_encrypted_message( + sender: &mut SplitSink, + session: Arc, + message: String, + mode: SenderMode, +) -> Result<(), Box> { + match encrypt_server_message(session.cipher.clone(), &message) { + Ok(enc_json) => match mode { + SenderMode::Feed => { + let _ = sender.feed(enc_json.into()).await; + } + SenderMode::Send => { + let _ = sender.send(enc_json.into()).await; + } + }, + Err(e) => { + error!("Failed to encrypt out message payload context: {e:?}"); + } + } + + Ok(()) } diff --git a/src/websocket/session.rs b/src/websocket/session.rs new file mode 100644 index 0000000..a796ff1 --- /dev/null +++ b/src/websocket/session.rs @@ -0,0 +1,161 @@ +use std::sync::Arc; + +use aes_gcm::{Aes256Gcm, aead::AeadMut}; +use log::info; +use p256::elliptic_curve::sec1::{FromEncodedPoint, ToEncodedPoint}; +use rand::Rng; +use tokio::time::Instant; + +use crate::websocket::core::GOOGLE_PUBLIC_ENDPOINT; + +pub(crate) struct SecureSession { + pub uid: String, + pub cipher: Aes256Gcm, + pub key_established_at: Instant, +} + +#[derive(serde::Deserialize)] +pub(crate) struct HandshakePayload { + pub token: String, + pub client_public_key: String, // BASE 64 +} + +#[derive(serde::Serialize)] +pub(crate) struct HandshakeAck { + pub status: String, + pub server_public_key: String, +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub(crate) struct EncryptedFrame { + pub iv: String, // Initialized vector per message + pub ciphertext: String, // Encrypted application message +} + +#[derive(serde::Deserialize)] +pub(crate) struct FirebaseJwtClaims { + aud: String, // Audience (Expect Firebase project id), + sub: String, // Subject (Firebase user uid), + exp: u64, // Expiration timestamp +} + +pub(crate) async fn refresh_jwk_cache( + state: Arc, +) -> Result<(), Box> { + let response: serde_json::Value = reqwest::get(GOOGLE_PUBLIC_ENDPOINT).await?.json().await?; + let mut new_keys = Vec::new(); + + if let Some(obj) = response.as_object() { + for (_, cert_pem) in obj { + if let Some(pem_str) = cert_pem.as_str() { + if let Ok(key) = jsonwebtoken::DecodingKey::from_rsa_pem(pem_str.as_bytes()) { + new_keys.push(key); + } + } + } + } + + { + let mut cache = state.jwk_encoding_keys.write().unwrap(); + *cache = new_keys; + info!("Google Jwk Identity cache updated!"); + } + + Ok(()) +} + +pub(crate) async fn verify_token( + token: &str, + state: Arc, +) -> Result> { + let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::RS256); + validation.set_audience(&[&state.firebase_project_id]); + validation.validate_exp = true; + + let keys = { state.jwk_encoding_keys.read().unwrap() }; + + for key in keys.iter() { + if let Ok(token_data) = jsonwebtoken::decode::(token, key, &validation) { + return Ok(token_data.claims.sub); + } + } + + Err(Box::from( + "Invalid Firebase Token signature or metadata mismatch", + )) +} + +pub(crate) fn execute_dh_handshake( + client_pub_b64: &str, +) -> Result<(String, aes_gcm::Aes256Gcm), Box> { + use aes_gcm::KeyInit; + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + use p256::{EncodedPoint, PublicKey, ecdh::EphemeralSecret}; + use rand_core::OsRng; + + // Step: decode client public key + // info!("client_pub_b64: {client_pub_b64}"); + let client_bytes = BASE64.decode(client_pub_b64)?; + let encoded_point = EncodedPoint::from_bytes(&client_bytes)?; + let client_public = PublicKey::from_encoded_point(&encoded_point).unwrap(); + + // Generate server ephemeral keypair + + let server_secret = EphemeralSecret::random(&mut OsRng); + let server_public = PublicKey::from(&server_secret); + + // Compute symmetric shared secret + let shared_secret = server_secret.diffie_hellman(&client_public); + + let secret_bytes = shared_secret.raw_secret_bytes(); + // Instantiate AES-256 GCM Core Cipher block natively using derived 32-byte hash block + let cipher = aes_gcm::Aes256Gcm::new_from_slice(&secret_bytes) + .map_err(|_| "failed allocating cipher payload context init")?; + + let server_pub_bytes = server_public.to_encoded_point(false); + let server_public_b64 = BASE64.encode(server_pub_bytes.as_bytes()); + + Ok((server_public_b64, cipher)) +} + +pub(crate) fn decrypt_message( + cipher: &aes_gcm::Aes256Gcm, + frame: &EncryptedFrame, +) -> Result, Box> { + use aes_gcm::aead::Aead; + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + + let iv_bytes = BASE64.decode(&frame.iv)?; + let ciphertext_bytes = BASE64.decode(&frame.ciphertext)?; + + let nonce = aes_gcm::Nonce::from_slice(&iv_bytes); + let decrypted = cipher + .decrypt(nonce, ciphertext_bytes.as_slice()) + .map_err(|_| "Decryption routine validation assertion failed")?; + + Ok(decrypted) +} + +pub(crate) fn encrypt_server_message( + mut cipher: aes_gcm::Aes256Gcm, + plain_text: &str, +) -> Result> { + use base64::{Engine, engine::general_purpose::STANDARD as BASE64}; + + let mut iv_bytes = [0u8; 12]; + rand::rng().fill_bytes(&mut iv_bytes); + let nonce = aes_gcm::Nonce::from_slice(&iv_bytes); + + let ciphertext_bytes = cipher + .encrypt(nonce, plain_text.as_bytes()) + .map_err(|_| "Encryption execution routine failed")?; + + let frame = EncryptedFrame { + iv: BASE64.encode(iv_bytes), + ciphertext: BASE64.encode(ciphertext_bytes), + }; + + let json_output = serde_json::to_string(&frame)?; + + Ok(json_output) +} diff --git a/src/websocket/tasks/auth.rs b/src/websocket/tasks/auth.rs index 3e6da20..ef01df1 100644 --- a/src/websocket/tasks/auth.rs +++ b/src/websocket/tasks/auth.rs @@ -52,23 +52,5 @@ pub async fn handle_auth_request( } } - // TODO - // - Queue requests - // - Send if service available - // if let Some(_) = state.system_tx.send(p).err() { - // info!("failed to send command request"); - // let _ = tx - // .send(TxControlMessage::Payload(serde_json::json!({ - // "type": "notify", - // "payload": { - // "from": "system_tx", - // "level": "error", - // "msg": "send request fail", - // "to": "" - // } - // }))) - // .await; - // } - Ok(()) } diff --git a/src/websocket/tasks/price.rs b/src/websocket/tasks/price.rs index 582d1e2..e728aeb 100644 --- a/src/websocket/tasks/price.rs +++ b/src/websocket/tasks/price.rs @@ -125,6 +125,7 @@ impl RecipePrice { /// Get main price profile of country pub async fn handle_price_request( + http_client: &reqwest::Client, config: DevConfig, redis: redis::Client, tx: Sender, @@ -148,7 +149,7 @@ pub async fn handle_price_request( let price_action = price_param.action; let price_content = - match invoke_checkout_request(config.clone(), price_file_format.clone()).await { + match invoke_checkout_request(http_client, config.clone(), price_file_format.clone()).await { Ok(pc) => pc, Err(e) => return Err(format!("Cannot find price of expected country: {e:?}").into()), }; @@ -314,7 +315,14 @@ pub async fn handle_price_request( // return Err("Fail to sync repo, backing up ...".into()); // } - let _ = invoke_commit_request(config.clone(), commit_payload.clone()).await; + let _ = invoke_commit_request( + http_client, + config.clone(), + commit_payload.clone(), + tx, + uidd.clone().to_string(), + ) + .await; // if invoke_push_request(config.clone()).await.is_err() { // let _ = commit_payload.dump_backup(); diff --git a/src/websocket/tasks/recipe.rs b/src/websocket/tasks/recipe.rs index 49e78b3..d4d296f 100644 --- a/src/websocket/tasks/recipe.rs +++ b/src/websocket/tasks/recipe.rs @@ -2,10 +2,8 @@ use crate::app::*; use crate::stream::model::{ IntoStreamMessage, StreamDataChunk, StreamDataEnd, StreamDataExtra, StreamDataStart, }; -use crate::websocket::plugins::call_plugin_if_existed; use crate::websocket::{core::*, helper::*, model::*}; -use std::collections::HashMap; use std::{fs::File, io::Read, path::PathBuf, sync::Arc}; use async_compression::tokio::bufread::BrotliDecoder; @@ -16,9 +14,8 @@ use futures::{ stream::{SplitSink, SplitStream}, }; use libtbr::models::recipe::{MaterialSetting, Recipe, Recipe01}; -use log::{error, info, warn}; +use log::{debug, error, info, warn}; -use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use redis::{self, TypedCommands}; use tokio::{ @@ -29,7 +26,6 @@ use tokio::{ }, time::Instant, }; -use wasmtime::{Config, Engine}; const NO_MERGE_FLAG: i32 = 1000; const MERGE_DONE_FLAG: i32 = 0; @@ -71,24 +67,26 @@ pub fn get_key_cache(country: String, version: String, is_patch: bool, retry_cnt } async fn get_latest_recipe_from_git( + http_client: &reqwest::Client, config: &DevConfig, country: &str, ) -> Result> { let latest_key = format!("{country}/version"); - let latest_version = match invoke_checkout_request(config.clone(), latest_key).await { - Ok(version) => version, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + let latest_version = + match invoke_checkout_request(http_client, config.clone(), latest_key).await { + Ok(version) => version, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; let mut recipe_result: Option = None; let init_key = 3; for i in init_key..6 { let r1_key = get_key_cache(country.to_string(), latest_version.clone(), false, i); - let content = match invoke_checkout_request(config.clone(), r1_key).await { + let content = match invoke_checkout_request(http_client, config.clone(), r1_key).await { Ok(file_content) => file_content, Err(e) => { println!("Error on checkout: {e}"); @@ -111,18 +109,20 @@ async fn get_latest_recipe_from_git( } async fn get_latest_recipe_saved_machine_from_git( + http_client: &reqwest::Client, config: &DevConfig, country: &str, boxid: &str, ) -> Result> { let latest_key = format!("{country}/coffeethai02_{country}_{boxid}_temp.json"); - let content = match invoke_checkout_request(config.clone(), latest_key.clone()).await { - Ok(content) => content, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + let content = + match invoke_checkout_request(http_client, config.clone(), latest_key.clone()).await { + Ok(content) => content, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; info!( "[get-latest] {} -> content ready: {}", latest_key, @@ -143,22 +143,25 @@ pub async fn throttle_send_recipe( uid: Arc>, ) { info!("Starting throttle"); - let r01s: Vec = recipe + + // Use Arc to avoid cloning - single allocation, shared ownership + let r01s: Vec> = recipe .Recipe01 - .par_iter() + .iter() .flat_map(|x| { let mut v = Vec::new(); - v.push(x.clone()); + v.push(Arc::new(x.clone())); - if let Some(sub) = x.clone().SubMenu { - v.extend(sub); + if let Some(sub) = &x.SubMenu { + v.extend(sub.iter().map(|s| Arc::new(s.clone()))); } v }) .collect(); - let matset: Vec = recipe.MaterialSetting.clone(); + // Use reference to MaterialSetting instead of clone + let matset = &recipe.MaterialSetting; // test stream start model let ss = StreamDataStart::new( @@ -175,7 +178,7 @@ pub async fn throttle_send_recipe( println!("ERR: send tx error, {err:?}"); } - // split send + // split send - use Arc pointers, zero-copy let uidd = uid.try_lock().unwrap().to_string(); for (index, chunk) in r01s.chunks(CHUNK_SIZE).enumerate() { @@ -192,7 +195,7 @@ pub async fn throttle_send_recipe( for (index, chunk) in matset.chunks(CHUNK_SIZE).enumerate() { let curr_ch_id = format!("{mat_exid}_{index}"); - let extra_matset = StreamDataExtra::new(&curr_ch_id, &extp, chunk.to_vec(), uidd.clone()); + let extra_matset = StreamDataExtra::from_slice(&curr_ch_id, &extp, chunk, uidd.clone()); if let Some(err) = tx .send(TxControlMessage::Payload(extra_matset.as_msg())) @@ -206,7 +209,7 @@ pub async fn throttle_send_recipe( let extl = "topplist"; for (index, chunk) in recipe.Topping.ToppingList.chunks(CHUNK_SIZE).enumerate() { let curr_ch_id = format!("{mat_exid}_tl{index}"); - let extra_topplist = StreamDataExtra::new(&curr_ch_id, &extl, chunk.to_vec(), uidd.clone()); + let extra_topplist = StreamDataExtra::from_slice(&curr_ch_id, &extl, chunk, uidd.clone()); if let Some(err) = tx .send(TxControlMessage::Payload(extra_topplist.as_msg())) .await @@ -219,7 +222,7 @@ pub async fn throttle_send_recipe( let extg = "toppgrp"; for (index, chunk) in recipe.Topping.ToppingGroup.chunks(CHUNK_SIZE).enumerate() { let curr_ch_id = format!("{mat_exid}_tg{index}"); - let extra_toppgrp = StreamDataExtra::new(&curr_ch_id, &extg, chunk.to_vec(), uidd.clone()); + let extra_toppgrp = StreamDataExtra::from_slice(&curr_ch_id, &extg, chunk, uidd.clone()); if let Some(err) = tx .send(TxControlMessage::Payload(extra_toppgrp.as_msg())) .await @@ -248,8 +251,41 @@ pub async fn throttle_send_recipe( } } +/// Helper: fetch machine recipe saved on server with retry. +/// If machine recipe is not found, do get latest instead. +async fn get_machine_recipe_with_retry( + http_client: &reqwest::Client, + config: &DevConfig, + country: &str, + box_id: &str, + callback_case_not_found: Option, +) -> Option +where + F: Fn(&reqwest::Client, &DevConfig, String) -> Fut, + Fut: Future>, +{ + let mut result_pre: Option = + match get_latest_recipe_saved_machine_from_git(http_client, &config, &country, &box_id) + .await + { + Ok(saved) => Some(saved), + Err(_) => { + error!("[get_save] previous save not found ..."); + None + } + }; + if result_pre.is_none() + && let Some(cb) = callback_case_not_found + { + result_pre = cb(http_client, config, country.to_string()).await; + } + + result_pre +} + // TODO: split cases into sub function pub async fn handle_recipe_request( + http_client: &reqwest::Client, config: DevConfig, redis: redis::Client, tx: Sender, @@ -287,13 +323,14 @@ pub async fn handle_recipe_request( if latest_version.is_empty() { // cannot get actual version, try get from git - latest_version = match invoke_checkout_request(config.clone(), latest_key).await { - Ok(version) => version, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + latest_version = + match invoke_checkout_request(http_client, config.clone(), latest_key).await { + Ok(version) => version, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; } // detect if use different version @@ -415,13 +452,14 @@ pub async fn handle_recipe_request( } } else { // retry get from git - let content = match invoke_checkout_request(config.clone(), r1_key).await { - Ok(file_content) => file_content, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + let content = + match invoke_checkout_request(http_client, config.clone(), r1_key).await { + Ok(file_content) => file_content, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; info!("content ready: {}", content.len()); let recipe = serde_json::from_str::(&content); @@ -447,8 +485,9 @@ pub async fn handle_recipe_request( } pub async fn handle_recipe_versions_list_request( + http_client: &reqwest::Client, config: DevConfig, - redis: redis::Client, + _redis: redis::Client, tx: Sender, req: WebsocketMessageRequest, uid_clone: Arc>, @@ -458,10 +497,11 @@ pub async fn handle_recipe_versions_list_request( let version_list = format!("{country}", country = recipe_param.country); - let country_versions_str = match invoke_checkout_request(config.clone(), version_list).await { - Ok(vs) => vs, - Err(e) => return Err(format!("Cannot find versions of expected country: {e:?}").into()), - }; + let country_versions_str = + match invoke_checkout_request(http_client, config.clone(), version_list).await { + Ok(vs) => vs, + Err(e) => return Err(format!("Cannot find versions of expected country: {e:?}").into()), + }; // extract version as list let files: Vec = country_versions_str @@ -491,6 +531,7 @@ pub async fn handle_recipe_versions_list_request( } pub async fn handle_recipe_save_change_request( + http_client: &reqwest::Client, config: DevConfig, redis: redis::Client, tx: Sender, @@ -526,7 +567,9 @@ pub async fn handle_recipe_save_change_request( // try get saved machine recipe let mut result_pre: Option = - match get_latest_recipe_saved_machine_from_git(&config, &country, &box_id).await { + match get_latest_recipe_saved_machine_from_git(http_client, &config, &country, &box_id) + .await + { Ok(saved) => Some(saved), Err(_) => { error!("[save_recipe] previous save not found ..."); @@ -535,7 +578,7 @@ pub async fn handle_recipe_save_change_request( }; if result_pre.is_none() { - result_pre = match get_latest_recipe_from_git(&config, &country).await { + result_pre = match get_latest_recipe_from_git(http_client, &config, &country).await { Ok(r) => Some(r), Err(e) => { return Err(format!("{e}").into()); @@ -616,7 +659,10 @@ pub async fn handle_recipe_save_change_request( message: commit_message, }; - if let Err(commit_error) = invoke_commit_request(config, commit_payload.clone()).await { + let uidd_clone = uid_clone.lock().await.to_string(); + if let Err(commit_error) = + invoke_commit_request(http_client, config, commit_payload.clone(), tx, uidd_clone).await + { error!("failed to commit: {commit_error}"); let _ = commit_payload.dump_backup(); return Err(format!("{commit_error}").into()); @@ -634,8 +680,9 @@ pub async fn handle_recipe_save_change_request( } pub async fn handle_request_list_menu_recipe( + http_client: &reqwest::Client, config: DevConfig, - redis: redis::Client, + _redis: redis::Client, tx: Sender, req: WebsocketMessageRequest, uid_clone: Arc>, @@ -646,20 +693,23 @@ pub async fn handle_request_list_menu_recipe( let latest_key = format!("{country}/version", country = req_menu_list.country); - let latest_version = match invoke_checkout_request(config.clone(), latest_key).await { - Ok(version) => version, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + let latest_version = + match invoke_checkout_request(http_client, config.clone(), latest_key).await { + Ok(version) => version, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; let country = req_menu_list.clone().country; let box_id = req_menu_list.clone().boxid; // merge from already saved recipe let result_previous_on_same_boxid: Option = - match get_latest_recipe_saved_machine_from_git(&config, &country, &box_id).await { + match get_latest_recipe_saved_machine_from_git(http_client, &config, &country, &box_id) + .await + { Ok(saved) => Some(saved), Err(e) => { error!("[list-menu-restore] previous save not found, {e}"); @@ -678,13 +728,14 @@ pub async fn handle_request_list_menu_recipe( i, ); - let content = match invoke_checkout_request(config.clone(), r1_key.clone()).await { - Ok(file_content) => file_content, - Err(e) => { - println!("Error on checkout: {e}"); - "".to_string() - } - }; + let content = + match invoke_checkout_request(http_client, config.clone(), r1_key.clone()).await { + Ok(file_content) => file_content, + Err(e) => { + println!("Error on checkout: {e}"); + "".to_string() + } + }; info!("[list-menu] {r1_key} -> content ready: {}", content.len()); let recipe = serde_json::from_str::(&content); @@ -784,3 +835,571 @@ fn handle_case_found_existed_recipe( Ok(result.clone()) } + +async fn modify_material( + client: &tokio_postgres::Client, + payload: &ModifyMaterial, + _tx: &Sender, + _uidd: &str, +) -> Result { + let mut set_clauses = Vec::new(); + let mut param_values: Vec> = Vec::new(); + let mut param_index = 1; + + if payload.alarm_id_when_offline.is_some() { + set_clauses.push(format!("alarm_id_when_offline = ${}", param_index)); + let val = payload.alarm_id_when_offline.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if payload.bean_channel { + set_clauses.push(format!("bean_channel = ${}", param_index)); + param_values.push(Box::new(payload.bean_channel)); + param_index += 1; + } + + if let Some(canister_type) = &payload.canister_type { + if !canister_type.is_empty() { + set_clauses.push(format!("canister_type = ${}", param_index)); + param_values.push(Box::new(canister_type.clone())); + param_index += 1; + } + } + + if payload.drain_timer.is_some() { + set_clauses.push(format!("drain_timer = ${}", param_index)); + let val = payload.drain_timer.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.ice_scream_bingsu_channel { + set_clauses.push(format!("ice_scream_bingsu_channel = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.is_equipment { + set_clauses.push(format!("is_equipment = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.leaves_channel { + set_clauses.push(format!("leaves_channel = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if payload.low_to_offline.is_some() { + set_clauses.push(format!("low_to_offline = ${}", param_index)); + let val = payload.low_to_offline.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if payload.material_status.is_some() { + set_clauses.push(format!("material_status = ${}", param_index)); + let val = payload.material_status.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.powder_channel { + set_clauses.push(format!("powder_channel = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.refill_unit_gram { + set_clauses.push(format!("refill_unit_gram = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.refill_unit_milliliters { + set_clauses.push(format!("refill_unit_milliliters = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.refill_unit_pcs { + set_clauses.push(format!("refill_unit_pcs = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if payload.schedule_drain_type.is_some() { + set_clauses.push(format!("schedule_drain_type = ${}", param_index)); + let val = payload.schedule_drain_type.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.soda_channel { + set_clauses.push(format!("soda_channel = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(str_text_show_error) = &payload.str_text_show_error { + set_clauses.push(format!("str_text_show_error = ${}", param_index)); + param_values.push(Box::new(str_text_show_error.clone())); + param_index += 1; + } + + if let Some(val) = payload.syrup_channel { + set_clauses.push(format!("syrup_channel = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if payload.id_alternate.is_some() { + set_clauses.push(format!("id_alternate = ${}", param_index)); + let val = payload.id_alternate.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(val) = payload.is_use { + set_clauses.push(format!("is_use = ${}", param_index)); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(material_other_name) = &payload.material_other_name { + if !material_other_name.is_empty() { + set_clauses.push(format!("material_other_name = ${}", param_index)); + param_values.push(Box::new(material_other_name.clone())); + param_index += 1; + } + } + + if let Some(material_name) = &payload.material_name { + if !material_name.is_empty() { + set_clauses.push(format!("material_name = ${}", param_index)); + param_values.push(Box::new(material_name.clone())); + param_index += 1; + } + } + + if let Some(path_other_name) = &payload.path_other_name { + if !path_other_name.is_empty() { + set_clauses.push(format!("path_other_name = ${}", param_index)); + param_values.push(Box::new(path_other_name.clone())); + param_index += 1; + } + } + + if payload.pay_rettry_max_count.is_some() { + set_clauses.push(format!("pay_retry_max_count = ${}", param_index)); + let val = payload.pay_rettry_max_count.as_ref().map(|v| v.as_i64().unwrap_or(0) as i32).unwrap_or(0); + param_values.push(Box::new(val)); + param_index += 1; + } + + if let Some(raw_material_unit) = &payload.raw_material_unit { + if !raw_material_unit.is_empty() { + set_clauses.push(format!("raw_material_unit = ${}", param_index)); + param_values.push(Box::new(raw_material_unit.clone())); + param_index += 1; + } + } + + if let Some(material_parameter) = &payload.material_parameter { + if !material_parameter.is_empty() { + set_clauses.push(format!("material_parameter = ${}", param_index)); + param_values.push(Box::new(material_parameter.clone())); + param_index += 1; + } + } + + if set_clauses.is_empty() { + return Err("At least one field besides id must be provided for modification".to_string()); + } + + set_clauses.push("updated_at = NOW()".to_string()); + + let sql = format!( + "UPDATE material_setting SET {} WHERE id = ${} RETURNING id", + set_clauses.join(", "), + param_index + ); + + param_values.push(Box::new(payload.id)); + + let params: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> = param_values.iter().map(|b| b.as_ref() as _).collect(); + + match client.execute(&sql, ¶ms).await { + Ok(rows) => Ok(rows), + Err(e) => Err(format!("Database error: {}", e)), + } +} + +pub async fn handle_request_material_action( + http_client: &reqwest::Client, + config: DevConfig, + _redis: redis::Client, + postgres_cli: Arc>, + tx: Sender, + req: WebsocketMessageRequest, + uid_clone: Arc>, +) -> WebsocketMessageResult { + // suppose guard value passed + let p = req.payload.unwrap(); + let req_material_action: RequestMaterialActionPayload = safe_deserialize(&p)?; + + let (country, mut box_id) = if req_material_action.country.contains("_") { + // send with box id + let spl: Vec = req_material_action + .country + .split("_") + .map(|x| x.to_string()) + .collect(); + (spl[0].clone(), spl[1].clone()) + } else { + (req_material_action.country, "".to_string()) + }; + let country_prefix = config.get_country_config_from_short_name(&country); + + if box_id.is_empty() { + box_id = String::from("unknown"); + } + + let expected_file_path = format!("{country}/coffeethai02_{country}_{box_id}_temp.json"); + + // + let uidd = uid_clone.lock().await.to_string(); + + let result_pre = get_machine_recipe_with_retry( + &http_client, + &config, + &country, + &box_id, + Some( + |client: &reqwest::Client, config: &DevConfig, cnt: String| { + // NOTE: must do clone to extend lifetime for this scope + let client_clone = client.clone(); + let config_clone = config.clone(); + Box::pin(async move { + get_latest_recipe_from_git(&client_clone, &config_clone, &cnt) + .await + .ok() + }) + }, + ), + ) + .await; + + if result_pre.is_none() { + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": "fail to interact with recipe, try again later" + } + }))) + .await; + return Err(format!("cannot fetch recipe").into()); + } else { + match req_material_action.action { + AvailableMaterialAction::Create + if let Some(d) = req_material_action.data.to_owned() + && let Ok(mut create_payload) = serde_json::from_value::(d) => + { + if let Some(cp) = country_prefix + && create_payload.check_valid(Some(cp.to_string())) + { + let mut result = result_pre.unwrap(); + + let display_name = req_material_action + .user_info + .get("displayName") + .unwrap_or_default() + .as_str() + .unwrap_or(&"unknown".to_string()) + .to_string(); + + let email = req_material_action + .user_info + .get("email") + .unwrap_or_default() + .as_str() + .unwrap_or(&"unknown".to_string()) + .to_string(); + + let current_mat = result.list_material_settings(); + + let request_mat_id_string = create_payload.0.id.as_i64().unwrap().to_string(); + + if current_mat.contains(&request_mat_id_string) { + warn!( + "[create_material] unexpect new requested material: {request_mat_id_string} but already existed" + ); + + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": "reject by incorrect material action, material already existed, this should be modified instead." + } + }))) + .await; + } else { + info!("[create_material] new requested material: {request_mat_id_string}"); + + result.MaterialSetting.push(create_payload.0.clone()); + + // save to git prepare process + let serial_recipe = + match serde_json::to_string_pretty(&serde_json::json!(result)) { + Ok(s) => s, + Err(e) => { + error!("failed to serialize recipe: {e}"); + return Err(format!("{e}").into()); + } + }; + + let commit_message = format!( + "add material {}({})", + create_payload.0.materialOtherName.unwrap_or_default(), + create_payload.0.id.as_i64().unwrap() + ); + + let commit_payload = CommitPayload { + file_bytes: serial_recipe.as_bytes().to_vec(), + path: expected_file_path.clone(), + signature_username: display_name, + signature_email: email, + message: commit_message, + }; + + if let Err(commit_error) = invoke_commit_request( + http_client, + config, + commit_payload.clone(), + tx, + uidd, + ) + .await + { + error!("failed to commit: {commit_error}"); + let _ = commit_payload.dump_backup(); + return Err(format!("{commit_error}").into()); + } + } + } else { + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": "reject by invalid material range or unavailable country" + } + }))) + .await; + } + } + AvailableMaterialAction::Modify => { + if let Some(d) = req_material_action.data.to_owned() + && let Ok(modify_payload) = serde_json::from_value::(d) + { + let client = postgres_cli.lock().await; + let tx_result = match modify_material(&client, &modify_payload, &tx, &uidd).await { + Ok(rows_updated) => { + info!("[modify_material] updated {} row(s) for id={}", rows_updated, modify_payload.id); + serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "msg": format!("Material updated successfully, {} row(s) affected", rows_updated), + "updated_rows": rows_updated, + "material_id": modify_payload.id + } + }) + } + Err(e) => { + error!("[modify_material] failed for id={}: {}", modify_payload.id, e); + serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": e, + "material_id": modify_payload.id + } + }) + } + }; + let _ = tx.send(TxControlMessage::Payload(tx_result)).await; + } + } + AvailableMaterialAction::GetNewMaterialId => { + // TODO: get available material id, user must provide the type they want + } + AvailableMaterialAction::Update => { + let result = result_pre.unwrap(); + let client = postgres_cli.lock().await; + + if country.eq("mys") { + // ignore this, as malaysia use same id with tha + info!("[update_material] skip update, as malaysia use same id with tha"); + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "msg": "Material update skipped" + } + }))) + .await; + + return Ok(()); + } + + for matset in result.MaterialSetting { + let id = matset.id.as_i64().unwrap_or(0) as i32; + let id_alternate = matset.idAlternate.as_i64().unwrap_or(0) as i32; + let is_use = matset.isUse; + let material_name = matset.materialName.clone().unwrap_or_default(); + let material_other_name = matset.materialOtherName.clone(); + let path_other_name = matset.pathOtherName.clone(); + let bean_channel = matset.BeanChannel; + let syrup_channel = matset.SyrupChannel; + let powder_channel = matset.PowderChannel; + let ice_scream_bingsu_channel = matset.IceScreamBingsuChannel.unwrap_or(false); + let soda_channel = matset.SodaChannel; + let leaves_channel = matset.LeavesChannel; + let is_equipment = matset.IsEquipment; + let canister_type = matset.CanisterType.clone().unwrap_or_default(); + let alarm_id_when_offline = + matset.AlarmIDWhenOffline.as_i64().unwrap_or(0) as i32; + let drain_timer = matset.DrainTimer.as_i64().unwrap_or(0) as i32; + let low_to_offline = matset.LowToOffline.as_i64().unwrap_or(0) as i32; + let material_status = matset.MaterialStatus.as_i64().unwrap_or(0) as i32; + let schedule_drain_type = matset.ScheduleDrainType.as_i64().unwrap_or(0) as i32; + let pay_retry_max_count = + matset.pay_rettry_max_count.as_i64().unwrap_or(0) as i32; + let refill_unit_gram = matset.RefillUnitGram; + let refill_unit_milliliters = matset.RefillUnitMilliliters; + let refill_unit_pcs = matset.RefillUnitPCS; + let material_parameter = matset.MaterialParameter.clone(); + let raw_material_unit = matset.RawMaterialUnit.clone(); + let str_text_show_error = matset.StrTextShowError.clone().unwrap_or_default(); + + // Fields not in MaterialSetting but in table schema - use defaults + let fresh_syrup_channel = false; + let frozen_fruit_channel = false; + let item_channel = false; + let feed_mode: Option = None; + + let upsert_sql = r#"INSERT INTO material_setting (id, id_alternate, is_use, material_name, material_other_name, path_other_name, bean_channel, syrup_channel, powder_channel, fresh_syrup_channel, frozen_fruit_channel, ice_scream_bingsu_channel, soda_channel,leaves_channel, item_channel, is_equipment, canister_type,alarm_id_when_offline, drain_timer, low_to_offline, material_status,schedule_drain_type,pay_retry_max_count,refill_unit_gram,refill_unit_milliliters, refill_unit_pcs, feed_mode, material_parameter,raw_material_unit, str_text_show_error) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17,$18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30) ON CONFLICT (id) DO UPDATE SET + id_alternate = EXCLUDED.id_alternate, + is_use = EXCLUDED.is_use, + material_name = EXCLUDED.material_name, + material_other_name = EXCLUDED.material_other_name, + path_other_name = EXCLUDED.path_other_name, + bean_channel = EXCLUDED.bean_channel, + syrup_channel = EXCLUDED.syrup_channel, + powder_channel = EXCLUDED.powder_channel, + fresh_syrup_channel = EXCLUDED.fresh_syrup_channel, + frozen_fruit_channel = EXCLUDED.frozen_fruit_channel, + ice_scream_bingsu_channel = EXCLUDED.ice_scream_bingsu_channel, + soda_channel = EXCLUDED.soda_channel, + leaves_channel = EXCLUDED.leaves_channel, + item_channel = EXCLUDED.item_channel, + is_equipment = EXCLUDED.is_equipment, + canister_type = EXCLUDED.canister_type, + alarm_id_when_offline = EXCLUDED.alarm_id_when_offline, + drain_timer = EXCLUDED.drain_timer, + low_to_offline = EXCLUDED.low_to_offline, + material_status = EXCLUDED.material_status, + schedule_drain_type = EXCLUDED.schedule_drain_type, + pay_retry_max_count = EXCLUDED.pay_retry_max_count, + refill_unit_gram = EXCLUDED.refill_unit_gram, + refill_unit_milliliters = EXCLUDED.refill_unit_milliliters, + refill_unit_pcs = EXCLUDED.refill_unit_pcs, + feed_mode = EXCLUDED.feed_mode, + material_parameter = EXCLUDED.material_parameter, + raw_material_unit = EXCLUDED.raw_material_unit, + str_text_show_error = EXCLUDED.str_text_show_error, + updated_at = NOW() + "#; + + if let Err(e) = client + .execute( + upsert_sql, + &[ + &id, + &id_alternate, + &is_use, + &material_name, + &material_other_name, + &path_other_name, + &bean_channel, + &syrup_channel, + &powder_channel, + &fresh_syrup_channel, + &frozen_fruit_channel, + &ice_scream_bingsu_channel, + &soda_channel, + &leaves_channel, + &item_channel, + &is_equipment, + &canister_type, + &alarm_id_when_offline, + &drain_timer, + &low_to_offline, + &material_status, + &schedule_drain_type, + &pay_retry_max_count, + &refill_unit_gram, + &refill_unit_milliliters, + &refill_unit_pcs, + &feed_mode, + &material_parameter, + &raw_material_unit, + &str_text_show_error, + ], + ) + .await + { + error!( + "[update_material] failed to upsert material {}: {}", + material_name, e + ); + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "error": format!("Failed to update material {}: {}", material_name, e) + } + }))) + .await; + } else { + info!( + "[update_material] upserted material: {} (id={})", + material_name, id + ); + } + } + + let _ = tx + .send(TxControlMessage::Payload(serde_json::json!({ + "type": "notify", + "payload": { + "to": uidd, + "msg": "Material update completed" + } + }))) + .await; + } + _ => {} + } + } + + Ok(()) +}