diff --git a/.gitignore b/.gitignore index 7512808..ca7135e 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,8 @@ next-env.d.ts # données locales (configuration avec mots de passe, comptes de secours) /data/ + +# Rust +/server/target/ +# Vite +/dist/ diff --git a/server/Cargo.lock b/server/Cargo.lock new file mode 100644 index 0000000..e2a50a6 --- /dev/null +++ b/server/Cargo.lock @@ -0,0 +1,3447 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "async-compression" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-tungstenite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8acc405d38be14342132609f06f02acaf825ddccfe76c4824a69281e0458ebd4" +dependencies = [ + "atomic-waker", + "futures-core", + "futures-io", + "futures-task", + "futures-util", + "log", + "pin-project-lite", + "tokio", + "tungstenite", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chromiumoxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ed067eb6c1f660bdb87c05efb964421d2ca262bae0296cdfe38cf0cd949a3e" +dependencies = [ + "async-tungstenite", + "base64 0.22.1", + "bytes", + "chromiumoxide_cdp", + "chromiumoxide_types", + "dunce", + "fnv", + "futures", + "futures-timer", + "pin-project-lite", + "reqwest 0.13.5", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "url", + "which", + "windows-registry", +] + +[[package]] +name = "chromiumoxide_cdp" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a6a03a7ebac4ea85308f285d6959a3e6b2ce32a0c9465dc7a7b1db0144eec7" +dependencies = [ + "chromiumoxide_pdl", + "chromiumoxide_types", + "serde", + "serde_json", +] + +[[package]] +name = "chromiumoxide_pdl" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c602dea92337bc4d824668d78c5b79c3b4ddb29b40dd7218282bbe8fd3fc2091" +dependencies = [ + "chromiumoxide_types", + "either", + "heck", + "once_cell", + "proc-macro2", + "quote", + "regex", + "serde_json", +] + +[[package]] +name = "chromiumoxide_types" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678d5146e74f16fc4a41978b275af572cd913de1f10270d2b93b6c276bc57d80" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "collectflow" +version = "0.2.0" +dependencies = [ + "anyhow", + "axum", + "axum-extra", + "base64 0.22.1", + "chromiumoxide", + "chrono", + "futures", + "hex", + "hmac", + "parking_lot", + "rand 0.8.8", + "regex", + "reqwest 0.12.28", + "rust_decimal", + "rust_xlsxwriter", + "scrypt", + "serde", + "serde_json", + "sha2", + "sqlx", + "subtle", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", +] + +[[package]] +name = "compression-codecs" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "headers" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc2700e3fe555c3310aa7286cac6167449f2c87e0eb58769c9208a1c58a1d106" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.9", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.9.4", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.8", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +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 = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 1.0.9", +] + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust_decimal" +version = "1.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.8", + "rand 0.9.5", + "serde", + "serde_json", + "wasm-bindgen", +] + +[[package]] +name = "rust_xlsxwriter" +version = "0.99.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34f2d2cda7223168a14fb86bc7411dbc3379bd7d49749ecc53b91ed98585b6b0" +dependencies = [ + "zip", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash 0.2.0", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rust_decimal", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.8", + "rsa", + "rust_decimal", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.8", + "rust_decimal", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.17+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typed-path" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "serde", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf7fc5d30c28483d93805c4a5e12b05bbb52407fa67c5f8bd552374cd01fb11" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/server/Cargo.toml b/server/Cargo.toml new file mode 100644 index 0000000..314381d --- /dev/null +++ b/server/Cargo.toml @@ -0,0 +1,53 @@ +[package] +name = "collectflow" +version = "0.2.0" +edition = "2021" +description = "CollectFlow — serveur Rust (API, base, FF Nancy, Qlik, synchronisation)" +publish = false + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie", "typed-header"] } +base64 = "0.22" +chromiumoxide = "0.9.1" +chrono = { version = "0.4", features = ["serde"] } +futures = "0.3" +hex = "0.4" +hmac = "0.12" +parking_lot = "0.12" +rand = "0.8" +regex = "1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "gzip", "cookies", "stream", "http2"] } +rust_decimal = "1" +rust_xlsxwriter = "0.99" +scrypt = { version = "0.11", default-features = false, features = ["std"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +sha2 = "0.10" +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "json", "chrono", "rust_decimal", "macros"] } +subtle = "2" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "compression-gzip", "trace", "set-header"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +urlencoding = "2" +uuid = { version = "1", features = ["v4"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 4 +strip = true + +# scrypt est lent sans optimisation : la connexion prenait plusieurs secondes en debug. +[profile.dev.package.scrypt] +opt-level = 3 +[profile.dev.package.salsa20] +opt-level = 3 +[profile.dev.package.pbkdf2] +opt-level = 3 +[profile.dev.package.sha2] +opt-level = 3 diff --git a/server/PORTING.md b/server/PORTING.md new file mode 100644 index 0000000..0b40e75 --- /dev/null +++ b/server/PORTING.md @@ -0,0 +1,115 @@ +# Portage Rust — conventions de travail + +Ce document fixe le contrat entre le serveur Rust (`server/`) et l'interface +React (`src/`, compilée en SPA par Vite). Le code TypeScript d'origine (Next.js) +reste la **référence fonctionnelle** : chaque module Rust en est la traduction +fidèle (mêmes requêtes SQL, mêmes règles, mêmes messages, mêmes formes JSON). + +## Socle déjà en place (ne pas réécrire) + +| Besoin | Rust | +|---|---| +| Pool PostgreSQL (base app + recopie FF) | `crate::db::pool()? -> PgPool` (remplaçable à chaud, `db::refresh()`) | +| Ligne → JSON générique | `db::row_to_json`, `db::rows_to_json` | +| Lecture tolérante (`Number(r.x) \|\| 0`, `String(r.x ?? "")`) | `db::get_f64`, `db::f64_or0`, `db::get_string`, `db::string_or_empty` | +| Date ISO façon `toISOString()` | `db::iso(NaiveDateTime)`, `db::iso_utc` | +| Réglages `data/.db-config.json` | `config::read_config()`, `config::write_config(patch)`, `config::data_dir()`, `config::env_number(...)` | +| Cache FF (`cachedFF`) | `ff_cache::cached(key, CacheOpts { ttl, cache_if }, \|\| async { … }).await`, `ff_cache::invalidate()` | +| Journaux de job (`startCapture`…) | `logcapture::{start_capture, stop_capture, read_capture, list_captures}` | +| Session (routes internes) | extracteurs `auth::SessionUser`, `auth::AdminUser` (401/403 `{ error }`) ; `auth::Token` (jeton brut) ; `auth::require_session(token)` / `require_admin(token)` | +| Auth `/api/v1` | extracteur `auth::ApiAuth` (clé d'API ou session, erreurs au format v1) ; `ctx.require_admin()` | +| Enveloppe `/api/v1` | `api_v1_response::{ok, fail, build_pagination, ApiErrorCode}` | +| Erreurs | `error::{AppError, AppResult}` — `AppError` implémente `IntoResponse` (`{ error }`) et `From` | +| Magasins / gammes | `domain::{MAGASINS, CODE_TOUS_MAGASINS, nom_magasin, est_gamme_valide}` | +| Actions / pages | `rpc::{Ctx, Handler, json}` + macro `handler!(fonction)` | + +## Organisation + +Chaque module possède son répertoire `server/src//` et expose dans son +`mod.rs` : + +```rust +pub fn router() -> axum::Router; // routes HTTP (chemins identiques à Next.js) +pub fn actions() -> Vec<(&'static str, rpc::Handler)>; // ex. ("getUsers", handler!(get_users)) +pub fn pages() -> Vec<(&'static str, rpc::Handler)>; // ex. ("dashboard", handler!(page_dashboard)) +``` + +Le routeur Axum n'a pas d'état (`Router<()>`) : tout passe par les accès globaux +ci-dessus. Syntaxe des chemins Axum 0.8 : `/api/v1/products/{codein}`. + +## Actions (ex-« server actions ») + +`POST /api/actions/{nom}` — corps : tableau JSON des arguments positionnels, +réponse : la valeur de retour de la fonction TS, sérialisée à l'identique. +Une exception TS (ex. `requireSession()` qui lève) devient une `AppError` +(statut 401/403/500, corps `{ error }`) ; le client la relève en `Error`. + +Une action s'écrit : + +```rust +async fn get_users(ctx: Ctx) -> AppResult { + ctx.admin().await?; // = await requireAdmin() + let page: i64 = ctx.arg(0)?; // argument n°0 (null si absent → Option) + … + json(resultat) +} +``` + +| Action | Source TS | Module Rust | +|---|---|---| +| `getFournisseurs`, `getMagasins` | `features/grid/actions.ts` | `ff` | +| `getFournisseursPourCadence`, `listCadences`, `upsertCadence`, `toggleCadence`, `removeCadence` | `features/commandes-auto/actions.ts` | `ff` | +| `getUsers`, `createUser`, `deleteUser`, `updatePassword` | `features/admin/api/user-actions.ts` | `admin` | +| `getApiKeys`, `createApiKey`, `revokeApiKey`, `deleteApiKey` | `features/admin/api/api-key-actions.ts` | `admin` | +| `getParametresAffichables`, `testDatabaseConnection`, `saveDatabaseSettings`, `saveQlikSettings`, `testQlikConnection`, `saveFfApiSettings`, `testFfApiConnection` | `features/settings/actions.ts` | `admin` | +| `readSyncSettings`, `saveSyncSettings` | `features/admin/api/sync-settings.ts` | `admin` | +| `getSnapshots`, `deleteSnapshot`, `saveSnapshot` | `features/snapshots/api/*.ts` | `grid` | +| `saveDraftChanges` | `features/grid/api/save-draft-changes.ts` | `grid` | +| `getGammesAValider`, `rejeterGammesAValider` | `features/grid/api/gammes-a-valider-actions.ts` | `grid` | + +(`loginAction` disparaît : `POST /api/auth/login`.) + +## Pages (données des anciennes pages serveur) + +`GET /api/pages/{nom}?…` renvoie **uniquement les données** que la page serveur +obtenait de ses appels d'accès aux données (fonctions `pg*`, API FF, actions), +sous la forme exacte de leur valeur de retour TS. Toute la mise en forme pure +(pivots, listes de mois, totaux…) reste côté interface. En cas d'échec là où la +page affichait un `ErrorState` : statut 500 `{ error: message }`. + +| Page | Paramètres | Réponse | Module | +|---|---|---|---| +| `dashboard` | — | `pgGetDashboardData()` (cache 30 min) | `ff` | +| `analytics` | `mode` (`fournisseur`\|`nomenclature`), `mois`, `moisN1` | lignes de `pgGetCaByFournisseur` ou `pgGetCaByNomenclature` (cachedFF, mêmes clés) | `ff` | +| `hit-parade` | `debut`, `fin` | lignes de `pgGetHitParade` (cachedFF) | `ff` | +| `stock-negatif` | `magasin` | `{ rowsNegatif, rowsSansVente, rowsSansVente6Mois }` (cachedFF) | `ff` | +| `commandes-auto` | — | `{ rows, cadences, fournisseurs }` | `ff` | +| `publicites` | `statut`, `page` | `{ publicites, total, pages, erreurPublicites?, historique, erreurHistorique?, pageSize }` | `ff` | +| `produit-fiche` | `codein` ou `cc` | `getProduitFiche(codein)` / `getProduitFicheByCodeCentrale(cc)` (ou `null`) | `produits` | + +## Routes HTTP conservées (mêmes chemins, mêmes réponses) + +`/api/ff-status`, `/api/diag`, `/api/diag/stock-fournisseur`, `/api/test-db`, +`/api/commandes-auto` → `ff` ; `/api/qlik/sync` → `qlik` ; +`/api/grid/rows`, `/api/grid/rows/store-patch`, `/api/export/excel`, +`/api/export/modified-gammes`, `/api/v1/*` → `grid` ; +`/api/produits/search`, `/api/produits/opportunites` → `produits` ; +`/api/admin/sync`, `/api/admin/sync/fournisseurs`, `/api/admin/grid-warmup`, +`/api/logs` → `admin`. Authentification : `/api/auth/{login,logout,session}`. + +## Règles de traduction + +- **Formes JSON identiques** : noms de champs exacts (`#[serde(rename_all = "camelCase")]` + ou `rename` explicite selon l'interface TS), champs optionnels TS absents ⇒ + `#[serde(skip_serializing_if = "Option::is_none")]` (JSON.stringify omet `undefined`, + mais garde `null`). Une `Map` TS renvoyée au client se sérialise comme le faisait le TS + (souvent `Object.fromEntries`). +- **Même SQL** : recopier les requêtes telles quelles (paramètres `$1…`, `QueryBuilder` + ou `format!` contrôlé pour les fragments dynamiques — jamais de valeur utilisateur + interpolée sans échappement). Les colonnes `numeric` se lisent avec `db::get_f64`. +- **Même tolérance aux pannes** : une fonction TS qui avale l'erreur et rend `[]` + fait de même ; une qui lève propage une erreur. +- **Journaux** : `tracing::info!/warn!/error!` avec les mêmes préfixes (`[pg-ff]`, `[qlik]`…). +- **Dates** : le conteneur tourne en UTC ; `new Date()` ⇒ `chrono::Local::now()`. +- **Commentaires** en français, comme le code d'origine : garder les « pourquoi ». +- `cargo build` sans avertissement dans son module ; `cargo test` pour la logique pure. diff --git a/server/sql/init.sql b/server/sql/init.sql new file mode 100644 index 0000000..b2e7866 --- /dev/null +++ b/server/sql/init.sql @@ -0,0 +1,150 @@ +-- Schéma CollectFlow : joué à chaque démarrage (idempotent). +-- Porté depuis scripts/db-init.js. + +CREATE TABLE IF NOT EXISTS "ai_supplier_context" ( + "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, + "context" text NOT NULL, + "updated_at" timestamp DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "session_snapshots" ( + "id" serial PRIMARY KEY NOT NULL, + "user_id" integer, + "code_fournisseur" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "magasin" varchar(20) NOT NULL, + "changes" jsonb NOT NULL, + "summary_json" jsonb, + "label" text, + "type" varchar(20) DEFAULT 'snapshot', + "created_at" timestamp DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "idx_snapshots_fournisseur_date" + ON "session_snapshots" ("code_fournisseur", "created_at" DESC); +CREATE INDEX IF NOT EXISTS "idx_snapshots_user_type_date" + ON "session_snapshots" ("user_id", "type", "created_at" DESC); + +CREATE TABLE IF NOT EXISTS "users" ( + "id" serial PRIMARY KEY NOT NULL, + "username" varchar(50) NOT NULL UNIQUE, + "password_hash" text NOT NULL, + "role" varchar(20) NOT NULL DEFAULT 'user', + "created_at" timestamp DEFAULT now() +); + +CREATE TABLE IF NOT EXISTS "commande_cadences" ( + "id" serial PRIMARY KEY NOT NULL, + "code_fournisseur" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "site" varchar(20) NOT NULL, + "intervalle_semaines" integer NOT NULL, + "actif" boolean NOT NULL DEFAULT true, + "created_at" timestamp DEFAULT now(), + "updated_at" timestamp DEFAULT now() +); + +CREATE UNIQUE INDEX IF NOT EXISTS "uq_cadence_fou_site" + ON "commande_cadences" ("code_fournisseur", "site"); + +CREATE INDEX IF NOT EXISTS "idx_cadence_site" + ON "commande_cadences" ("site"); + +CREATE TABLE IF NOT EXISTS "qlik_network_metrics" ( + "code_centrale" varchar(20) PRIMARY KEY NOT NULL, + "ca_reseau" numeric(16, 2), + "qte_reseau" numeric(14, 2), + "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "marge_pct_reseau" numeric(8, 4), + "periode" varchar(20), + "fetched_at" timestamp DEFAULT now() +); + +ALTER TABLE "qlik_network_metrics" + ADD COLUMN IF NOT EXISTS "ca_par_magasin_reseau" numeric(16, 2), + ADD COLUMN IF NOT EXISTS "marge_pct_reseau" numeric(8, 4), + ADD COLUMN IF NOT EXISTS "qte_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "metrics_by_month" jsonb, + ADD COLUMN IF NOT EXISTS "libelle_reseau" varchar(255), + ADD COLUMN IF NOT EXISTS "fournisseur_reseau" varchar(255); +ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "rupture_pct_reseau"; +ALTER TABLE "qlik_network_metrics" DROP COLUMN IF EXISTS "couverture_stock_reseau"; + +CREATE TABLE IF NOT EXISTS "grid_rows" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "nom_fournisseur" varchar(255), + "libelle1" varchar(500), + "gtin" varchar(30), + "reference" varchar(100), + "code_centrale" varchar(20), + "code1" varchar(20), + "code2" varchar(20), + "code3" varchar(20), + "code_gamme" varchar(20), + "code_gamme_init" varchar(20), + "total_ca" numeric(16, 2), + "total_quantite" numeric(14, 2), + "total_marge" numeric(16, 2), + "taux_marge" numeric(8, 4), + "stock_actuel" numeric(14, 2), + "ca_reseau" numeric(16, 2), + "qte_reseau" numeric(14, 2), + "nb_magasins_reseau" integer, + "ca_par_magasin_reseau" numeric(16, 2), + "marge_pct_reseau" numeric(8, 4), + "payload" jsonb NOT NULL, + "computed_at" timestamp DEFAULT now() NOT NULL +); +CREATE UNIQUE INDEX IF NOT EXISTS "uq_grid_rows_fou_codein" + ON "grid_rows" ("code_fournisseur", "codein"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_fournisseur" ON "grid_rows" ("code_fournisseur"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_codein" ON "grid_rows" ("codein"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_gamme" ON "grid_rows" ("code_gamme"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_code_centrale" ON "grid_rows" ("code_centrale"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_total_ca" ON "grid_rows" ("total_ca"); +CREATE INDEX IF NOT EXISTS "idx_grid_rows_libelle" ON "grid_rows" ("libelle1"); + +CREATE TABLE IF NOT EXISTS "api_keys" ( + "id" serial PRIMARY KEY NOT NULL, + "name" varchar(100) NOT NULL, + "key_prefix" varchar(20) NOT NULL, + "key_hash" text NOT NULL UNIQUE, + "role" varchar(20) DEFAULT 'user' NOT NULL, + "created_by" varchar(50), + "created_at" timestamp DEFAULT now(), + "last_used_at" timestamp, + "revoked_at" timestamp +); + +CREATE TABLE IF NOT EXISTS "sync_fournisseurs" ( + "code_fournisseur" varchar(20) PRIMARY KEY NOT NULL, + "nom_fournisseur" varchar(255), + "actif_sql" boolean NOT NULL DEFAULT true, + "actif_qlik" boolean NOT NULL DEFAULT true, + "desactive_motif" text, + "dernier_sql_at" timestamp, + "dernier_sql_statut" varchar(20), + "dernier_sql_lignes" integer, + "dernier_sql_erreur" text, + "dernier_sql_ms" integer, + "dernier_qlik_at" timestamp, + "dernier_qlik_statut" varchar(20), + "dernier_qlik_codes" integer, + "dernier_qlik_erreur" text, + "dernier_qlik_ms" integer, + "updated_at" timestamp DEFAULT now() +); +CREATE INDEX IF NOT EXISTS "idx_sync_fou_sql" ON "sync_fournisseurs" ("actif_sql", "dernier_sql_at"); +CREATE INDEX IF NOT EXISTS "idx_sync_fou_qlik" ON "sync_fournisseurs" ("actif_qlik", "dernier_qlik_at"); + +CREATE TABLE IF NOT EXISTS "gammes_a_valider" ( + "code_fournisseur" varchar(20) NOT NULL, + "codein" varchar(20) NOT NULL, + "gamme" varchar(20) NOT NULL, + "gamme_precedente" varchar(20), + "auteur" varchar(100), + "created_at" timestamp DEFAULT now() NOT NULL, + PRIMARY KEY ("code_fournisseur", "codein") +); diff --git a/server/src/admin/mod.rs b/server/src/admin/mod.rs new file mode 100644 index 0000000..2306925 --- /dev/null +++ b/server/src/admin/mod.rs @@ -0,0 +1,22 @@ +//! Module `admin` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Tâches de fond lancées au démarrage (minuteur de la synchro nocturne). +pub fn start_background_tasks() {} diff --git a/server/src/api_v1_response.rs b/server/src/api_v1_response.rs new file mode 100644 index 0000000..cdbca9b --- /dev/null +++ b/server/src/api_v1_response.rs @@ -0,0 +1,81 @@ +//! Enveloppe de réponse de l'API publique `/api/v1`. +//! +//! Une forme unique : `{ data, pagination?, meta }` en succès, +//! `{ error: { code, message, details? } }` en échec, avec de vrais statuts HTTP. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; +use serde_json::{json, Map, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ApiErrorCode { + Unauthorized, + Forbidden, + BadRequest, + NotFound, + /// Donnée pas encore calculée : 202, ce n'est pas un échec. + NotReady, + InternalError, +} + +impl ApiErrorCode { + pub fn as_str(self) -> &'static str { + match self { + Self::Unauthorized => "unauthorized", + Self::Forbidden => "forbidden", + Self::BadRequest => "bad_request", + Self::NotFound => "not_found", + Self::NotReady => "not_ready", + Self::InternalError => "internal_error", + } + } + pub fn status(self) -> StatusCode { + match self { + Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::Forbidden => StatusCode::FORBIDDEN, + Self::BadRequest => StatusCode::BAD_REQUEST, + Self::NotFound => StatusCode::NOT_FOUND, + Self::NotReady => StatusCode::ACCEPTED, + Self::InternalError => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Pagination { + pub page: i64, + pub limit: i64, + pub total: i64, + pub total_pages: i64, + /// `true` s'il reste des pages à lire. + pub has_more: bool, +} + +pub fn build_pagination(page: i64, limit: i64, total: i64) -> Pagination { + let limit = limit.max(1); + let total_pages = ((total as f64) / (limit as f64)).ceil().max(1.0) as i64; + Pagination { page, limit, total, total_pages, has_more: page < total_pages } +} + +/// Réponse de succès. `meta` porte la fraîcheur de la donnée (`computedAt`). +pub fn ok(data: T, pagination: Option, meta: Option>) -> Response { + let mut body = Map::new(); + body.insert("data".into(), serde_json::to_value(data).unwrap_or(Value::Null)); + if let Some(p) = pagination { + body.insert("pagination".into(), serde_json::to_value(p).unwrap_or(Value::Null)); + } + body.insert("meta".into(), Value::Object(meta.unwrap_or_default())); + Json(Value::Object(body)).into_response() +} + +/// Réponse d'erreur. `details` sert notamment aux erreurs de validation. +pub fn fail(code: ApiErrorCode, message: impl Into, details: Option) -> Response { + let mut err = json!({ "code": code.as_str(), "message": message.into() }); + if let Some(d) = details { + err["details"] = d; + } + (code.status(), Json(json!({ "error": err }))).into_response() +} diff --git a/server/src/auth/api_key.rs b/server/src/auth/api_key.rs new file mode 100644 index 0000000..a3591b1 --- /dev/null +++ b/server/src/auth/api_key.rs @@ -0,0 +1,149 @@ +//! Authentification de l'API publique `/api/v1`. +//! +//! Deux voies : clé d'API (`X-API-Key` ou `Authorization: Bearer …`) pour les +//! appelants non-navigateur, ou session du navigateur (l'interface utilise les +//! mêmes routes). Échec → 401 JSON au format `/api/v1`, jamais de redirection. +//! +//! Seul le hachage SHA-256 des clés est stocké (table `api_keys`). + +use crate::api_v1_response::{fail, ApiErrorCode}; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::HeaderMap; +use axum::response::Response; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use parking_lot::Mutex; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; +use subtle::ConstantTimeEq; + +const KEY_PREFIX: &str = "cf_"; +const TRACE_USAGE: Duration = Duration::from_secs(60); +static DERNIERE_TRACE: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +#[derive(Debug, Clone)] +pub struct AuthContext { + /// "api_key" | "session" + pub via: &'static str, + pub role: String, + /// Nom de la clé, ou nom de l'utilisateur connecté. + pub subject: String, +} + +impl AuthContext { + /// Restreint un endpoint aux appelants administrateurs. + pub fn require_admin(&self) -> Result<(), Response> { + if self.role != "admin" { + return Err(fail(ApiErrorCode::Forbidden, "Rôle administrateur requis pour cette opération.", None)); + } + Ok(()) + } +} + +pub fn hash_api_key(key: &str) -> String { + hex::encode(Sha256::digest(key.as_bytes())) +} + +pub struct GeneratedKey { + pub key: String, + pub key_hash: String, + pub key_prefix: String, +} + +/// Génère une clé : en clair (affichée une seule fois), son hachage, et un préfixe lisible. +pub fn generate_api_key() -> GeneratedKey { + let mut buf = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut buf); + let key = format!("{KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf)); + GeneratedKey { key_hash: hash_api_key(&key), key_prefix: key.chars().take(11).collect(), key } +} + +fn hashes_equal(a: &str, b: &str) -> bool { + match (hex::decode(a), hex::decode(b)) { + (Ok(x), Ok(y)) => !x.is_empty() && x.len() == y.len() && bool::from(x.as_slice().ct_eq(y.as_slice())), + _ => false, + } +} + +fn read_key(headers: &HeaderMap) -> Option { + if let Some(v) = headers.get("x-api-key").and_then(|v| v.to_str().ok()) { + if !v.trim().is_empty() { + return Some(v.trim().to_string()); + } + } + let authz = headers.get("authorization").and_then(|v| v.to_str().ok())?; + let re = regex::Regex::new(r"(?i)^Bearer\s+(.+)$").unwrap(); + re.captures(authz).map(|c| c[1].trim().to_string()) +} + +/// Authentifie une requête `/api/v1`. +pub async fn require_api_auth(headers: &HeaderMap) -> Result { + if let Some(presented) = read_key(headers) { + let presented_hash = hash_api_key(&presented); + let pool = crate::db::pool().map_err(|e| fail(ApiErrorCode::InternalError, e.message, None))?; + let row = sqlx::query("SELECT id, name, role, key_hash, revoked_at FROM api_keys WHERE key_hash = $1 LIMIT 1") + .bind(&presented_hash) + .fetch_optional(&pool) + .await + .map_err(|e| fail(ApiErrorCode::InternalError, e.to_string(), None))?; + let Some(row) = row else { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None)); + }; + let key_hash: String = row.try_get("key_hash").unwrap_or_default(); + if !hashes_equal(&key_hash, &presented_hash) { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API invalide.", None)); + } + let revoked: Option = row.try_get("revoked_at").ok().flatten(); + if revoked.is_some() { + return Err(fail(ApiErrorCode::Unauthorized, "Clé d'API révoquée.", None)); + } + let id: i32 = row.try_get("id").unwrap_or_default(); + // Trace d'usage — jamais bloquante, au plus une écriture par minute et par clé. + let tracer = { + let mut m = DERNIERE_TRACE.lock(); + let due = m.get(&id).map(|t| t.elapsed() >= TRACE_USAGE).unwrap_or(true); + if due { + m.insert(id, Instant::now()); + } + due + }; + if tracer { + tokio::spawn(async move { + if let Err(e) = sqlx::query("UPDATE api_keys SET last_used_at = now() WHERE id = $1").bind(id).execute(&pool).await { + tracing::error!("[api-auth] lastUsedAt KO: {}", crate::error::short(&e, 120)); + } + }); + } + return Ok(AuthContext { + via: "api_key", + role: row.try_get("role").unwrap_or_else(|_| "user".into()), + subject: row.try_get("name").unwrap_or_default(), + }); + } + + // Repli sur la session du navigateur. + if let Some(c) = super::session::from_headers(headers) { + return Ok(AuthContext { via: "session", role: c.role, subject: c.name }); + } + + Err(fail( + ApiErrorCode::Unauthorized, + "Authentification requise : fournissez un en-tête X-API-Key (ou Authorization: Bearer), ou connectez-vous.", + None, + )) +} + +/// Extracteur pour les routes `/api/v1`. +pub struct ApiAuth(pub AuthContext); + +impl FromRequestParts for ApiAuth { + type Rejection = Response; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(ApiAuth(require_api_auth(&parts.headers).await?)) + } +} diff --git a/server/src/auth/authz.rs b/server/src/auth/authz.rs new file mode 100644 index 0000000..f8d0229 --- /dev/null +++ b/server/src/auth/authz.rs @@ -0,0 +1,155 @@ +//! Contrôle d'accès commun aux actions, routes et pages. +//! +//! Le jeton de session seul ne suffit pas : il reste valide jusqu'à son +//! expiration, même si le compte a été supprimé ou rétrogradé entre-temps. On +//! relit donc l'utilisateur dans `users` (cache court), et le rôle en base +//! l'emporte sur celui du jeton. +//! +//! Base injoignable : on se contente du jeton (mode dégradé), sans quoi le +//! compte de secours `data/users.json` ne pourrait plus rien faire — y compris +//! configurer la base. + +use super::session::{from_headers, Claims}; +use crate::error::AppError; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use parking_lot::Mutex; +use serde::Serialize; +use sqlx::Row; +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::{Duration, Instant}; + +pub const MESSAGE_SESSION: &str = "Session expirée : reconnectez-vous."; +pub const MESSAGE_ADMIN: &str = "Action réservée aux administrateurs."; + +const CACHE_TTL: Duration = Duration::from_secs(60); +const PAUSE_BASE_INJOIGNABLE: Duration = Duration::from_secs(15); + +#[derive(Debug, Clone, Serialize)] +pub struct UtilisateurCourant { + pub id: String, + pub username: String, + pub role: String, +} + +impl UtilisateurCourant { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } + /// Identifiant numérique (`users.id`), `None` pour le compte de secours. + pub fn id_numerique(&self) -> Option { + self.id.parse::().ok().filter(|v| *v > 0) + } +} + +#[derive(Clone)] +struct Fiche { + exists: bool, + role: String, + username: String, + at: Instant, +} + +struct CacheState { + fiches: HashMap, + injoignable_depuis: Option, +} + +static CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(CacheState { fiches: HashMap::new(), injoignable_depuis: None })); + +/// Oublie la fiche d'un utilisateur (après modification de son compte). +pub fn invalider(id: &str) { + CACHE.lock().fiches.remove(id); +} + +/// Fiche de l'utilisateur en base, ou `None` si la base n'a pas pu répondre. +async fn lire_fiche(id: &str) -> Option { + { + let c = CACHE.lock(); + if let Some(f) = c.fiches.get(id) { + if f.at.elapsed() < CACHE_TTL { + return Some(f.clone()); + } + } + if c.injoignable_depuis.map(|t| t.elapsed() < PAUSE_BASE_INJOIGNABLE).unwrap_or(false) { + return None; + } + } + let id_num: i32 = if id.chars().all(|c| c.is_ascii_digit()) && !id.is_empty() { + id.parse::().unwrap_or(-1) + } else { + -1 + }; + let res = async { + let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let row = sqlx::query("SELECT username, role FROM users WHERE id = $1 LIMIT 1") + .bind(id_num) + .fetch_optional(&pool) + .await?; + anyhow::Ok(row) + } + .await; + match res { + Ok(row) => { + let fiche = match row { + Some(r) => Fiche { + exists: true, + username: r.try_get("username").unwrap_or_default(), + role: r.try_get("role").unwrap_or_default(), + at: Instant::now(), + }, + None => Fiche { exists: false, username: String::new(), role: String::new(), at: Instant::now() }, + }; + CACHE.lock().fiches.insert(id.to_string(), fiche.clone()); + Some(fiche) + } + Err(e) => { + CACHE.lock().injoignable_depuis = Some(Instant::now()); + tracing::warn!("[authz] Base injoignable, contrôle sur le seul jeton de session : {}", crate::error::short(&e, 200)); + None + } + } +} + +/// Utilisateur connecté, relu en base. 401 sinon. +pub async fn require_session(token: Option<&Claims>) -> Result { + let jeton = token.ok_or_else(|| AppError::unauthorized(MESSAGE_SESSION))?; + match lire_fiche(&jeton.id).await { + None => Ok(UtilisateurCourant { id: jeton.id.clone(), username: jeton.name.clone(), role: jeton.role.clone() }), + Some(f) if !f.exists => Err(AppError::unauthorized(MESSAGE_SESSION)), + Some(f) => Ok(UtilisateurCourant { id: jeton.id.clone(), username: f.username, role: f.role }), + } +} + +/// Administrateur connecté. 401 ou 403 sinon. +pub async fn require_admin(token: Option<&Claims>) -> Result { + let u = require_session(token).await?; + if !u.is_admin() { + return Err(AppError::forbidden(MESSAGE_ADMIN)); + } + Ok(u) +} + +/// Extracteur : utilisateur connecté (sinon 401 JSON `{ error }`). +pub struct SessionUser(pub UtilisateurCourant); + +impl FromRequestParts for SessionUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let claims = from_headers(&parts.headers); + Ok(SessionUser(require_session(claims.as_ref()).await?)) + } +} + +/// Extracteur : administrateur connecté (sinon 401/403 JSON `{ error }`). +pub struct AdminUser(pub UtilisateurCourant); + +impl FromRequestParts for AdminUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + let claims = from_headers(&parts.headers); + Ok(AdminUser(require_admin(claims.as_ref()).await?)) + } +} diff --git a/server/src/auth/fallback.rs b/server/src/auth/fallback.rs new file mode 100644 index 0000000..b019c7a --- /dev/null +++ b/server/src/auth/fallback.rs @@ -0,0 +1,62 @@ +//! Comptes de secours `data/users.json`, utilisés seulement quand la base est +//! injoignable (première installation, base pas encore configurée). + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UserJson { + pub id: String, + pub username: String, + pub password_hash: String, + pub role: String, +} + +fn path() -> PathBuf { + crate::config::data_dir().join("users.json") +} + +fn default_admin() -> UserJson { + UserJson { + id: "0".into(), + username: "admin".into(), + password_hash: super::password::hash_password("admin"), + role: "admin".into(), + } +} + +/// Comptes de secours (créés avec admin/admin si le fichier n'existe pas). +pub fn get_fallback_users() -> Vec { + let p = path(); + if !p.exists() { + let admin = default_admin(); + save_fallback_users(&[admin.clone()]); + return vec![admin]; + } + match std::fs::read_to_string(&p).ok().and_then(|s| serde_json::from_str(&s).ok()) { + Some(users) => users, + None => { + tracing::error!("[AUTH] Error reading users.json"); + vec![] + } + } +} + +pub fn save_fallback_users(users: &[UserJson]) { + let p = path(); + if let Some(dir) = p.parent() { + let _ = std::fs::create_dir_all(dir); + } + if let Err(e) = std::fs::write(&p, serde_json::to_string_pretty(users).unwrap_or_default()) { + tracing::error!("[AUTH] Error writing users.json: {e}"); + } +} + +/// Base injoignable : le fichier de secours doit contenir au moins un administrateur. +pub fn assurer_admin_de_secours() { + if get_fallback_users().is_empty() { + tracing::info!("[AUTH] Initializing local JSON fallback with admin/admin..."); + save_fallback_users(&[default_admin()]); + } +} diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs new file mode 100644 index 0000000..a1da3d4 --- /dev/null +++ b/server/src/auth/mod.rs @@ -0,0 +1,191 @@ +//! Authentification : comptes, sessions, clés d'API. + +pub mod api_key; +pub mod authz; +pub mod fallback; +pub mod password; +pub mod session; + +pub use api_key::{ApiAuth, AuthContext}; +pub use authz::{require_admin, require_session, AdminUser, SessionUser, UtilisateurCourant}; +pub use session::{Claims, Token}; + +use axum::http::{header, HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use serde::Deserialize; +use serde_json::json; +use sqlx::Row; +use tokio::sync::Mutex; + +// --------------------------------------------------------------------------- +// Amorçage du compte admin +// --------------------------------------------------------------------------- + +/// `true` une fois l'amorçage réussi pour ce processus. +static AMORCE: Mutex = Mutex::const_new(false); + +/// Prépare les comptes au premier démarrage, une seule fois par processus. +/// +/// - Base joignable : crée la table `users` si besoin, puis admin/admin +/// **uniquement si la table est vide**. +/// - Base injoignable : s'assure que le fichier de secours contient un +/// administrateur ; on retentera la base à la prochaine connexion. +pub async fn ensure_admin_exists() -> bool { + let mut done = AMORCE.lock().await; + if *done { + return false; + } + match amorcer().await { + Ok(cree) => { + *done = true; + cree + } + Err(e) => { + tracing::warn!("[AUTH] Database seeding failed (expected if DB not configured yet): {e:#}"); + false + } + } +} + +async fn amorcer() -> anyhow::Result { + let pool = match crate::db::pool() { + Ok(p) => p, + Err(e) => { + fallback::assurer_admin_de_secours(); + anyhow::bail!(e.message); + } + }; + if let Err(e) = sqlx::query("SELECT 1").execute(&pool).await { + fallback::assurer_admin_de_secours(); + return Err(e.into()); + } + sqlx::raw_sql( + r#"CREATE TABLE IF NOT EXISTS "users" ( + id SERIAL PRIMARY KEY, + username VARCHAR(50) NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + role VARCHAR(20) NOT NULL DEFAULT 'user', + created_at TIMESTAMP DEFAULT NOW() + )"#, + ) + .execute(&pool) + .await?; + let nb: i64 = sqlx::query_scalar("SELECT count(*) FROM users").fetch_one(&pool).await?; + if nb > 0 { + return Ok(false); + } + tracing::info!("[AUTH] Table users vide : création du compte admin/admin."); + let res = sqlx::query("INSERT INTO users (username, password_hash, role) VALUES ('admin', $1, 'admin') ON CONFLICT DO NOTHING") + .bind(password::hash_password("admin")) + .execute(&pool) + .await?; + Ok(res.rows_affected() > 0) +} + +// --------------------------------------------------------------------------- +// Connexion / déconnexion +// --------------------------------------------------------------------------- + +struct CompteTrouve { + id: String, + username: String, + password_hash: String, + role: String, +} + +/// Vérifie les identifiants. `None` si refusés. +async fn authorize(username: &str, password: &str) -> Option { + ensure_admin_exists().await; + if username.is_empty() || password.is_empty() { + return None; + } + tracing::info!("[AUTH] Checking user \"{username}\"..."); + + let db_lookup = async { + let pool = crate::db::pool().map_err(|e| anyhow::anyhow!(e.message))?; + let row = sqlx::query("SELECT id, username, password_hash, role FROM users WHERE username = $1") + .bind(username) + .fetch_optional(&pool) + .await?; + anyhow::Ok(row.map(|r| CompteTrouve { + id: r.try_get::("id").unwrap_or_default().to_string(), + username: r.try_get("username").unwrap_or_default(), + password_hash: r.try_get("password_hash").unwrap_or_default(), + role: r.try_get("role").unwrap_or_else(|_| "user".into()), + })) + }; + + let user = match db_lookup.await { + Ok(u) => { + tracing::info!("[AUTH] DB check: User found: {}", u.is_some()); + u + } + Err(_) => { + // Base injoignable uniquement : un compte absent de la base ne doit + // pas pouvoir se connecter avec le fichier de secours. + tracing::warn!("[AUTH] DB unreachable, falling back to JSON."); + fallback::get_fallback_users().into_iter().find(|u| u.username == username).map(|u| CompteTrouve { + id: u.id, + username: u.username, + password_hash: u.password_hash, + role: u.role, + }) + } + }?; + + let ok = password::verify_password(password, &user.password_hash); + tracing::info!("[AUTH] Password valid for \"{username}\": {ok}"); + ok.then_some(user) +} + +#[derive(Deserialize)] +struct LoginBody { + #[serde(default)] + username: String, + #[serde(default)] + password: String, +} + +async fn login(headers: HeaderMap, Json(body): Json) -> Response { + let Some(user) = authorize(body.username.trim(), &body.password).await else { + return (StatusCode::UNAUTHORIZED, Json(json!({ "error": "Utilisateur ou mot de passe incorrect." }))).into_response(); + }; + let claims = Claims { + id: user.id.clone(), + name: user.username.clone(), + role: user.role.clone(), + exp: chrono::Utc::now().timestamp() + session::MAX_AGE_SECS, + }; + ( + [(header::SET_COOKIE, session::set_cookie(&claims, &headers)), (header::CACHE_CONTROL, "no-store".into())], + Json(json!({ "ok": true, "user": { "id": user.id, "name": user.username, "role": user.role } })), + ) + .into_response() +} + +async fn logout() -> Response { + ([(header::SET_COOKIE, session::clear_cookie())], Json(json!({ "ok": true }))).into_response() +} + +/// Session courante pour l'interface (`useSession`). Relue en base : un compte +/// supprimé n'a plus de session. +async fn current_session(Token(token): Token) -> Response { + let user = match token.as_ref() { + Some(_) => authz::require_session(token.as_ref()).await.ok(), + None => None, + }; + let body = match user { + Some(u) => json!({ "user": { "id": u.id, "name": u.username, "role": u.role } }), + None => json!({ "user": null }), + }; + ([(header::CACHE_CONTROL, "no-store")], Json(body)).into_response() +} + +pub fn router() -> Router { + Router::new() + .route("/api/auth/login", post(login)) + .route("/api/auth/logout", post(logout)) + .route("/api/auth/session", get(current_session)) +} diff --git a/server/src/auth/password.rs b/server/src/auth/password.rs new file mode 100644 index 0000000..9a53311 --- /dev/null +++ b/server/src/auth/password.rs @@ -0,0 +1,60 @@ +//! Mots de passe : scrypt au format `sel:hachage` (hex), compatible avec les +//! comptes créés par l'ancienne version Node (`scryptSync(pwd, sel, 64)`, +//! N=16384, r=8, p=1, le sel hexadécimal étant utilisé tel quel comme octets). + +use rand::RngCore; +use subtle::ConstantTimeEq; + +fn derive(password: &str, salt: &str) -> Option<[u8; 64]> { + let params = scrypt::Params::new(14, 8, 1, 64).ok()?; + let mut out = [0u8; 64]; + scrypt::scrypt(password.as_bytes(), salt.as_bytes(), ¶ms, &mut out).ok()?; + Some(out) +} + +pub fn hash_password(password: &str) -> String { + let mut salt = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut salt); + let salt = hex::encode(salt); + let hash = derive(password, &salt).map(hex::encode).unwrap_or_default(); + format!("{salt}:{hash}") +} + +pub fn verify_password(password: &str, stored: &str) -> bool { + let Some((salt, hash)) = stored.split_once(':') else { return false }; + if salt.is_empty() || hash.is_empty() { + return false; + } + let Ok(expected) = hex::decode(hash) else { return false }; + let Some(actual) = derive(password, salt) else { return false }; + expected.len() == actual.len() && bool::from(actual.as_slice().ct_eq(expected.as_slice())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip() { + let h = hash_password("admin"); + assert!(verify_password("admin", &h)); + assert!(!verify_password("nope", &h)); + } + + #[test] + fn compatible_node() { + // require("crypto").scryptSync("admin", "00112233445566778899aabbccddeeff", 64).toString("hex") + let stored = "00112233445566778899aabbccddeeff:".to_string() + + &hex::encode(derive("admin", "00112233445566778899aabbccddeeff").unwrap()); + assert!(verify_password("admin", &stored)); + } +} + +#[cfg(test)] +mod node_compat { + #[test] + fn hash_from_node() { + assert!(super::verify_password("s3cret", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa")); + assert!(!super::verify_password("s3cre", "abcdef0123456789abcdef0123456789:8e07d3f906fe581051e47f776fd3fb6307401dc4702369a6db2b7fbb892eb23571af616a59cf13a162cfd584035381c062b7d17fd4ca0d82a2cdf0e1ce2330aa")); + } +} diff --git a/server/src/auth/session.rs b/server/src/auth/session.rs new file mode 100644 index 0000000..d90a945 --- /dev/null +++ b/server/src/auth/session.rs @@ -0,0 +1,124 @@ +//! Jeton de session signé (cookie HttpOnly), remplaçant le JWT NextAuth. +//! +//! Contenu : identifiant, nom et rôle de l'utilisateur + expiration, encodés en +//! base64url et signés HMAC-SHA256 avec `AUTH_SECRET` (ou un secret aléatoire +//! conservé dans `data/.auth-secret`). + +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum::http::HeaderMap; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use base64::Engine; +use hmac::{Hmac, Mac}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use std::convert::Infallible; +use std::sync::LazyLock; + +pub const COOKIE_NAME: &str = "cf_session"; +/// Durée de vie d'une session : 30 jours (comme NextAuth). +pub const MAX_AGE_SECS: i64 = 30 * 24 * 3600; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + /// Identifiant (`users.id`, ou « 0 » pour le compte de secours). + pub id: String, + pub name: String, + pub role: String, + /// Expiration (secondes Unix). + pub exp: i64, +} + +static SECRET: LazyLock> = LazyLock::new(|| { + if let Ok(s) = std::env::var("AUTH_SECRET") { + if !s.trim().is_empty() { + return s.into_bytes(); + } + } + let path = crate::config::data_dir().join(".auth-secret"); + if let Ok(s) = std::fs::read_to_string(&path) { + if !s.trim().is_empty() { + return s.trim().as_bytes().to_vec(); + } + } + let mut buf = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut buf); + let s = hex::encode(buf); + let _ = std::fs::create_dir_all(crate::config::data_dir()); + if let Err(e) = std::fs::write(&path, &s) { + tracing::warn!("[auth] secret de session non persisté ({e}) : les sessions ne survivront pas au redémarrage"); + } + s.into_bytes() +}); + +fn sign(payload: &str) -> String { + let mut mac = Hmac::::new_from_slice(&SECRET).expect("hmac"); + mac.update(payload.as_bytes()); + URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes()) +} + +pub fn encode(claims: &Claims) -> String { + let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims).unwrap_or_default()); + let sig = sign(&payload); + format!("{payload}.{sig}") +} + +pub fn decode(token: &str) -> Option { + let (payload, sig) = token.split_once('.')?; + let mut mac = Hmac::::new_from_slice(&SECRET).ok()?; + mac.update(payload.as_bytes()); + let sig_bytes = URL_SAFE_NO_PAD.decode(sig).ok()?; + mac.verify_slice(&sig_bytes).ok()?; + let claims: Claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload).ok()?).ok()?; + if claims.exp < chrono::Utc::now().timestamp() { + return None; + } + Some(claims) +} + +/// Jeton lu dans l'en-tête `Cookie`, s'il est présent et valide. +pub fn from_headers(headers: &HeaderMap) -> Option { + for value in headers.get_all(axum::http::header::COOKIE) { + let Ok(s) = value.to_str() else { continue }; + for part in s.split(';') { + let part = part.trim(); + if let Some(v) = part.strip_prefix(COOKIE_NAME).and_then(|r| r.strip_prefix('=')) { + if let Some(c) = decode(v) { + return Some(c); + } + } + } + } + None +} + +fn is_https(headers: &HeaderMap) -> bool { + headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .map(|v| v.eq_ignore_ascii_case("https")) + .unwrap_or(false) +} + +/// En-tête `Set-Cookie` qui ouvre la session. +pub fn set_cookie(claims: &Claims, req_headers: &HeaderMap) -> String { + let secure = if is_https(req_headers) { "; Secure" } else { "" }; + format!("{COOKIE_NAME}={}; Path=/; HttpOnly; SameSite=Lax; Max-Age={MAX_AGE_SECS}{secure}", encode(claims)) +} + +/// En-tête `Set-Cookie` qui ferme la session. +pub fn clear_cookie() -> String { + format!("{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0") +} + +/// Jeton de la requête (jamais de rejet : `None` si absent ou invalide). +#[derive(Debug, Clone)] +pub struct Token(pub Option); + +impl FromRequestParts for Token { + type Rejection = Infallible; + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Token(from_headers(&parts.headers))) + } +} diff --git a/server/src/config.rs b/server/src/config.rs new file mode 100644 index 0000000..d386c94 --- /dev/null +++ b/server/src/config.rs @@ -0,0 +1,99 @@ +//! Fichier de configuration `data/.db-config.json` (réglages saisis dans Paramètres). +//! +//! Le fichier contient des mots de passe et des clés d'API : il ne repart jamais +//! tel quel vers le navigateur. Les clés inconnues sont conservées à l'écriture. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::PathBuf; + +/// Répertoire des données persistantes (volume Docker `/app/data`). +pub fn data_dir() -> PathBuf { + std::env::var("DATA_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default().join("data")) +} + +pub fn config_file() -> PathBuf { + data_dir().join(".db-config.json") +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DbConfig { + #[serde(default)] + pub url: Option, + #[serde(default)] + pub open_router_key: Option, + #[serde(default)] + pub open_router_model: Option, + #[serde(default)] + pub ai_provider: Option, + #[serde(default)] + pub google_ai_key: Option, + #[serde(default)] + pub google_ai_model: Option, + /// Qlik Sense — données réseau. + #[serde(default)] + pub qlik_host: Option, + #[serde(default)] + pub qlik_user: Option, + #[serde(default)] + pub qlik_password: Option, + /// API REST FF Nancy (ex. https://api.ffnancy.fr). + #[serde(default)] + pub ff_api_base_url: Option, +} + +fn read_raw_sync() -> Map { + std::fs::read_to_string(config_file()) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| match v { + Value::Object(m) => Some(m), + _ => None, + }) + .unwrap_or_default() +} + +/// Lit la config existante (vide si absente ou illisible). +pub fn read_config() -> DbConfig { + serde_json::from_value(Value::Object(read_raw_sync())).unwrap_or_default() +} + +/// Fusionne `patch` dans la config enregistrée : les clés absentes du patch sont +/// conservées ; une clé passée à `null` est retirée. +pub fn write_config(patch: Map) -> std::io::Result<()> { + let mut existing = read_raw_sync(); + for (k, v) in patch { + if v.is_null() { + existing.remove(&k); + } else { + existing.insert(k, v); + } + } + std::fs::create_dir_all(data_dir())?; + let text = serde_json::to_string_pretty(&Value::Object(existing)).unwrap_or_else(|_| "{}".into()); + std::fs::write(config_file(), text) +} + +/// Chaîne non vide, sinon `None`. +pub fn non_empty(s: Option) -> Option { + s.map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +/// URL de connexion PostgreSQL : réglage enregistré, sinon `DATABASE_URL`. +pub fn database_url() -> Option { + non_empty(read_config().url).or_else(|| non_empty(std::env::var("DATABASE_URL").ok())) +} + +/// Variable d'environnement numérique bornée (équivalent de `envNumber` côté TS). +pub fn env_number(name: &str, default: i64, min: i64, max: i64) -> i64 { + match std::env::var(name) { + Ok(raw) if !raw.trim().is_empty() => match raw.trim().parse::() { + Ok(v) if v.is_finite() => (v.trunc() as i64).clamp(min, max), + _ => default, + }, + _ => default, + } +} diff --git a/server/src/db.rs b/server/src/db.rs new file mode 100644 index 0000000..d163c06 --- /dev/null +++ b/server/src/db.rs @@ -0,0 +1,202 @@ +//! Pool PostgreSQL unique par processus, remplaçable à chaud. +//! +//! L'URL vient de `data/.db-config.json` (Paramètres), sinon de `DATABASE_URL`. +//! Enregistrer de nouveaux réglages appelle [`refresh`] : le pool suivant est +//! recréé à la première requête. +//! +//! La même base porte les tables de l'application et la recopie nocturne des +//! tables FF Nancy (`articles`, `mvtart`, `cube_stock`, `fouident`…). + +use crate::error::{AppError, AppResult}; +use chrono::{DateTime, NaiveDate, NaiveDateTime, Utc}; +use parking_lot::RwLock; +use serde_json::{Map, Value}; +use sqlx::postgres::{PgConnectOptions, PgPoolOptions, PgRow}; +use sqlx::{Column, Executor, PgPool, Row, TypeInfo, ValueRef}; +use std::str::FromStr; +use std::sync::LazyLock; +use std::time::Duration; + +static POOL: LazyLock>> = LazyLock::new(|| RwLock::new(None)); + +/// Masque le mot de passe d'une URL de connexion pour les journaux. +pub fn mask_url(url: &str) -> String { + let re = regex::Regex::new(r":([^@/]+)@").unwrap(); + re.replace(url, ":****@").into_owned() +} + +/// Options de connexion communes (pool applicatif et tests de connexion). +pub fn connect_options(url: &str) -> Result { + let mut opts = PgConnectOptions::from_str(url)?.application_name("collectflow"); + // Délai maximal par requête : opt-in (les calculs nocturnes des plus gros + // fournisseurs peuvent être longs). + if let Some(ms) = std::env::var("PG_STATEMENT_TIMEOUT_MS").ok().and_then(|v| v.parse::().ok()).filter(|v| *v > 0) { + opts = opts.options([("statement_timeout", ms.to_string())]); + } + Ok(opts) +} + +fn build_pool(url: &str) -> Result { + let opts = connect_options(url)?; + Ok(PgPoolOptions::new() + .max_connections(20) + .idle_timeout(Duration::from_secs(30)) + .acquire_timeout(Duration::from_secs(10)) + // Pas de workers parallèles PostgreSQL : ils consomment /dev/shm, trop + // petit dans le conteneur, et font échouer les grosses agrégations. + .after_connect(|conn, _meta| { + Box::pin(async move { + if let Err(e) = conn.execute("SET max_parallel_workers_per_gather = 0").await { + tracing::error!("[DB] SET max_parallel_workers_per_gather failed: {e}"); + } + Ok(()) + }) + }) + .connect_lazy_with(opts)) +} + +/// Pool courant, créé à la demande. Erreur 500 si aucune base n'est configurée. +pub fn pool() -> AppResult { + if let Some(p) = POOL.read().as_ref() { + return Ok(p.clone()); + } + let mut guard = POOL.write(); + if let Some(p) = guard.as_ref() { + return Ok(p.clone()); + } + let url = crate::config::database_url().ok_or_else(|| { + tracing::error!("[DB] CRITICAL: No database connection string available!"); + AppError::internal("Base de données non configurée (Paramètres → Base de données).") + })?; + tracing::info!("[DB] Initializing connection with: {}", mask_url(&url)); + let p = build_pool(&url).map_err(|e| AppError::internal(format!("URL de base invalide : {e}")))?; + *guard = Some(p.clone()); + Ok(p) +} + +/// Oublie le pool courant (réglages modifiés dans Paramètres). +pub fn refresh() { + tracing::info!("[DB] Refreshing database connection..."); + if let Some(old) = POOL.write().take() { + tokio::spawn(async move { old.close().await }); + } +} + +// --------------------------------------------------------------------------- +// Conversion générique d'une ligne en JSON +// --------------------------------------------------------------------------- + +/// Horodatage au format JS `toISOString()` (`2026-01-31T12:34:56.789Z`). +pub fn iso(dt: NaiveDateTime) -> String { + dt.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +pub fn iso_utc(dt: DateTime) -> String { + iso(dt.naive_utc()) +} + +/// Convertit une ligne en objet JSON, colonne par colonne, selon le type SQL. +/// +/// Les `numeric` deviennent des nombres (le pilote Node les rendait en chaînes, +/// que le code convertissait avec `Number()`), les horodatages des chaînes ISO. +pub fn row_to_json(row: &PgRow) -> Value { + let mut obj = Map::new(); + for (i, col) in row.columns().iter().enumerate() { + obj.insert(col.name().to_string(), cell_to_json(row, i)); + } + Value::Object(obj) +} + +pub fn rows_to_json(rows: &[PgRow]) -> Vec { + rows.iter().map(row_to_json).collect() +} + +fn cell_to_json(row: &PgRow, i: usize) -> Value { + let raw = match row.try_get_raw(i) { + Ok(r) => r, + Err(_) => return Value::Null, + }; + if raw.is_null() { + return Value::Null; + } + let ty = raw.type_info().name().to_ascii_uppercase(); + macro_rules! get { + ($t:ty) => { + row.try_get::<$t, _>(i).ok() + }; + } + let num = |f: Option| f.and_then(serde_json::Number::from_f64).map(Value::Number).unwrap_or(Value::Null); + match ty.as_str() { + "BOOL" => get!(bool).map(Value::Bool).unwrap_or(Value::Null), + "INT2" => get!(i16).map(|v| Value::from(v)).unwrap_or(Value::Null), + "INT4" => get!(i32).map(|v| Value::from(v)).unwrap_or(Value::Null), + "INT8" => get!(i64).map(|v| Value::from(v)).unwrap_or(Value::Null), + "FLOAT4" => num(get!(f32).map(|v| v as f64)), + "FLOAT8" => num(get!(f64)), + "NUMERIC" => num(get!(rust_decimal::Decimal).and_then(|d| d.to_string().parse::().ok())), + "JSON" | "JSONB" => get!(Value).unwrap_or(Value::Null), + "TIMESTAMP" => get!(NaiveDateTime).map(|d| Value::String(iso(d))).unwrap_or(Value::Null), + "TIMESTAMPTZ" => get!(DateTime).map(|d| Value::String(iso_utc(d))).unwrap_or(Value::Null), + "DATE" => get!(NaiveDate) + .map(|d| Value::String(iso(d.and_hms_opt(0, 0, 0).unwrap()))) + .unwrap_or(Value::Null), + "TEXT[]" | "VARCHAR[]" | "_TEXT" | "_VARCHAR" => get!(Vec).map(Value::from).unwrap_or(Value::Null), + _ => get!(String).map(Value::String).unwrap_or(Value::Null), + } +} + +// --------------------------------------------------------------------------- +// Lecture tolérante de colonnes (équivalent des `Number(r.x) || 0` du code TS) +// --------------------------------------------------------------------------- + +/// Valeur numérique d'une colonne quel que soit son type SQL (int, numeric, +/// float, texte numérique). `None` si NULL ou illisible. +pub fn get_f64(row: &PgRow, col: &str) -> Option { + let raw = row.try_get_raw(col).ok()?; + if raw.is_null() { + return None; + } + let ty = raw.type_info().name().to_ascii_uppercase(); + match ty.as_str() { + "INT2" => row.try_get::(col).ok().map(|v| v as f64), + "INT4" => row.try_get::(col).ok().map(|v| v as f64), + "INT8" => row.try_get::(col).ok().map(|v| v as f64), + "FLOAT4" => row.try_get::(col).ok().map(|v| v as f64), + "FLOAT8" => row.try_get::(col).ok(), + "NUMERIC" => row + .try_get::(col) + .ok() + .and_then(|d| d.to_string().parse::().ok()), + _ => row.try_get::(col).ok().and_then(|s| s.trim().parse::().ok()), + } + .filter(|v| v.is_finite()) +} + +/// `Number(r.col) || 0`. +pub fn f64_or0(row: &PgRow, col: &str) -> f64 { + get_f64(row, col).unwrap_or(0.0) +} + +/// Valeur texte d'une colonne quel que soit son type (nombres et dates convertis). +pub fn get_string(row: &PgRow, col: &str) -> Option { + let idx = row.columns().iter().position(|c| c.name() == col)?; + match cell_to_json(row, idx) { + Value::Null => None, + Value::String(s) => Some(s), + Value::Number(n) => { + // 12.0 → "12", comme String(12) en JS. + let f = n.as_f64().unwrap_or_default(); + if f.fract() == 0.0 && f.abs() < 1e15 { + Some(format!("{}", f as i64)) + } else { + Some(n.to_string()) + } + } + other => Some(other.to_string()), + } +} + +/// `String(r.col ?? "")`. +pub fn string_or_empty(row: &PgRow, col: &str) -> String { + get_string(row, col).unwrap_or_default() +} diff --git a/server/src/domain.rs b/server/src/domain.rs new file mode 100644 index 0000000..da49d19 --- /dev/null +++ b/server/src/domain.rs @@ -0,0 +1,52 @@ +//! Référentiels partagés : magasins et gammes (miroir de `src/lib/magasins.ts` +//! et `src/lib/gammes.ts` côté interface). + +pub struct Magasin { + /// Code FF du site (clé de toutes les requêtes). + pub code: &'static str, + /// Nom complet. + pub nom: &'static str, + /// Nom court. + pub court: &'static str, +} + +pub const MAGASINS: [Magasin; 2] = [ + Magasin { code: "292", nom: "Frouard (Nancy)", court: "Frouard" }, + Magasin { code: "579", nom: "Houdemont", court: "Houdemont" }, +]; + +/// Code utilisé par la Grille et l'API pour « tous nos magasins ». +pub const CODE_TOUS_MAGASINS: &str = "TOTAL"; +pub const LIBELLE_TOUS_MAGASINS: &str = "Nos 2 magasins"; + +/// Anciens codes encore présents dans certaines réponses de l'API FF. +fn alias(code: &str) -> &str { + match code { + "F01" => "292", + "F02" => "579", + c => c, + } +} + +pub fn trouver_magasin(code: &str) -> Option<&'static Magasin> { + let c = alias(code.trim()); + MAGASINS.iter().find(|m| m.code == c) +} + +/// Nom lisible d'un magasin. `TOTAL` (ou vide) → « Nos 2 magasins ». +pub fn nom_magasin(code: Option<&str>, court: bool) -> String { + match code { + None | Some("") | Some(CODE_TOUS_MAGASINS) => LIBELLE_TOUS_MAGASINS.to_string(), + Some(c) => match trouver_magasin(c) { + Some(m) => (if court { m.court } else { m.nom }).to_string(), + None => c.to_string(), + }, + } +} + +/// Codes de gamme valides. +pub const CODES_GAMME: [&str; 5] = ["A", "B", "C", "Y", "Z"]; + +pub fn est_gamme_valide(code: &str) -> bool { + CODES_GAMME.contains(&code) +} diff --git a/server/src/error.rs b/server/src/error.rs new file mode 100644 index 0000000..3e40518 --- /dev/null +++ b/server/src/error.rs @@ -0,0 +1,105 @@ +//! Erreurs HTTP communes. +//! +//! Les routes « internes » (appelées par l'interface) répondent `{ "error": "…" }` +//! avec le statut adéquat ; l'API publique `/api/v1` a sa propre enveloppe +//! (`crate::api_v1_response`). + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::{json, Value}; + +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub message: String, + /// Corps JSON complet à renvoyer à la place de `{ error }`. + pub body: Option, +} + +pub type AppResult = Result; + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into(), body: None } + } + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + pub fn unauthorized(message: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, message) + } + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + pub fn not_found(message: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, message) + } + pub fn internal(message: impl Into) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, message) + } + /// Réponse au corps libre (ex. `{ success: false, error }` avec un statut 500). + pub fn with_body(status: StatusCode, body: Value) -> Self { + let message = body.get("error").and_then(|v| v.as_str()).unwrap_or_default().to_string(); + Self { status, message, body: Some(body) } + } +} + +impl std::fmt::Display for AppError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} {}", self.status, self.message) + } +} + +impl std::error::Error for AppError {} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let body = self.body.unwrap_or_else(|| json!({ "error": self.message })); + (self.status, Json(body)).into_response() + } +} + +impl From for AppError { + fn from(e: anyhow::Error) -> Self { + // Une AppError enveloppée dans anyhow garde son statut. + match e.downcast::() { + Ok(app) => app, + Err(e) => { + tracing::error!("[error] {e:#}"); + Self::internal(format!("{e:#}")) + } + } + } +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + tracing::error!("[db] {e}"); + Self::internal(e.to_string()) + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + Self::bad_request(e.to_string()) + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + Self::internal(e.to_string()) + } +} + +impl From for AppError { + fn from(e: reqwest::Error) -> Self { + Self::internal(e.to_string()) + } +} + +/// Message d'une erreur quelconque tronqué à `n` caractères (pour les journaux). +pub fn short(e: &dyn std::fmt::Display, n: usize) -> String { + let s = e.to_string(); + s.chars().take(n).collect() +} diff --git a/server/src/ff/mod.rs b/server/src/ff/mod.rs new file mode 100644 index 0000000..d861561 --- /dev/null +++ b/server/src/ff/mod.rs @@ -0,0 +1,19 @@ +//! Module `ff` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/ff_cache.rs b/server/src/ff_cache.rs new file mode 100644 index 0000000..6741920 --- /dev/null +++ b/server/src/ff_cache.rs @@ -0,0 +1,156 @@ +//! Cache mémoire des lectures « FF » (base miroir et API FF Nancy). +//! +//! Les tables FF ne sont recopiées qu'une fois par nuit : recalculer à chaque +//! visite les mêmes agrégations lourdes coûtait plusieurs secondes par page. +//! - durée de vie bornée (`FF_CACHE_TTL_S`, 30 min par défaut) ; +//! - les appels simultanés pour la même clé partagent une seule requête ; +//! - une erreur n'est jamais mise en cache ; +//! - [`invalidate`] vide tout (fin de synchro nocturne). + +use futures::future::{BoxFuture, FutureExt, Shared}; +use parking_lot::Mutex; +use std::any::Any; +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock}; +use std::time::{Duration, Instant}; + +const MAX_ENTRIES: usize = 100; + +type AnyArc = Arc; +type Pending = Shared>>>; + +enum Entry { + Ready { value: AnyArc, expires_at: Instant, inserted: u64 }, + Pending(Pending), +} + +struct Store { + map: HashMap, + seq: u64, +} + +static STORE: LazyLock> = LazyLock::new(|| Mutex::new(Store { map: HashMap::new(), seq: 0 })); +static GENERATION: AtomicU64 = AtomicU64::new(0); + +pub fn default_ttl() -> Duration { + let s = std::env::var("FF_CACHE_TTL_S").ok().and_then(|v| v.parse::().ok()).filter(|v| *v > 0).unwrap_or(30 * 60); + Duration::from_secs(s) +} + +/// Options d'une lecture mise en cache. +pub struct CacheOpts { + /// Durée de vie propre à cette clé. + pub ttl: Option, + /// Ne garder le résultat que s'il passe ce test (ex. liste non vide : une + /// lecture qui avale ses erreurs ne doit pas figer une panne passagère). + pub cache_if: Option bool>, +} + +impl Default for CacheOpts { + fn default() -> Self { + Self { ttl: None, cache_if: None } + } +} + +/// Lecture mise en cache sous `key`. +pub async fn cached(key: &str, opts: CacheOpts, load: F) -> anyhow::Result +where + T: Clone + Send + Sync + 'static, + F: FnOnce() -> Fut, + Fut: Future> + Send + 'static, +{ + let pending = { + let mut store = STORE.lock(); + match store.map.get(key) { + Some(Entry::Ready { value, expires_at, .. }) if *expires_at > Instant::now() => { + if let Some(v) = value.downcast_ref::() { + return Ok(v.clone()); + } + None + } + Some(Entry::Pending(p)) => Some(p.clone()), + _ => None, + } + }; + + let pending = match pending { + Some(p) => p, + None => { + let generation = GENERATION.load(Ordering::SeqCst); + let key_owned = key.to_string(); + let ttl = opts.ttl.unwrap_or_else(default_ttl); + let cache_if = opts.cache_if; + let fut = load(); + let shared: Pending = async move { + let res = fut.await; + let mut store = STORE.lock(); + match res { + Ok(value) => { + let keep = cache_if.map(|f| f(&value)).unwrap_or(true); + let any: AnyArc = Arc::new(value); + if generation != GENERATION.load(Ordering::SeqCst) { + // Invalidation pendant le chargement : résultat rendu mais pas gardé. + } else if keep { + store.seq += 1; + let inserted = store.seq; + store.map.insert( + key_owned, + Entry::Ready { value: any.clone(), expires_at: Instant::now() + ttl, inserted }, + ); + evict(&mut store); + } else { + store.map.remove(&key_owned); + } + Ok(any) + } + Err(e) => { + if generation == GENERATION.load(Ordering::SeqCst) { + store.map.remove(&key_owned); + } + Err(Arc::new(format!("{e:#}"))) + } + } + } + .boxed() + .shared(); + STORE.lock().map.insert(key.to_string(), Entry::Pending(shared.clone())); + shared + } + }; + + match pending.await { + Ok(any) => any + .downcast_ref::() + .cloned() + .ok_or_else(|| anyhow::anyhow!("ff_cache: type inattendu pour la clé {key}")), + Err(msg) => Err(anyhow::anyhow!("{msg}")), + } +} + +fn evict(store: &mut Store) { + while store.map.len() > MAX_ENTRIES { + let oldest = store + .map + .iter() + .filter_map(|(k, e)| match e { + Entry::Ready { inserted, .. } => Some((k.clone(), *inserted)), + _ => None, + }) + .min_by_key(|(_, i)| *i) + .map(|(k, _)| k); + match oldest { + Some(k) => { + store.map.remove(&k); + } + None => break, + } + } +} + +/// Vide tout le cache FF (fin de synchro nocturne, données fraîches). +pub fn invalidate() { + GENERATION.fetch_add(1, Ordering::SeqCst); + STORE.lock().map.clear(); +} diff --git a/server/src/grid/mod.rs b/server/src/grid/mod.rs new file mode 100644 index 0000000..5687605 --- /dev/null +++ b/server/src/grid/mod.rs @@ -0,0 +1,19 @@ +//! Module `grid` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/logcapture.rs b/server/src/logcapture.rs new file mode 100644 index 0000000..be37e77 --- /dev/null +++ b/server/src/logcapture.rs @@ -0,0 +1,225 @@ +//! Capture des journaux serveur d'un job, pour téléchargement. +//! +//! Les extractions Qlik produisent des milliers de lignes ; la sortie standard +//! du conteneur les tronque. Tant qu'au moins une capture est ouverte, chaque +//! ligne journalisée (par n'importe quel code du processus) y est recopiée, puis +//! écrite sur disque au fil de l'eau (toutes les 2 s au plus). +//! +//! ⚠️ Deux jobs simultanés partagent les journaux du processus : chaque capture +//! ouverte reçoit TOUTES les lignes de la période. + +use chrono::Utc; +use parking_lot::Mutex; +use serde::Serialize; +use std::collections::HashMap; +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::PathBuf; +use std::sync::LazyLock; +use std::time::{Instant, SystemTime}; +use tracing::field::{Field, Visit}; +use tracing::{Event, Subscriber}; +use tracing_subscriber::layer::Context; +use tracing_subscriber::Layer; + +const MAX_LIGNES: usize = 200_000; +const MAX_LONGUEUR_LIGNE: usize = 8_000; +const MAX_FICHIERS: usize = 20; +const FLUSH_MS: u128 = 2_000; + +struct Capture { + lignes: Vec, + tronque: bool, + debut: Instant, + dernier_flush: Option, + ecrites: usize, +} + +static CAPTURES: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); + +fn log_dir() -> PathBuf { + std::env::temp_dir().join("collectflow-logs") +} + +fn nom_sur(id: &str) -> String { + id.chars().map(|c| if c.is_ascii_alphanumeric() || "_.-".contains(c) { c } else { '_' }).collect() +} + +fn fichier_pour(id: &str) -> PathBuf { + log_dir().join(format!("{}.log", nom_sur(id))) +} + +fn horodatage() -> String { + Utc::now().format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +/// Écrit sur disque les lignes pas encore écrites (synchrone, appelé sous verrou). +fn flush(id: &str, c: &mut Capture) { + let _ = std::fs::create_dir_all(log_dir()); + let total = c.lignes.len(); + if c.ecrites == 0 || total > c.ecrites { + let nouvelles = &c.lignes[c.ecrites..total]; + let mut texte = nouvelles.join("\n"); + texte.push('\n'); + let res = if c.ecrites == 0 { + std::fs::write(fichier_pour(id), texte) + } else { + std::fs::OpenOptions::new() + .append(true) + .open(fichier_pour(id)) + .and_then(|mut f| f.write_all(texte.as_bytes())) + }; + if res.is_ok() { + c.ecrites = total; + } + } + c.dernier_flush = Some(Instant::now()); +} + +/// Ajoute une ligne à toutes les captures ouvertes. +pub fn ajouter(ligne: &str) { + let mut captures = CAPTURES.lock(); + if captures.is_empty() { + return; + } + let texte = if ligne.len() > MAX_LONGUEUR_LIGNE { + let mut cut = MAX_LONGUEUR_LIGNE; + while !ligne.is_char_boundary(cut) { + cut -= 1; + } + format!("{} …[ligne tronquée]", &ligne[..cut]) + } else { + ligne.to_string() + }; + let prefixe = format!("[{}] ", horodatage()); + for (id, c) in captures.iter_mut() { + if c.lignes.len() >= MAX_LIGNES { + if !c.tronque { + c.tronque = true; + c.lignes.push(format!("{prefixe}[log-capture] limite de {MAX_LIGNES} lignes atteinte — suite non enregistrée")); + } + continue; + } + c.lignes.push(format!("{prefixe}{texte}")); + if c.dernier_flush.map(|t| t.elapsed().as_millis() >= FLUSH_MS).unwrap_or(true) { + flush(id, c); + } + } +} + +fn purger() { + let Ok(rd) = std::fs::read_dir(log_dir()) else { return }; + let mut fichiers: Vec<(PathBuf, SystemTime)> = rd + .filter_map(|e| e.ok()) + .filter(|e| e.file_name().to_string_lossy().ends_with(".log")) + .map(|e| (e.path(), e.metadata().and_then(|m| m.modified()).unwrap_or(SystemTime::UNIX_EPOCH))) + .collect(); + if fichiers.len() <= MAX_FICHIERS { + return; + } + fichiers.sort_by(|a, b| b.1.cmp(&a.1)); + for (p, _) in fichiers.into_iter().skip(MAX_FICHIERS) { + let _ = std::fs::remove_file(p); + } +} + +/// Ouvre une capture : toutes les lignes journalisées à partir de maintenant y +/// sont recopiées jusqu'à [`stop_capture`]. +pub fn start_capture(id: &str, entete: Option<&str>) { + let mut c = Capture { lignes: Vec::new(), tronque: false, debut: Instant::now(), dernier_flush: None, ecrites: 0 }; + c.lignes.push(format!("[{}] [log-capture] début de capture {id}", horodatage())); + if let Some(e) = entete { + c.lignes.push(format!("[{}] [log-capture] {e}", horodatage())); + } + flush(id, &mut c); + CAPTURES.lock().insert(id.to_string(), c); + purger(); +} + +/// Ferme la capture et écrit son état final sur disque. +pub fn stop_capture(id: &str, pied: Option<&str>) { + let Some(mut c) = CAPTURES.lock().remove(id) else { return }; + let duree = c.debut.elapsed().as_secs(); + let n = c.lignes.len(); + c.lignes.push(format!("[{}] [log-capture] fin de capture {id} — {n} lignes, {duree} s", horodatage())); + if let Some(p) = pied { + c.lignes.push(format!("[{}] [log-capture] {p}", horodatage())); + } + flush(id, &mut c); +} + +/// Contenu d'un journal : la capture en cours, sinon le fichier sur disque. +pub fn read_capture(id: &str) -> Option { + if let Some(c) = CAPTURES.lock().get(id) { + return Some(c.lignes.join("\n") + "\n"); + } + std::fs::read_to_string(fichier_pour(id)).ok() +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CaptureInfo { + pub id: String, + pub octets: u64, + pub modifie_le: String, + pub en_cours: bool, +} + +/// Journaux disponibles, du plus récent au plus ancien. +pub fn list_captures() -> Vec { + let en_cours: Vec = CAPTURES.lock().keys().cloned().collect(); + let mut out = Vec::new(); + if let Ok(rd) = std::fs::read_dir(log_dir()) { + for e in rd.filter_map(|e| e.ok()) { + let nom = e.file_name().to_string_lossy().to_string(); + let Some(id) = nom.strip_suffix(".log") else { continue }; + let Ok(meta) = e.metadata() else { continue }; + let modifie: chrono::DateTime = meta.modified().map(Into::into).unwrap_or_else(|_| Utc::now()); + out.push(CaptureInfo { + id: id.to_string(), + octets: meta.len(), + modifie_le: modifie.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(), + en_cours: en_cours.iter().any(|x| nom_sur(x) == id), + }); + } + } + out.sort_by(|a, b| b.modifie_le.cmp(&a.modifie_le)); + out +} + +// --------------------------------------------------------------------------- +// Couche `tracing` qui alimente les captures +// --------------------------------------------------------------------------- + +pub struct CaptureLayer; + +struct MessageVisitor(String); + +impl Visit for MessageVisitor { + fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + let _ = write!(self.0, "{value:?}"); + } else { + let _ = write!(self.0, " {}={value:?}", field.name()); + } + } + fn record_str(&mut self, field: &Field, value: &str) { + if field.name() == "message" { + self.0.push_str(value); + } else { + let _ = write!(self.0, " {}={value}", field.name()); + } + } +} + +impl Layer for CaptureLayer { + fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) { + // Aucune capture ouverte (le cas courant) : on ne formate rien. + if CAPTURES.lock().is_empty() { + return; + } + let mut v = MessageVisitor(String::new()); + event.record(&mut v); + ajouter(&v.0); + } +} diff --git a/server/src/main.rs b/server/src/main.rs new file mode 100644 index 0000000..7e7f1cc --- /dev/null +++ b/server/src/main.rs @@ -0,0 +1,108 @@ +#![allow(dead_code)] +//! CollectFlow — serveur. +//! +//! Un binaire unique : API JSON (interface + `/api/v1` publique), accès à la +//! base PostgreSQL (tables de l'application et recopie FF Nancy), extraction +//! Qlik Sense, synchronisation nocturne, et service de l'interface compilée. + +mod admin; +mod api_v1_response; +mod auth; +mod config; +mod db; +mod domain; +mod error; +mod ff; +mod ff_cache; +mod grid; +mod logcapture; +mod migrations; +mod produits; +mod qlik; +mod routes; +mod rpc; +mod spa; + +use axum::extract::DefaultBodyLimit; +use axum::Router; +use rpc::Handler; +use tower_http::compression::CompressionLayer; +use tracing_subscriber::layer::SubscriberExt; +use tracing_subscriber::util::SubscriberInitExt; +use tracing_subscriber::{EnvFilter, Layer}; + +/// Toutes les actions déclarées par les modules. +pub fn modules_actions() -> Vec<(&'static str, Handler)> { + [ff::actions(), qlik::actions(), grid::actions(), produits::actions(), admin::actions(), routes::actions()].concat() +} + +/// Toutes les pages déclarées par les modules. +pub fn modules_pages() -> Vec<(&'static str, Handler)> { + [ff::pages(), qlik::pages(), grid::pages(), produits::pages(), admin::pages(), routes::pages()].concat() +} + +fn init_tracing() { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,sqlx=warn,chromiumoxide=warn,tungstenite=warn,hyper=warn")); + let fmt = tracing_subscriber::fmt::layer().with_target(false).with_ansi(std::io::IsTerminal::is_terminal(&std::io::stdout())).with_filter(filter); + tracing_subscriber::registry().with(fmt).with(logcapture::CaptureLayer).init(); +} + +fn app() -> Router { + Router::new() + .merge(auth::router()) + .merge(rpc::router()) + .merge(ff::router()) + .merge(qlik::router()) + .merge(grid::router()) + .merge(produits::router()) + .merge(admin::router()) + .merge(routes::router()) + .merge(spa::router()) + // Les instantanés de la Grille peuvent peser plusieurs Mo. + .layer(DefaultBodyLimit::max(64 * 1024 * 1024)) + .layer(CompressionLayer::new()) +} + +async fn shutdown_signal() { + let ctrl_c = async { + let _ = tokio::signal::ctrl_c().await; + }; + #[cfg(unix)] + let terminate = async { + if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + s.recv().await; + } + }; + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + tokio::select! { + _ = ctrl_c => {}, + _ = terminate => {}, + } + tracing::info!("Arrêt demandé."); +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + init_tracing(); + tracing::info!("CollectFlow {} — démarrage", env!("CARGO_PKG_VERSION")); + let _ = std::fs::create_dir_all(config::data_dir()); + + migrations::run().await; + tokio::spawn(async { + auth::ensure_admin_exists().await; + }); + admin::start_background_tasks(); + + let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(5643); + let host = std::env::var("HOSTNAME").ok().filter(|h| !h.is_empty()).unwrap_or_else(|| "0.0.0.0".into()); + let addr = format!("{host}:{port}"); + let listener = match tokio::net::TcpListener::bind(&addr).await { + Ok(l) => l, + // HOSTNAME est parfois le nom du conteneur : on retombe sur toutes les interfaces. + Err(_) => tokio::net::TcpListener::bind(("0.0.0.0", port)).await?, + }; + tracing::info!("Écoute sur http://{}", listener.local_addr()?); + axum::serve(listener, app()).with_graceful_shutdown(shutdown_signal()).await?; + Ok(()) +} diff --git a/server/src/migrations.rs b/server/src/migrations.rs new file mode 100644 index 0000000..b45835e --- /dev/null +++ b/server/src/migrations.rs @@ -0,0 +1,19 @@ +//! Schéma de la base, joué à chaque démarrage (idempotent). + +const INIT_SQL: &str = include_str!("../sql/init.sql"); + +pub async fn run() { + tracing::info!("[DB Init] Starting database migration & initialization..."); + let pool = match crate::db::pool() { + Ok(p) => p, + Err(_) => { + tracing::warn!("[DB Init] No DATABASE_URL found in env or config. Skipping."); + return; + } + }; + match sqlx::raw_sql(INIT_SQL).execute(&pool).await { + Ok(_) => tracing::info!("[DB Init] Initialization successful."), + // L'application reste utilisable (comptes de secours, Paramètres). + Err(e) => tracing::error!("[DB Init] Initialization failed: {e}"), + } +} diff --git a/server/src/produits/mod.rs b/server/src/produits/mod.rs new file mode 100644 index 0000000..05e86bf --- /dev/null +++ b/server/src/produits/mod.rs @@ -0,0 +1,19 @@ +//! Module `produits` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/qlik/mod.rs b/server/src/qlik/mod.rs new file mode 100644 index 0000000..c29a7c9 --- /dev/null +++ b/server/src/qlik/mod.rs @@ -0,0 +1,19 @@ +//! Module `qlik` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/routes/mod.rs b/server/src/routes/mod.rs new file mode 100644 index 0000000..c094245 --- /dev/null +++ b/server/src/routes/mod.rs @@ -0,0 +1,19 @@ +//! Module `routes` — à porter. + +use crate::rpc::Handler; +use axum::Router; + +/// Routes HTTP du module. +pub fn router() -> Router { + Router::new() +} + +/// Actions (`POST /api/actions/{nom}`) du module. +pub fn actions() -> Vec<(&'static str, Handler)> { + vec![] +} + +/// Données de pages (`GET /api/pages/{nom}`) du module. +pub fn pages() -> Vec<(&'static str, Handler)> { + vec![] +} diff --git a/server/src/rpc.rs b/server/src/rpc.rs new file mode 100644 index 0000000..40fd73b --- /dev/null +++ b/server/src/rpc.rs @@ -0,0 +1,125 @@ +//! Appels de l'interface vers le serveur hors routes REST classiques. +//! +//! - **Actions** — `POST /api/actions/{nom}`, corps = tableau JSON des arguments. +//! Remplacent les « server actions » Next.js : l'interface garde les mêmes +//! fonctions (`getUsers()`, `saveSnapshot(…)`…), devenues de simples appels +//! HTTP (cf. `src/lib/rpc.ts`). Une erreur renvoie `{ error }` avec un statut +//! 4xx/5xx, que le client relève comme une exception. +//! - **Pages** — `GET /api/pages/{nom}?…` : les données qu'une page serveur +//! Next.js calculait avant rendu (tableau de bord, hit-parade…), paramètres +//! d'URL compris. +//! +//! Chaque module déclare les siennes via `actions()` / `pages()`. + +use crate::auth::{session, Claims, UtilisateurCourant}; +use crate::error::{AppError, AppResult}; +use axum::extract::{Path, Query}; +use axum::http::{header, HeaderMap}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, post}; +use axum::{Json, Router}; +use futures::future::BoxFuture; +use serde::de::DeserializeOwned; +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use std::sync::LazyLock; + +/// Contexte d'un appel : jeton de session, paramètres d'URL, arguments. +#[derive(Clone, Default)] +pub struct Ctx { + pub token: Option, + pub query: HashMap, + pub args: Vec, +} + +impl Ctx { + /// Utilisateur connecté (401 sinon). + pub async fn session(&self) -> AppResult { + crate::auth::require_session(self.token.as_ref()).await + } + /// Administrateur connecté (401/403 sinon). + pub async fn admin(&self) -> AppResult { + crate::auth::require_admin(self.token.as_ref()).await + } + /// Argument n° `i` désérialisé (`null` s'il est absent). + pub fn arg(&self, i: usize) -> AppResult { + let v = self.args.get(i).cloned().unwrap_or(Value::Null); + serde_json::from_value(v).map_err(|e| AppError::bad_request(format!("Argument {i} invalide : {e}"))) + } + /// Argument brut n° `i`. + pub fn raw(&self, i: usize) -> Value { + self.args.get(i).cloned().unwrap_or(Value::Null) + } + /// Paramètre d'URL (page), vide → `None`. + pub fn q(&self, name: &str) -> Option<&str> { + self.query.get(name).map(|s| s.as_str()).filter(|s| !s.is_empty()) + } +} + +pub type Handler = fn(Ctx) -> BoxFuture<'static, AppResult>; + +/// Sérialise un résultat en JSON. +pub fn json(v: T) -> AppResult { + serde_json::to_value(v).map_err(|e| AppError::internal(e.to_string())) +} + +/// Transforme une fonction `async fn(Ctx) -> AppResult` en [`Handler`]. +#[macro_export] +macro_rules! handler { + ($f:path) => { + (|c: $crate::rpc::Ctx| -> ::futures::future::BoxFuture<'static, $crate::error::AppResult<::serde_json::Value>> { + Box::pin($f(c)) + }) as $crate::rpc::Handler + }; +} + +static ACTIONS: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + for (name, h) in crate::modules_actions() { + if m.insert(name, h).is_some() { + panic!("action en double : {name}"); + } + } + m +}); + +static PAGES: LazyLock> = LazyLock::new(|| { + let mut m = HashMap::new(); + for (name, h) in crate::modules_pages() { + if m.insert(name, h).is_some() { + panic!("page en double : {name}"); + } + } + m +}); + +fn no_store(v: Value) -> Response { + ([(header::CACHE_CONTROL, "no-store")], Json(v)).into_response() +} + +async fn call_action(Path(name): Path, headers: HeaderMap, body: Option>) -> AppResult { + let h = ACTIONS.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Action inconnue : {name}")))?; + let args = match body.map(|b| b.0) { + Some(Value::Array(a)) => a, + Some(Value::Null) | None => vec![], + Some(other) => vec![other], + }; + let ctx = Ctx { token: session::from_headers(&headers), query: HashMap::new(), args }; + Ok(no_store(h(ctx).await?)) +} + +async fn call_page(Path(name): Path, headers: HeaderMap, Query(query): Query>) -> AppResult { + let h = PAGES.get(name.as_str()).ok_or_else(|| AppError::not_found(format!("Page inconnue : {name}")))?; + let ctx = Ctx { token: session::from_headers(&headers), query, args: vec![] }; + Ok(no_store(h(ctx).await?)) +} + +pub fn router() -> Router { + // Force la construction des registres au démarrage (doublons détectés tôt). + LazyLock::force(&ACTIONS); + LazyLock::force(&PAGES); + Router::new() + .route("/api/actions/{name}", post(call_action)) + .route("/api/pages/{name}", get(call_page)) +} diff --git a/server/src/spa.rs b/server/src/spa.rs new file mode 100644 index 0000000..bc3d221 --- /dev/null +++ b/server/src/spa.rs @@ -0,0 +1,73 @@ +//! Service de l'interface (SPA React compilée par Vite) et garde des pages. +//! +//! Les fichiers existants du répertoire statique sont servis tels quels. Toute +//! autre URL hors `/api` renvoie `index.html` (routage côté navigateur), après +//! les mêmes contrôles que l'ancien middleware Next.js : +//! - non connecté → `/login` ; +//! - connecté sur `/login` ou `/` → `/dashboard` ; +//! - `/settings` et `/admin/*` réservés aux administrateurs. + +use crate::auth::session; +use axum::body::Body; +use axum::http::{header, HeaderValue, Request, StatusCode, Uri}; +use axum::response::{IntoResponse, Redirect, Response}; +use axum::Router; +use std::path::PathBuf; +use tower_http::services::ServeDir; +use tower_http::set_header::SetResponseHeaderLayer; + +pub fn static_dir() -> PathBuf { + std::env::var("STATIC_DIR").map(PathBuf::from).unwrap_or_else(|_| PathBuf::from("web")) +} + +async fn page(req: Request) -> Response { + let uri: Uri = req.uri().clone(); + let path = uri.path(); + if path.starts_with("/api/") || path == "/api" { + return (StatusCode::NOT_FOUND, axum::Json(serde_json::json!({ "error": "Route inconnue" }))).into_response(); + } + + let claims = session::from_headers(req.headers()); + let is_login = path == "/login"; + let is_public = is_login || path.starts_with("/public/"); + + let user = match claims.as_ref() { + Some(_) => crate::auth::require_session(claims.as_ref()).await.ok(), + None => None, + }; + + match &user { + None if !is_public => return Redirect::to("/login").into_response(), + Some(_) if is_public || path == "/" => return Redirect::to("/dashboard").into_response(), + Some(u) if (path.starts_with("/settings") || path.starts_with("/admin")) && !u.is_admin() => { + return Redirect::to("/dashboard").into_response() + } + _ => {} + } + + match tokio::fs::read(static_dir().join("index.html")).await { + Ok(html) => ( + [ + (header::CONTENT_TYPE, HeaderValue::from_static("text/html; charset=utf-8")), + (header::CACHE_CONTROL, HeaderValue::from_static("no-cache")), + ], + html, + ) + .into_response(), + Err(_) => (StatusCode::SERVICE_UNAVAILABLE, "Interface non compilée (index.html introuvable).").into_response(), + } +} + +pub fn router() -> Router { + let dir = static_dir(); + let assets = Router::new() + .nest_service("/assets", ServeDir::new(dir.join("assets"))) + .layer(SetResponseHeaderLayer::overriding( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + )); + let files = ServeDir::new(&dir) + .append_index_html_on_directories(false) + .fallback(tower::service_fn(|req: Request| async move { Ok::<_, std::convert::Infallible>(page(req).await) })); + assets.fallback_service(files) +}