From 16abe754afc4ed4ba7c75574d945ea528493cac4 Mon Sep 17 00:00:00 2001 From: AB Date: Thu, 21 May 2026 14:22:33 +0300 Subject: [PATCH] Initial commit: web-app boilerplate with auth, OIDC/SSO, admin panel, i18n MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rust (cot framework) + PostgreSQL boilerplate providing: - Session-based auth with Admin/User roles - OIDC/SSO login with PKCE, group-to-role mapping, auto-provisioning - Admin panel: user management, settings, debug/config inspector - 3-tier config system (compiled default → DB → FURU_* env vars) - i18n (English + Russian) with compile-time translations macro - JSON API skeleton (GET /api/me) - DB-backed sessions (survive server restarts) Co-Authored-By: Claude --- .gitignore | 2 + Cargo.lock | 4095 ++++++++++++++++++++++++++++++++ Cargo.toml | 16 + README.md | 193 ++ build.rs | 17 + src/admin/mod.rs | 252 ++ src/admin/views.rs | 431 ++++ src/api/mod.rs | 68 + src/auth.rs | 138 ++ src/config.rs | 372 +++ src/i18n/mod.rs | 226 ++ src/i18n/phrases.rs | 95 + src/main.rs | 350 +++ src/oidc.rs | 578 +++++ src/user.rs | 426 ++++ templates/admin/debug.html | 82 + templates/admin/index.html | 8 + templates/admin/layout.html | 57 + templates/admin/settings.html | 73 + templates/admin/setup.html | 38 + templates/admin/user_form.html | 40 + templates/admin/users.html | 37 + templates/base.html | 14 + templates/login.html | 56 + 24 files changed, 7664 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 build.rs create mode 100644 src/admin/mod.rs create mode 100644 src/admin/views.rs create mode 100644 src/api/mod.rs create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/i18n/mod.rs create mode 100644 src/i18n/phrases.rs create mode 100644 src/main.rs create mode 100644 src/oidc.rs create mode 100644 src/user.rs create mode 100644 templates/admin/debug.html create mode 100644 templates/admin/index.html create mode 100644 templates/admin/layout.html create mode 100644 templates/admin/settings.html create mode 100644 templates/admin/setup.html create mode 100644 templates/admin/user_form.html create mode 100644 templates/admin/users.html create mode 100644 templates/base.html create mode 100644 templates/login.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..56e92e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/nul diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7bb9cf2 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4095 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "askama" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc" +dependencies = [ + "askama_macros", + "itoa", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "askama_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738" +dependencies = [ + "askama_parser", + "memchr", + "proc-macro2", + "quote", + "rustc-hash", + "syn", +] + +[[package]] +name = "askama_macros" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a" +dependencies = [ + "askama_derive", +] + +[[package]] +name = "askama_parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da" +dependencies = [ + "rustc-hash", + "unicode-ident", + "winnow", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", +] + +[[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", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[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 = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf 0.12.1", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "codemap" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cot" +version = "0.6.0" +dependencies = [ + "ahash", + "askama", + "async-trait", + "axum", + "blake3", + "bytes", + "chrono", + "chrono-tz", + "clap", + "cot_core", + "cot_macros", + "derive_builder", + "derive_more", + "email_address", + "form_urlencoded", + "futures-core", + "futures-util", + "grass_compiler", + "hex", + "http", + "http-body-util", + "humantime", + "indexmap 2.14.0", + "mime", + "mime_guess", + "multer", + "password-auth", + "pin-project-lite", + "sea-query", + "sea-query-binder", + "serde", + "serde_json", + "sqlx", + "subtle", + "thiserror 2.0.18", + "time", + "tokio", + "toml", + "tower", + "tower-sessions", + "tracing", + "url", +] + +[[package]] +name = "cot_codegen" +version = "0.6.0" +dependencies = [ + "darling 0.23.0", + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "cot_core" +version = "0.6.0" +dependencies = [ + "askama", + "axum", + "backtrace", + "bytes", + "cot_macros", + "derive_more", + "form_urlencoded", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "indexmap 2.14.0", + "serde", + "serde_html_form", + "serde_json", + "serde_path_to_error", + "sync_wrapper", + "thiserror 2.0.18", + "tower", + "tower-sessions", +] + +[[package]] +name = "cot_macros" +version = "0.6.0" +dependencies = [ + "askama_derive", + "cot_codegen", + "darling 0.23.0", + "heck", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +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 = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn", +] + +[[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" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[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 = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "furumusic" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "cot", + "openidconnect", + "reqwest", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "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", + "zeroize", +] + +[[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", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "grass_compiler" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9e3df7f0222ce5184154973d247c591d9aadc28ce7a73c6cd31100c9facff6" +dependencies = [ + "codemap", + "indexmap 2.14.0", + "lasso", + "once_cell", + "phf 0.11.3", +] + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[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 = "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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[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 = "humantime" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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 = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inherent" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c727f80bfa4a6c6e2508d2f05b6f4bfce242030bd88ed15ae5331c5b5d30fba7" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lasso" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e14eda50a3494b3bf7b9ce51c52434a761e383d7238ce1dd5dcec2fbc13e9fb" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.7.5", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[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.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[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.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[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.6", + "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.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "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 = "oauth2" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" +dependencies = [ + "base64 0.22.1", + "chrono", + "getrandom 0.2.17", + "http", + "rand 0.8.6", + "reqwest", + "serde", + "serde_json", + "serde_path_to_error", + "sha2", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openidconnect" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2" +dependencies = [ + "base64 0.21.7", + "chrono", + "dyn-clone", + "ed25519-dalek", + "hmac", + "http", + "itertools", + "log", + "oauth2", + "p256", + "p384", + "rand 0.8.6", + "rsa", + "serde", + "serde-value", + "serde_json", + "serde_path_to_error", + "serde_plain", + "serde_with", + "sha2", + "subtle", + "thiserror 1.0.69", + "url", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "parking" +version = "2.2.1" +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-auth" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a2a4764cc1f8d961d802af27193c6f4f0124bd0e76e8393cf818e18880f0524" +dependencies = [ + "argon2", + "getrandom 0.2.17", + "password-hash", + "rand_core 0.6.4", +] + +[[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 = "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 = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[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 = "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.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4666a1a60d8412eab19d94f6d13dcc9cea0a5ef4fdf6a5db306537413c661b1b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[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", + "futures-core", + "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", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[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 = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "chrono", + "inherent", +] + +[[package]] +name = "sea-query-binder" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0019f47430f7995af63deda77e238c17323359af241233ec768aba1faea7608" +dependencies = [ + "chrono", + "sea-query", + "sqlx", +] + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_html_form" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0946d52b4b7e28823148aebbeceb901012c595ad737920d504fa8634bb099e6f" +dependencies = [ + "form_urlencoded", + "indexmap 2.14.0", + "serde_core", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "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_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "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 = "serde_with" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +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 = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[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 = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +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 2.14.0", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[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", +] + +[[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", + "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.6", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "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.6", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "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 2.0.18", + "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 = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +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.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +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", +] + +[[package]] +name = "tower-cookies" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36" +dependencies = [ + "axum-core", + "cookie", + "futures-util", + "http", + "parking_lot", + "pin-project-lite", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "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 = "tower-sessions" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "518dca34b74a17cadfcee06e616a09d2bd0c3984eff1769e1e76d58df978fc78" +dependencies = [ + "async-trait", + "http", + "time", + "tokio", + "tower-cookies", + "tower-layer", + "tower-service", + "tower-sessions-core", + "tower-sessions-memory-store", + "tracing", +] + +[[package]] +name = "tower-sessions-core" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "568531ec3dfcf3ffe493de1958ae5662a0284ac5d767476ecdb6a34ff8c6b06c" +dependencies = [ + "async-trait", + "base64 0.22.1", + "futures", + "http", + "parking_lot", + "rand 0.9.4", + "serde", + "serde_json", + "thiserror 2.0.18", + "time", + "tokio", + "tracing", +] + +[[package]] +name = "tower-sessions-memory-store" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "713fabf882b6560a831e2bbed6204048b35bdd60e50bbb722902c74f8df33460" +dependencies = [ + "async-trait", + "time", + "tokio", + "tower-sessions-core", +] + +[[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", +] + +[[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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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 = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +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.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +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.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +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 = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +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 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +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_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +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_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +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 = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..dd69caa --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "furumusic" +version = "0.1.0" +edition = "2024" +description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" + +[dependencies] +cot = { path = "../cot/cot", features = ["postgres", "json"] } +serde = { version = "1", features = ["derive"] } +openidconnect = "4.0" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +tokio = { version = "1", features = ["sync"] } +base64 = "0.22" +serde_json = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..69ce50e --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# furumusic + +Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL. + +Built with Rust ([cot](https://cot.rs) framework). + +## Quick start + +```bash +export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic +cargo run +# Open http://localhost:8000/admin/setup to create the first admin account +``` + +## Project structure + +``` +Cargo.toml Project manifest and dependencies +build.rs Captures rustc version + target at compile time +src/ + main.rs Entrypoint; HTTP router, login/logout handlers, tracing init + config.rs 3-tier config system (default → DB → env); FURU_* env vars + auth.rs Session auth, Role enum (Admin/User), login/logout/guards + user.rs User + OidcLink DB models, CRUD, password hashing, migrations + oidc.rs OIDC/SSO flow: discovery, PKCE, token exchange, user provisioning + i18n/ + mod.rs Language resolution (cookie → Accept-Language → default), extractor + phrases.rs All UI strings in English and Russian (translations! macro) + api/ + mod.rs JSON API endpoints (mounted at /api), session-based auth + admin/ + mod.rs Admin sub-app router: dashboard, settings, users, debug, setup + views.rs Admin page handlers and templates +templates/ + base.html Root HTML layout with lang/title blocks + login.html Login page (password + optional SSO button) + admin/ + layout.html Admin sidebar/nav wrapper + index.html Admin dashboard + debug.html Build info + config table (with secret redaction) + settings.html OIDC and auth settings form + setup.html First-run admin account creation + users.html User list + user_form.html User create/edit form +``` + +## Architecture + +### Config system (`src/config.rs`) + +Every setting lives in `AppConfig` and is resolved in three layers: + +1. **Compiled default** — `AppConfig::default()` +2. **Database override** — rows in the `furumusic__config_entry` table +3. **Environment variable** — `FURU_` (highest priority) + +`ConfigSources` tracks where each field's effective value came from (shown in the admin debug page). + +**To add a new config field:** + +1. Add the field to `AppConfig` struct +2. Set its default in `AppConfig::default()` +3. Add the field to `ConfigSources` struct and its `Default` impl +4. Add it to the `impl_env_overrides!(…)` invocation +5. Add an `apply_db_field!()` call in `apply_db_overrides` +6. Add an `entry!()` line in `admin/views.rs → config_display_entries()` + +### Auth (`src/auth.rs`) + +Session-based authentication with two roles: + +- **`Role::Admin`** — full access to admin panel +- **`Role::User`** — standard user + +Key functions: +- `login(session, user_id)` — sets session, cycles session ID +- `logout(session)` — flushes session +- `get_session_user(session, db)` — returns `AuthenticatedUser` if active +- `require_admin_or_redirect(session, db)` — guard that returns 403 or redirects to `/login` + +### OIDC/SSO (`src/oidc.rs`) + +Full OpenID Connect authorization code flow with PKCE: + +1. `GET /auth/oidc/start` — discovers provider, builds auth URL, stores CSRF/nonce/PKCE in session, redirects to IdP +2. `GET /auth/oidc/callback` — validates CSRF, exchanges code for tokens, verifies ID token, provisions user + +Provider metadata is cached for 1 hour and invalidated when OIDC config changes. + +**Group-to-role mapping:** The `oidc_admin_groups` config field lists OIDC group names (comma-separated) that grant the admin role. Groups are extracted from the `groups` claim in the ID token JWT payload. + +**User provisioning order:** +1. Find existing `OidcLink` by issuer+sub → update claims, update role +2. Find existing `User` by email → create OidcLink, update role +3. Create new user (no password) + OidcLink + +Stale links (pointing to deleted users) are cleaned up automatically. + +### User model (`src/user.rs`) + +Two database models: + +- **`User`** — id, username (unique), password (optional for OIDC-only), email, display_name, avatar_url, role, is_active +- **`OidcLink`** — id, user_id, issuer, sub, email, name, avatar_url; unique index on (issuer, sub) + +Migrations: M0003 (User table), M0004 (OidcLink table), M0005 (OidcLink indexes). + +### i18n (`src/i18n/`) + +Compile-time bilingual UI (English + Russian). + +- `translations!` macro in `phrases.rs` generates a `Translations` struct with static `EN` and `RU` instances +- Language resolution: `furu_lang` cookie → `Accept-Language` header → English default +- `I18n` is a cot request extractor — handlers receive it automatically +- `set_lang` endpoint (`/set-lang?lang=ru&next=/`) sets the cookie + +### API (`src/api/`) + +JSON API mounted at `/api`. Uses the same session cookie as HTML pages — works automatically for same-origin frontend requests (no CORS, no tokens needed). + +Helpers in `api/mod.rs`: +- `json_ok(value)` — 200 with `application/json` +- `json_error(status, message)` — error response as `{"error": "..."}` + +| Route | Method | Description | +|-------|--------|-------------| +| `/api/me` | GET | Current user (id, name, role) or 401 | + +To add a new API endpoint: write an async handler returning `cot::Result`, use `json_ok`/`json_error`, add a `Route` in `ApiApp::router()`. + +### Admin panel (`src/admin/`) + +Mounted at `/admin`. All routes (except `/admin/setup`) require `Role::Admin`. + +| Route | Purpose | +|-------|---------| +| `/admin/setup` | First-run: create initial admin (only works when zero users exist) | +| `/admin/` | Dashboard | +| `/admin/debug` | Build info, config values with sources, DB connectivity | +| `/admin/settings` | OIDC config, auth toggles (saved to DB config table) | +| `/admin/users` | User list | +| `/admin/users/new` | Create user | +| `/admin/users/{id}/edit` | Edit user | +| `/admin/users/{id}/delete` | Delete user (POST) | + +## How to extend + +### 1. Add a config field + +See [Config system](#config-system-srcconfigrs) above — 6 locations to update. + +### 2. Add a database model + +1. Define a struct with `#[cot::db::model]` in a new or existing file +2. Write a migration struct implementing `cot::db::migrations::Migration` +3. Register the migration in the `AdminApp::migrations()` method in `src/admin/mod.rs` + +### 3. Add a page + +1. Create a template in `templates/` +2. Write a handler function that returns `Html` +3. Add a `Route::with_handler_and_name(…)` in the appropriate `router()` method +4. If admin-only, wrap with `require_admin_or_redirect` + +### 4. Add a translation + +Add a line to the `translations!` macro in `src/i18n/phrases.rs`: + +```rust +my_key: "English text", "Русский текст"; +``` + +Access it in handlers/templates as `i18n.t.my_key` (or `t.my_key` in templates). + +### 5. Add an API endpoint + +Same as adding a page, but return a JSON response instead of `Html`. The `json` feature is enabled in Cargo.toml. + +## Environment variables + +All prefixed with `FURU_`. Priority: env var > DB override > compiled default. + +| Variable | Description | Default | +|----------|-------------|---------| +| `FURU_DATABASE_URL` | PostgreSQL connection URL | *(empty — required)* | +| `FURU_LOG_LEVEL` | Tracing filter (e.g. `info`, `debug`, `warn,furumusic=trace`) | `info` | +| `FURU_AUTH_PASSWORD_ENABLED` | Enable password login | `true` | +| `FURU_AUTH_SSO_ENABLED` | Enable SSO/OIDC login | `false` | +| `FURU_OIDC_ISSUER` | OIDC issuer URL | *(empty)* | +| `FURU_OIDC_CLIENT_ID` | OIDC client ID | *(empty)* | +| `FURU_OIDC_CLIENT_SECRET` | OIDC client secret | *(empty)* | +| `FURU_OIDC_BUTTON_TEXT` | SSO button label | `Sign in with SSO` | +| `FURU_OIDC_ADMIN_GROUPS` | Comma-separated OIDC groups that grant admin | *(empty)* | diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..742978f --- /dev/null +++ b/build.rs @@ -0,0 +1,17 @@ +fn main() { + println!( + "cargo::rustc-env=FURU_TARGET={}", + std::env::var("TARGET").unwrap() + ); + + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let output = std::process::Command::new(rustc) + .arg("--version") + .output() + .expect("failed to run rustc --version"); + let version = String::from_utf8_lossy(&output.stdout); + println!( + "cargo::rustc-env=FURU_RUSTC_VERSION={}", + version.trim() + ); +} diff --git a/src/admin/mod.rs b/src/admin/mod.rs new file mode 100644 index 0000000..b511770 --- /dev/null +++ b/src/admin/mod.rs @@ -0,0 +1,252 @@ +pub mod views; + +use std::sync::Arc; + +use cot::db::Database; +use cot::db::migrations::SyncDynMigration; +use cot::request::extractors::{Path, RequestForm, UrlQuery}; +use cot::response::IntoResponse; +use cot::router::method::get; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::App; +use serde::Deserialize; + +use crate::auth; +use crate::config::AppConfig; +use crate::i18n::I18n; +use crate::user::User; +use views::{OidcSettingsForm, SetupForm, UserForm}; + +/// Build-time metadata baked in by `build.rs` and Cargo env vars. +#[derive(Debug)] +pub struct BuildInfo { + pub pkg_name: &'static str, + pub pkg_version: &'static str, + pub profile: &'static str, + pub target: &'static str, + pub rustc_version: &'static str, +} + +pub static BUILD_INFO: BuildInfo = BuildInfo { + pkg_name: env!("CARGO_PKG_NAME"), + pkg_version: env!("CARGO_PKG_VERSION"), + profile: if cfg!(debug_assertions) { + "debug" + } else { + "release" + }, + target: env!("FURU_TARGET"), + rustc_version: env!("FURU_RUSTC_VERSION"), +}; + +pub struct AdminApp { + config: Arc, +} + +impl AdminApp { + pub fn new(config: Arc) -> Self { + Self { config } + } +} + +#[derive(Debug, Deserialize)] +struct SettingsQuery { + saved: Option, +} + +#[derive(Debug, Deserialize)] +struct PathId { + id: i64, +} + +impl App for AdminApp { + fn name(&self) -> &'static str { + "admin" + } + + fn router(&self) -> Router { + Router::with_urls([ + // -- Setup (first-run, no auth required) -------------------------- + Route::with_handler_and_name( + "/setup", + get(|i18n: I18n, db: Database| async move { + let count = User::count_all(&db).await.unwrap_or(1); + if count > 0 { + return Ok(auth::redirect("/admin/")); + } + views::setup_page(i18n, String::new()) + .await? + .into_response() + }) + .post( + |i18n: I18n, db: Database, session: Session, + form: RequestForm| async move { + let count = User::count_all(&db).await.unwrap_or(1); + if count > 0 { + return Ok(auth::redirect("/admin/")); + } + views::setup_submit(i18n, &db, &session, form).await + }, + ), + "admin_setup", + ), + // -- Dashboard ---------------------------------------------------- + Route::with_handler_and_name( + "/", + |session: Session, db: Database, i18n: I18n| async move { + // First-run redirect + let count = User::count_all(&db).await.unwrap_or(0); + if count == 0 { + return Ok(auth::redirect("/admin/setup")); + } + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::admin_index(admin, i18n).await?.into_response() + }, + "admin_index", + ), + // -- Debug -------------------------------------------------------- + Route::with_handler_and_name( + "/debug", + { + let config = Arc::clone(&self.config); + move |session: Session, db: Database, i18n: I18n| { + let config = Arc::clone(&config); + async move { + let admin = + match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::debug_handler(admin, i18n, &config, &db) + .await? + .into_response() + } + } + }, + "admin_debug", + ), + // -- Settings ----------------------------------------------------- + Route::with_handler_and_name( + "/settings", + get({ + let config = Arc::clone(&self.config); + move |session: Session, db: Database, i18n: I18n, + query: UrlQuery| { + let config = Arc::clone(&config); + async move { + let admin = + match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + let saved = query.0.saved.as_deref() == Some("1"); + views::settings_handler(admin, i18n, &config, &db, saved) + .await? + .into_response() + } + } + }) + .post({ + let config = Arc::clone(&self.config); + move |session: Session, db: Database, i18n: I18n, + form: RequestForm| { + let config = Arc::clone(&config); + async move { + let admin = + match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::settings_submit(admin, i18n, &config, &db, form).await + } + } + }), + "admin_settings", + ), + // -- Users -------------------------------------------------------- + Route::with_handler_and_name( + "/users", + |session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_list(admin, i18n, &db).await?.into_response() + }, + "admin_users", + ), + Route::with_handler_and_name( + "/users/new", + get(|session: Session, db: Database, i18n: I18n| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_new(admin, i18n).await?.into_response() + }) + .post( + |session: Session, db: Database, form: RequestForm| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_create(admin, &db, form).await + }, + ), + "admin_users_new", + ), + Route::with_handler_and_name( + "/users/{id}/edit", + get( + |session: Session, db: Database, i18n: I18n, + path: Path| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_edit(admin, i18n, &db, path.0.id) + .await? + .into_response() + }, + ) + .post( + |session: Session, db: Database, path: Path, + form: RequestForm| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_update(admin, &db, path.0.id, form).await + }, + ), + "admin_users_edit", + ), + Route::with_handler_and_name( + "/users/{id}/delete", + cot::router::method::post( + |session: Session, db: Database, path: Path| async move { + let admin = match auth::require_admin_or_redirect(&session, &db).await { + Ok(u) => u, + Err(resp) => return Ok(resp), + }; + views::users_delete(admin, &db, path.0.id).await + }, + ), + "admin_users_delete", + ), + ]) + } + + fn migrations(&self) -> Vec> { + let mut all = + cot::db::migrations::wrap_migrations(crate::config::db_migrations::MIGRATIONS); + all.extend(cot::db::migrations::wrap_migrations( + crate::user::db_migrations::MIGRATIONS, + )); + all + } +} diff --git a/src/admin/views.rs b/src/admin/views.rs new file mode 100644 index 0000000..245dbdc --- /dev/null +++ b/src/admin/views.rs @@ -0,0 +1,431 @@ +use cot::db::{Database, Model}; +use cot::form::{Form, FormResult}; +use cot::html::Html; +use cot::request::extractors::RequestForm; +use cot::response::IntoResponse; +use cot::session::Session; +use cot::{Body, Template}; + +use crate::auth::{self, AuthenticatedUser}; +use crate::config::{AppConfig, ConfigEntry, ConfigSources}; +use crate::i18n::{I18n, Translations}; +use crate::user::User; +use super::BUILD_INFO; + +/// A config entry for display in the unified debug table. +#[derive(Debug)] +pub struct ConfigDisplayEntry { + pub key: String, + pub env_var: String, + pub value: String, + pub default_value: String, + pub source: &'static str, +} + +/// Secret field names that should be redacted in the debug view. +const SECRET_FIELDS: &[&str] = &[ + "database_url", + "oidc_client_secret", +]; + +fn is_secret(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + SECRET_FIELDS.iter().any(|s| lower.contains(s)) + || lower.contains("secret") + || lower.contains("token") +} + +fn redact(value: &str) -> String { + if value.is_empty() { + String::new() + } else { + "********".into() + } +} + +#[derive(Debug, Template)] +#[template(path = "admin/debug.html")] +struct DebugTemplate<'a> { + t: &'static Translations, + user_name: String, + user_role: String, + build: &'a super::BuildInfo, + config_entries: Vec, + db_status: String, +} + +fn config_display_entries(config: &AppConfig, sources: &ConfigSources) -> Vec { + let defaults = AppConfig::default(); + + macro_rules! entry { + ($field:ident, $value:expr, $default:expr) => { + { + let raw = $value; + let default_raw = $default; + let secret = is_secret(stringify!($field)); + let display = if secret { redact(&raw) } else { raw }; + let default_display = if secret { redact(&default_raw) } else { default_raw }; + ConfigDisplayEntry { + key: stringify!($field).into(), + env_var: format!("FURU_{}", stringify!($field).to_ascii_uppercase()), + value: display, + default_value: default_display, + source: sources.$field.code(), + } + } + }; + } + + vec![ + entry!(database_url, config.database_url.clone(), defaults.database_url.clone()), + entry!(oidc_issuer, config.oidc_issuer.clone(), defaults.oidc_issuer.clone()), + entry!(oidc_client_id, config.oidc_client_id.clone(), defaults.oidc_client_id.clone()), + entry!(oidc_client_secret, config.oidc_client_secret.clone(), defaults.oidc_client_secret.clone()), + entry!(log_level, config.log_level.clone(), defaults.log_level.clone()), + entry!(auth_password_enabled, config.auth_password_enabled.to_string(), defaults.auth_password_enabled.to_string()), + entry!(auth_sso_enabled, config.auth_sso_enabled.to_string(), defaults.auth_sso_enabled.to_string()), + entry!(oidc_button_text, config.oidc_button_text.clone(), defaults.oidc_button_text.clone()), + entry!(oidc_admin_groups, config.oidc_admin_groups.clone(), defaults.oidc_admin_groups.clone()), + ] +} + +pub async fn debug_handler( + admin: AuthenticatedUser, + i18n: I18n, + _startup_config: &AppConfig, + db: &Database, +) -> cot::Result { + let (config, sources) = AppConfig::load_with_db(db).await; + + let db_status = match db.raw("SELECT 1").await { + Ok(_) => i18n.t.debug_db_connected.to_owned(), + Err(e) => format!("{}: {e}", i18n.t.debug_db_error), + }; + + let template = DebugTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + build: &BUILD_INFO, + config_entries: config_display_entries(&config, &sources), + db_status, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Template)] +#[template(path = "admin/index.html")] +struct AdminIndexTemplate { + t: &'static Translations, + user_name: String, + user_role: String, +} + +pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { + let template = AdminIndexTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + }; + Ok(Html::new(template.render()?)) +} + +// --------------------------------------------------------------------------- +// Settings page +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "admin/settings.html")] +struct SettingsTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + saved: bool, + auth_password_enabled: bool, + auth_password_enabled_source: &'static str, + auth_sso_enabled: bool, + auth_sso_enabled_source: &'static str, + oidc_button_text: String, + oidc_button_text_source: &'static str, + oidc_issuer: String, + oidc_issuer_source: &'static str, + oidc_client_id: String, + oidc_client_id_source: &'static str, + oidc_client_secret: String, + oidc_client_secret_source: &'static str, + oidc_admin_groups: String, + oidc_admin_groups_source: &'static str, +} + +pub async fn settings_handler( + admin: AuthenticatedUser, + i18n: I18n, + _startup_config: &AppConfig, + db: &Database, + saved: bool, +) -> cot::Result { + let (config, sources) = AppConfig::load_with_db(db).await; + + let template = SettingsTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + saved, + auth_password_enabled: config.auth_password_enabled, + auth_password_enabled_source: sources.auth_password_enabled.code(), + auth_sso_enabled: config.auth_sso_enabled, + auth_sso_enabled_source: sources.auth_sso_enabled.code(), + oidc_button_text: config.oidc_button_text, + oidc_button_text_source: sources.oidc_button_text.code(), + oidc_issuer: config.oidc_issuer, + oidc_issuer_source: sources.oidc_issuer.code(), + oidc_client_id: config.oidc_client_id, + oidc_client_id_source: sources.oidc_client_id.code(), + oidc_client_secret: config.oidc_client_secret, + oidc_client_secret_source: sources.oidc_client_secret.code(), + oidc_admin_groups: config.oidc_admin_groups, + oidc_admin_groups_source: sources.oidc_admin_groups.code(), + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +pub struct OidcSettingsForm { + auth_password_enabled: Option, + auth_sso_enabled: Option, + oidc_button_text: String, + oidc_issuer: String, + oidc_client_id: String, + oidc_client_secret: String, + oidc_admin_groups: String, +} + +pub async fn settings_submit( + _admin: AuthenticatedUser, + _i18n: I18n, + _startup_config: &AppConfig, + db: &Database, + form: RequestForm, +) -> cot::Result> { + let RequestForm(result) = form; + match result { + FormResult::Ok(data) => { + let pw_enabled = if data.auth_password_enabled.is_some() { "true" } else { "false" }; + let sso_enabled = if data.auth_sso_enabled.is_some() { "true" } else { "false" }; + let fields: [(&str, &str); 7] = [ + ("auth_password_enabled", pw_enabled), + ("auth_sso_enabled", sso_enabled), + ("oidc_button_text", &data.oidc_button_text), + ("oidc_issuer", &data.oidc_issuer), + ("oidc_client_id", &data.oidc_client_id), + ("oidc_client_secret", &data.oidc_client_secret), + ("oidc_admin_groups", &data.oidc_admin_groups), + ]; + for (key, value) in fields { + let mut entry = ConfigEntry::new(key.to_owned(), value.to_owned()); + if let Err(e) = entry.save(db).await { + tracing::error!(key, error = %e, "failed to save config entry"); + return Err(e.into()); + } + } + + Ok(auth::redirect("/admin/settings?saved=1")) + } + FormResult::ValidationError(_ctx) => { + Ok(auth::redirect("/admin/settings")) + } + } +} + +// --------------------------------------------------------------------------- +// User management +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "admin/users.html")] +struct UsersTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + users: Vec, +} + +pub async fn users_list(admin: AuthenticatedUser, i18n: I18n, db: &Database) -> cot::Result { + let users = User::list_all(db).await.unwrap_or_default(); + let template = UsersTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + users, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Template)] +#[template(path = "admin/user_form.html")] +struct UserFormTemplate { + t: &'static Translations, + user_name: String, + user_role: String, + is_edit: bool, + form_user_id: i64, + form_username: String, + form_email: String, + form_display_name: String, + form_role: String, +} + +pub async fn users_new(admin: AuthenticatedUser, i18n: I18n) -> cot::Result { + let template = UserFormTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + is_edit: false, + form_user_id: 0, + form_username: String::new(), + form_email: String::new(), + form_display_name: String::new(), + form_role: "user".into(), + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +pub struct UserForm { + username: String, + email: String, + display_name: String, + password: String, + role: String, +} + +pub async fn users_create( + _admin: AuthenticatedUser, + db: &Database, + form: RequestForm, +) -> cot::Result> { + let RequestForm(result) = form; + match result { + FormResult::Ok(data) => { + let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) }; + let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) }; + User::create(db, &data.username, email, display_name, &data.password, &data.role).await + .map_err(|e| cot::Error::internal(format!("failed to create user: {e}")))?; + Ok(auth::redirect("/admin/users")) + } + FormResult::ValidationError(_) => { + Ok(auth::redirect("/admin/users/new")) + } + } +} + +pub async fn users_edit( + admin: AuthenticatedUser, + i18n: I18n, + db: &Database, + user_id: i64, +) -> cot::Result { + let target = User::get_by_id(db, user_id).await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))? + .ok_or_else(|| cot::Error::internal("user not found"))?; + let template = UserFormTemplate { + t: i18n.t, + user_name: admin.name, + user_role: admin.role.code().to_owned(), + is_edit: true, + form_user_id: target.id_val(), + form_username: target.username_str().to_owned(), + form_email: target.email_str(), + form_display_name: target.display_name_str(), + form_role: target.role_str().to_owned(), + }; + Ok(Html::new(template.render()?)) +} + +pub async fn users_update( + _admin: AuthenticatedUser, + db: &Database, + user_id: i64, + form: RequestForm, +) -> cot::Result> { + let RequestForm(result) = form; + match result { + FormResult::Ok(data) => { + let mut target = User::get_by_id(db, user_id).await + .map_err(|e| cot::Error::internal(format!("db error: {e}")))? + .ok_or_else(|| cot::Error::internal("user not found"))?; + let email = if data.email.is_empty() { None } else { Some(data.email.as_str()) }; + let display_name = if data.display_name.is_empty() { None } else { Some(data.display_name.as_str()) }; + let new_password = if data.password.is_empty() { None } else { Some(data.password.as_str()) }; + target.update_fields(db, &data.username, email, display_name, new_password, &data.role).await + .map_err(|e| cot::Error::internal(format!("failed to update user: {e}")))?; + Ok(auth::redirect("/admin/users")) + } + FormResult::ValidationError(_) => { + Ok(auth::redirect(&format!("/admin/users/{user_id}/edit"))) + } + } +} + +pub async fn users_delete( + _admin: AuthenticatedUser, + db: &Database, + user_id: i64, +) -> cot::Result> { + User::delete_by_id(db, user_id).await + .map_err(|e| cot::Error::internal(format!("failed to delete user: {e}")))?; + Ok(auth::redirect("/admin/users")) +} + +// --------------------------------------------------------------------------- +// First-run setup page +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "admin/setup.html")] +struct SetupTemplate { + t: &'static Translations, + message: String, +} + +pub async fn setup_page(i18n: I18n, message: String) -> cot::Result { + let template = SetupTemplate { + t: i18n.t, + message, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +pub struct SetupForm { + username: String, + password: String, + confirm_password: String, +} + +pub async fn setup_submit( + i18n: I18n, + db: &Database, + session: &Session, + form: RequestForm, +) -> cot::Result { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => { + return setup_page(i18n, String::new()).await?.into_response(); + } + }; + + if data.password != data.confirm_password { + let msg = i18n.t.setup_mismatch.to_owned(); + return setup_page(i18n, msg).await?.into_response(); + } + + let user = User::create(db, &data.username, None, None, &data.password, "admin") + .await + .map_err(|e| cot::Error::internal(format!("failed to create admin: {e}")))?; + + auth::login(session, user.id_val()).await?; + Ok(auth::redirect("/admin/")) +} diff --git a/src/api/mod.rs b/src/api/mod.rs new file mode 100644 index 0000000..8dc8531 --- /dev/null +++ b/src/api/mod.rs @@ -0,0 +1,68 @@ +use cot::db::Database; +use cot::router::method::get; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::{App, Body}; + +use crate::auth; + +// --------------------------------------------------------------------------- +// JSON response helpers +// --------------------------------------------------------------------------- + +fn json_ok(value: &serde_json::Value) -> cot::response::Response { + cot::http::Response::builder() + .status(cot::http::StatusCode::OK) + .header(cot::http::header::CONTENT_TYPE, "application/json") + .body(Body::fixed(value.to_string())) + .expect("valid response") +} + +fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Response { + let body = serde_json::json!({ "error": message }); + cot::http::Response::builder() + .status(status) + .header(cot::http::header::CONTENT_TYPE, "application/json") + .body(Body::fixed(body.to_string())) + .expect("valid response") +} + +// --------------------------------------------------------------------------- +// GET /api/me +// --------------------------------------------------------------------------- + +async fn me_handler( + session: Session, + db: Database, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error( + cot::http::StatusCode::UNAUTHORIZED, + "not authenticated", + )); + }; + + Ok(json_ok(&serde_json::json!({ + "id": user.id, + "name": user.name, + "role": user.role.code(), + }))) +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +pub struct ApiApp; + +impl App for ApiApp { + fn name(&self) -> &'static str { + "api" + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name("/me", get(me_handler), "api_me"), + ]) + } +} diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..c96b073 --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,138 @@ +use cot::db::Database; +use cot::response::IntoResponse; +use cot::session::Session; +use cot::Body; + +use crate::user::User; + +// --------------------------------------------------------------------------- +// Role enum +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Role { + Admin, + User, +} + +impl Role { + pub fn code(self) -> &'static str { + match self { + Role::Admin => "admin", + Role::User => "user", + } + } + + pub fn from_code(s: &str) -> Option { + match s { + "admin" => Some(Role::Admin), + "user" => Some(Role::User), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// Session-based auth +// --------------------------------------------------------------------------- + +const SESSION_USER_ID: &str = "user_id"; + +#[derive(Debug, Clone)] +pub struct AuthenticatedUser { + pub id: i64, + pub name: String, + pub role: Role, +} + +/// Read `user_id` from the session, fetch the `User` from DB, return +/// `AuthenticatedUser` if the user exists and is active. +pub async fn get_session_user(session: &Session, db: &Database) -> Option { + let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??; + let user = User::get_by_id(db, user_id).await.ok()??; + if !user.is_active() { + return None; + } + let name = { + let display = user.display_name_str(); + if display.is_empty() { + user.username_str().to_owned() + } else { + display + } + }; + Some(AuthenticatedUser { + id: user.id_val(), + name, + role: user.role(), + }) +} + +/// Return `Ok(user)` if the session belongs to an active admin, otherwise +/// `Err(response)` — a redirect to `/login` or a 403. +pub async fn require_admin_or_redirect( + session: &Session, + db: &Database, +) -> Result { + let Some(user) = get_session_user(session, db).await else { + return Err(redirect("/login")); + }; + if user.role != Role::Admin { + return Err( + "Forbidden" + .with_status(cot::http::StatusCode::FORBIDDEN) + .into_response() + .expect("valid response"), + ); + } + Ok(user) +} + +/// Insert user_id into the session and cycle the session ID. +pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> { + session + .cycle_id() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_USER_ID, user_id) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +/// Flush (destroy) the session. +pub async fn logout(session: &Session) -> cot::Result<()> { + session + .flush() + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +/// Build a 303 See Other redirect response. +pub fn redirect(location: &str) -> cot::response::Response { + cot::http::Response::builder() + .status(cot::http::StatusCode::SEE_OTHER) + .header(cot::http::header::LOCATION, location) + .body(Body::fixed("")) + .expect("valid response") +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn role_roundtrip() { + assert_eq!(Role::from_code("admin"), Some(Role::Admin)); + assert_eq!(Role::from_code("user"), Some(Role::User)); + assert_eq!(Role::from_code("other"), None); + assert_eq!(Role::Admin.code(), "admin"); + assert_eq!(Role::User.code(), "user"); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..2e6f087 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,372 @@ +/// Application-level configuration for furumusic. +/// +/// Every field is available both as a `FURU_`-prefixed environment variable +/// and through the admin UI. The resolution order is: +/// +/// env var > DB override > compiled default +/// +/// Adding a new field to [`AppConfig`] automatically makes it settable via +/// the `FURU_` env var thanks to the [`impl_env_overrides`] macro. +use std::collections::HashMap; + +use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; +use cot::db::{Database, DatabaseField, Identifier, LimitedString, Model}; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// ConfigSource — tracks where each field's effective value came from +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfigSource { + Default, + Database, + Env, +} + +impl ConfigSource { + pub fn code(self) -> &'static str { + match self { + Self::Default => "default", + Self::Database => "database", + Self::Env => "env", + } + } +} + +// --------------------------------------------------------------------------- +// ConfigEntry — DB model for the furu__config table +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct ConfigEntry { + #[model(primary_key)] + key: String, + value: String, +} + +impl ConfigEntry { + pub fn new(key: String, value: String) -> Self { + Self { key, value } + } +} + +// --------------------------------------------------------------------------- +// Migration +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use super::*; + + #[derive(Debug, Copy, Clone)] + pub struct M0001CreateConfig; + + impl migrations::Migration for M0001CreateConfig { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0001_create_config"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[]; + const OPERATIONS: &'static [Operation] = &[ + Operation::create_model() + .table_name(Identifier::new("furu__config")) + .fields(&[ + Field::new( + Identifier::new("key"), + as DatabaseField>::TYPE, + ) + .primary_key() + .set_null( as DatabaseField>::NULLABLE), + Field::new( + Identifier::new("value"), + ::TYPE, + ) + .set_null(::NULLABLE), + ]) + .build(), + ]; + } + + // -- M0002: rename furu__config → furumusic__config_entry --------------- + + #[cot::db::migrations::migration_op] + async fn rename_config_table(ctx: migrations::MigrationContext<'_>) -> cot::db::Result<()> { + ctx.db + .raw("ALTER TABLE furu__config RENAME TO furumusic__config_entry") + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0002RenameConfigTable; + + impl migrations::Migration for M0002RenameConfigTable { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0002_rename_config_table"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ + migrations::MigrationDependency::migration("furumusic", "m_0001_create_config"), + ]; + const OPERATIONS: &'static [Operation] = &[ + Operation::custom(rename_config_table).build(), + ]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[&M0001CreateConfig, &M0002RenameConfigTable]; +} + +// --------------------------------------------------------------------------- +// ConfigSources — parallel struct tracking the source of each field +// --------------------------------------------------------------------------- + +pub struct ConfigSources { + pub database_url: ConfigSource, + pub oidc_issuer: ConfigSource, + pub oidc_client_id: ConfigSource, + pub oidc_client_secret: ConfigSource, + pub log_level: ConfigSource, + pub auth_password_enabled: ConfigSource, + pub auth_sso_enabled: ConfigSource, + pub oidc_button_text: ConfigSource, + pub oidc_admin_groups: ConfigSource, +} + +impl Default for ConfigSources { + fn default() -> Self { + Self { + database_url: ConfigSource::Default, + oidc_issuer: ConfigSource::Default, + oidc_client_id: ConfigSource::Default, + oidc_client_secret: ConfigSource::Default, + log_level: ConfigSource::Default, + auth_password_enabled: ConfigSource::Default, + auth_sso_enabled: ConfigSource::Default, + oidc_button_text: ConfigSource::Default, + oidc_admin_groups: ConfigSource::Default, + } + } +} + +// --------------------------------------------------------------------------- +// Env-var helper +// --------------------------------------------------------------------------- + +/// Read a single env var with the `FURU_` prefix, returning `None` when the +/// variable is absent and logging a warning when it is present but cannot be +/// parsed. +fn env_override(field: &str) -> Option { + let key = format!("FURU_{}", field.to_ascii_uppercase()); + match std::env::var(&key) { + Ok(val) => match val.parse::() { + Ok(v) => Some(v), + Err(_) => { + tracing::warn!("ignoring invalid value for {key}: {val:?}"); + None + } + }, + Err(_) => None, + } +} + +// --------------------------------------------------------------------------- +// Macro: generates apply_env_overrides + apply_env_overrides_tracked +// --------------------------------------------------------------------------- + +/// Generates two methods on [`AppConfig`]: +/// +/// - `apply_env_overrides`: overwrites fields from `FURU_*` env vars (no source tracking). +/// - `apply_env_overrides_tracked`: same but also marks sources as [`ConfigSource::Env`]. +macro_rules! impl_env_overrides { + ($($field:ident),* $(,)?) => { + impl AppConfig { + /// Apply `FURU_*` environment variable overrides to self. + pub fn apply_env_overrides(&mut self) { + $( + if let Some(v) = env_override(stringify!($field)) { + self.$field = v; + } + )* + } + + /// Apply `FURU_*` environment variable overrides and record sources. + pub fn apply_env_overrides_tracked(&mut self, sources: &mut ConfigSources) { + $( + if let Some(v) = env_override(stringify!($field)) { + self.$field = v; + sources.$field = ConfigSource::Env; + } + )* + } + } + }; +} + +// --------------------------------------------------------------------------- +// AppConfig +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppConfig { + /// PostgreSQL connection URL. + pub database_url: String, + /// OIDC issuer URL. + pub oidc_issuer: String, + /// OIDC client ID. + pub oidc_client_id: String, + /// OIDC client secret. + pub oidc_client_secret: String, + /// Tracing log level filter (e.g. "info", "debug", "warn,furumusic=debug"). + pub log_level: String, + /// Whether password-based login is enabled. + pub auth_password_enabled: bool, + /// Whether SSO (OIDC) login is enabled. + pub auth_sso_enabled: bool, + /// Label shown on the SSO login button. + pub oidc_button_text: String, + /// Comma-separated list of OIDC group names that grant admin role. + pub oidc_admin_groups: String, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + database_url: String::new(), + oidc_issuer: String::new(), + oidc_client_id: String::new(), + oidc_client_secret: String::new(), + log_level: "info".into(), + auth_password_enabled: true, + auth_sso_enabled: false, + oidc_button_text: "Sign in with SSO".into(), + oidc_admin_groups: String::new(), + } + } +} + +// Register every field that should be overridable via FURU_* env vars. +impl_env_overrides!( + database_url, + oidc_issuer, + oidc_client_id, + oidc_client_secret, + log_level, + auth_password_enabled, + auth_sso_enabled, + oidc_button_text, + oidc_admin_groups, +); + +impl AppConfig { + /// Build config: start from defaults, then overlay env vars. + /// Used at startup before the DB is available (to get `database_url`). + pub fn load() -> Self { + let mut cfg = Self::default(); + cfg.apply_env_overrides(); + cfg + } + + /// Build config with full 3-layer resolution (default → DB → env) and + /// track the source of each field. + pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) { + let mut cfg = Self::default(); + let mut sources = ConfigSources::default(); + cfg.apply_db_overrides(db, &mut sources).await; + cfg.apply_env_overrides_tracked(&mut sources); + (cfg, sources) + } + + /// Query all rows from `furu__config` and overlay matching fields. + async fn apply_db_overrides(&mut self, db: &Database, sources: &mut ConfigSources) { + let rows = match ConfigEntry::objects().all(db).await { + Ok(rows) => rows, + Err(e) => { + tracing::warn!("failed to read furu__config: {e}"); + return; + } + }; + + let map: HashMap = rows + .into_iter() + .map(|entry| (entry.key.to_string(), entry.value)) + .collect(); + + macro_rules! apply_db_field { + ($field:ident) => { + if let Some(val) = map.get(stringify!($field)) { + match val.parse() { + Ok(v) => { + self.$field = v; + sources.$field = ConfigSource::Database; + } + Err(_) => { + tracing::warn!( + "ignoring invalid DB config value for {}: {:?}", + stringify!($field), + val, + ); + } + } + } + }; + } + + apply_db_field!(database_url); + apply_db_field!(oidc_issuer); + apply_db_field!(oidc_client_id); + apply_db_field!(oidc_client_secret); + apply_db_field!(log_level); + apply_db_field!(auth_password_enabled); + apply_db_field!(auth_sso_enabled); + apply_db_field!(oidc_button_text); + apply_db_field!(oidc_admin_groups); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + let cfg = AppConfig::default(); + assert!(cfg.database_url.is_empty()); + assert_eq!(cfg.log_level, "info"); + } + + // SAFETY: tests run with --test-threads=1 so no concurrent env access. + unsafe fn set(k: &str, v: &str) { unsafe { std::env::set_var(k, v) }; } + unsafe fn unset(k: &str) { unsafe { std::env::remove_var(k) }; } + + #[test] + fn env_override_string_field() { + unsafe { set("FURU_OIDC_ISSUER", "https://example.com"); } + let cfg = AppConfig::load(); + assert_eq!(cfg.oidc_issuer, "https://example.com"); + unsafe { unset("FURU_OIDC_ISSUER"); } + } + + #[test] + fn env_override_bool_field() { + unsafe { set("FURU_AUTH_SSO_ENABLED", "true"); } + let cfg = AppConfig::load(); + assert!(cfg.auth_sso_enabled); + unsafe { unset("FURU_AUTH_SSO_ENABLED"); } + } + + #[test] + fn source_tracking_env() { + unsafe { set("FURU_OIDC_ISSUER", "https://tracked.example.com"); } + let mut cfg = AppConfig::default(); + let mut sources = ConfigSources::default(); + cfg.apply_env_overrides_tracked(&mut sources); + assert_eq!(cfg.oidc_issuer, "https://tracked.example.com"); + assert_eq!(sources.oidc_issuer, ConfigSource::Env); + assert_eq!(sources.database_url, ConfigSource::Default); + unsafe { unset("FURU_OIDC_ISSUER"); } + } + + #[test] + fn config_source_codes() { + assert_eq!(ConfigSource::Default.code(), "default"); + assert_eq!(ConfigSource::Database.code(), "database"); + assert_eq!(ConfigSource::Env.code(), "env"); + } +} diff --git a/src/i18n/mod.rs b/src/i18n/mod.rs new file mode 100644 index 0000000..9dfd538 --- /dev/null +++ b/src/i18n/mod.rs @@ -0,0 +1,226 @@ +mod phrases; + +pub use phrases::Translations; + +use cot::request::extractors::FromRequestHead; +use cot::request::RequestHead; +use serde::{Deserialize, Serialize}; + +// --------------------------------------------------------------------------- +// Lang enum +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Lang { + En, + Ru, +} + +impl Lang { + pub fn code(self) -> &'static str { + match self { + Lang::En => "en", + Lang::Ru => "ru", + } + } + + pub fn from_code(s: &str) -> Option { + match s { + "en" => Some(Lang::En), + "ru" => Some(Lang::Ru), + _ => None, + } + } +} + +// --------------------------------------------------------------------------- +// translations! macro +// --------------------------------------------------------------------------- + +macro_rules! translations { + ( $( $key:ident : $en:expr , $ru:expr );* $(;)? ) => { + #[derive(Debug)] + pub struct Translations { + pub lang: $crate::i18n::Lang, + $( pub $key: &'static str, )* + } + + static EN: Translations = Translations { + lang: $crate::i18n::Lang::En, + $( $key: $en, )* + }; + + static RU: Translations = Translations { + lang: $crate::i18n::Lang::Ru, + $( $key: $ru, )* + }; + + impl Translations { + pub fn for_lang(lang: $crate::i18n::Lang) -> &'static Self { + match lang { + $crate::i18n::Lang::En => &EN, + $crate::i18n::Lang::Ru => &RU, + } + } + } + }; +} + +pub(crate) use translations; + +// --------------------------------------------------------------------------- +// Cookie helpers +// --------------------------------------------------------------------------- + +const COOKIE_NAME: &str = "furu_lang"; + +/// Build a `Set-Cookie` header value that persists the language choice for 1 year. +pub fn lang_cookie(lang: Lang) -> String { + format!("{COOKIE_NAME}={}; Path=/; SameSite=Lax; Max-Age=31536000", lang.code()) +} + +/// Parse `furu_lang` from the `Cookie` request header. +fn lang_from_cookie(headers: &cot::http::HeaderMap) -> Option { + let raw = headers.get(cot::http::header::COOKIE)?.to_str().ok()?; + for part in raw.split(';') { + let part = part.trim(); + if let Some(value) = part.strip_prefix("furu_lang=") { + return Lang::from_code(value.trim()); + } + } + None +} + +// --------------------------------------------------------------------------- +// Accept-Language parsing +// --------------------------------------------------------------------------- + +/// Parse the Accept-Language header and return the best matching `Lang`. +fn parse_accept_language(header: &str) -> Option { + let mut langs: Vec<(&str, u16)> = header + .split(',') + .filter_map(|part| { + let part = part.trim(); + let (tag, quality) = if let Some((tag, q)) = part.split_once(";q=") { + let q = q.trim().parse::().ok()?; + (tag.trim(), (q * 1000.0) as u16) + } else { + (part, 1000) + }; + Some((tag, quality)) + }) + .collect(); + + langs.sort_by(|a, b| b.1.cmp(&a.1)); + + for (tag, _) in langs { + let primary = tag.split('-').next().unwrap_or(tag); + if let Some(lang) = Lang::from_code(primary) { + return Some(lang); + } + } + None +} + +// --------------------------------------------------------------------------- +// Language resolution +// --------------------------------------------------------------------------- + +fn resolve_lang(headers: &cot::http::HeaderMap) -> Lang { + // 1. Explicit cookie override. + if let Some(lang) = lang_from_cookie(headers) { + return lang; + } + + // 2. Accept-Language header. + if let Some(value) = headers.get(cot::http::header::ACCEPT_LANGUAGE) { + if let Ok(s) = value.to_str() { + if let Some(lang) = parse_accept_language(s) { + return lang; + } + } + } + + // 3. Default. + Lang::En +} + +// --------------------------------------------------------------------------- +// I18n extractor +// --------------------------------------------------------------------------- + +pub struct I18n { + pub lang: Lang, + pub t: &'static Translations, +} + +impl FromRequestHead for I18n { + async fn from_request_head(head: &RequestHead) -> cot::Result { + let lang = resolve_lang(&head.headers); + Ok(I18n { + lang, + t: Translations::for_lang(lang), + }) + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lang_roundtrip() { + assert_eq!(Lang::from_code("en"), Some(Lang::En)); + assert_eq!(Lang::from_code("ru"), Some(Lang::Ru)); + assert_eq!(Lang::from_code("de"), None); + assert_eq!(Lang::En.code(), "en"); + assert_eq!(Lang::Ru.code(), "ru"); + } + + #[test] + fn parse_simple_accept_language() { + assert_eq!(parse_accept_language("ru"), Some(Lang::Ru)); + assert_eq!(parse_accept_language("en-US"), Some(Lang::En)); + } + + #[test] + fn parse_weighted_accept_language() { + assert_eq!( + parse_accept_language("en-US,en;q=0.9,ru;q=0.8"), + Some(Lang::En) + ); + assert_eq!( + parse_accept_language("ru-RU,ru;q=0.9,en;q=0.5"), + Some(Lang::Ru) + ); + } + + #[test] + fn parse_unknown_falls_through() { + assert_eq!( + parse_accept_language("de;q=1.0,ru;q=0.5"), + Some(Lang::Ru) + ); + assert_eq!(parse_accept_language("de,fr,ja"), None); + } + + #[test] + fn cookie_parsing() { + let mut headers = cot::http::HeaderMap::new(); + headers.insert( + cot::http::header::COOKIE, + "other=x; furu_lang=ru; foo=bar".parse().unwrap(), + ); + assert_eq!(lang_from_cookie(&headers), Some(Lang::Ru)); + } + + #[test] + fn cookie_missing() { + let headers = cot::http::HeaderMap::new(); + assert_eq!(lang_from_cookie(&headers), None); + } +} diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs new file mode 100644 index 0000000..3562b6b --- /dev/null +++ b/src/i18n/phrases.rs @@ -0,0 +1,95 @@ +use super::translations; + +translations! { + // Global + site_name: "furumusic" , "furumusic"; + + // Navigation / sidebar + nav_admin: "admin" , "админка"; + nav_dashboard: "Dashboard" , "Панель управления"; + nav_debug: "Debug" , "Отладка"; + + // Index page + index_heading: "furumusic" , "furumusic"; + index_status: "server is running" , "сервер запущен"; + + // Admin index + admin_heading: "Admin" , "Админка"; + admin_debug_link: "Debug info" , "Отладочная информация"; + + // Debug page + debug_heading: "Debug Information" , "Отладочная информация"; + debug_build_info: "Build Info" , "Информация о сборке"; + debug_app_config: "App Config" , "Конфигурация"; + debug_field: "Field" , "Поле"; + debug_value: "Value" , "Значение"; + debug_source: "Source" , "Источник"; + + // Navigation (settings) + nav_settings: "Settings" , "Настройки"; + + // Debug page — DB status + debug_db_status: "Database" , "База данных"; + debug_db_connected: "connected" , "подключена"; + debug_db_error: "error" , "ошибка"; + + // Settings page + settings_heading: "Settings" , "Настройки"; + settings_oidc: "OIDC Configuration" , "Настройки OIDC"; + settings_save: "Save" , "Сохранить"; + settings_saved: "Settings saved." , "Настройки сохранены."; + + // Auth settings + settings_auth: "Authentication" , "Аутентификация"; + settings_password_login: "Password login" , "Вход по паролю"; + settings_sso_login: "SSO login" , "Вход через SSO"; + settings_oidc_button: "SSO button text" , "Текст кнопки SSO"; + + // Login page + login_heading: "Sign in" , "Вход"; + login_username: "Username" , "Имя пользователя"; + login_password: "Password" , "Пароль"; + login_submit: "Sign in" , "Войти"; + login_disabled: "Login is currently disabled." , "Вход сейчас отключён."; + login_invalid: "Invalid username or password." , "Неверное имя пользователя или пароль."; + + // Logout + nav_logout: "Logout" , "Выход"; + + // Setup page + setup_heading: "Create Admin Account" , "Создание аккаунта администратора"; + setup_username: "Username" , "Имя пользователя"; + setup_password: "Password" , "Пароль"; + setup_confirm: "Confirm password" , "Подтверждение пароля"; + setup_submit: "Create" , "Создать"; + setup_mismatch: "Passwords do not match." , "Пароли не совпадают."; + + // OIDC help + settings_oidc_help: "Register this application with your identity provider. Use the callback URL shown below as the Redirect URI." , "Зарегистрируйте это приложение у вашего провайдера идентификации. Используйте указанный ниже callback URL в качестве Redirect URI."; + settings_oidc_callback: "Callback URL" , "Callback URL"; + settings_oidc_issuer_help: "Base URL of the OIDC provider (e.g. https://accounts.google.com)" , "Базовый URL провайдера OIDC (напр. https://accounts.google.com)"; + settings_oidc_admin_groups: "Admin groups" , "Группы администраторов"; + settings_oidc_admin_groups_help: "Comma-separated OIDC group names that grant admin role (e.g. /admin,/furumusic-admins)" , "OIDC группы через запятую, дающие роль администратора (напр. /admin,/furumusic-admins)"; + + // User management + nav_users: "Users" , "Пользователи"; + users_heading: "Users" , "Пользователи"; + users_add: "Add user" , "Добавить пользователя"; + users_username: "Username" , "Имя пользователя"; + users_email: "Email" , "Email"; + users_display_name: "Display name" , "Отображаемое имя"; + users_role: "Role" , "Роль"; + users_active: "Active" , "Активен"; + users_actions: "Actions" , "Действия"; + users_edit: "Edit" , "Редактировать"; + users_delete: "Delete" , "Удалить"; + users_delete_confirm: "Are you sure?" , "Вы уверены?"; + users_new_heading: "New user" , "Новый пользователь"; + users_edit_heading: "Edit user" , "Редактирование пользователя"; + users_password_hint: "Leave blank to keep current" , "Оставьте пустым, чтобы не менять"; + users_saved: "User saved." , "Пользователь сохранён."; + + // OIDC login errors + login_oidc_error: "SSO login failed. Please try again." , "Ошибка входа через SSO. Попробуйте ещё раз."; + login_sso_disabled: "SSO login is not configured." , "Вход через SSO не настроен."; +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b379a84 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,350 @@ +mod admin; +mod api; +mod auth; +mod config; +mod i18n; +mod oidc; +mod user; + +use std::sync::Arc; + +use cot::auth::PasswordVerificationResult; +use cot::cli::CliMetadata; +use cot::common_types::Password; +use cot::config::{ + DatabaseConfig, MiddlewareConfig, ProjectConfig, SessionMiddlewareConfig, SessionStoreConfig, + SessionStoreTypeConfig, +}; +use cot::db::Database; +use cot::form::{Form, FormResult}; +use cot::html::Html; +use cot::middleware::SessionMiddleware; +use cot::project::RegisterAppsContext; +use cot::request::extractors::{RequestForm, UrlQuery}; +use cot::response::IntoResponse; +use cot::router::method::get; +use cot::router::{Route, Router}; +use cot::session::Session; +use cot::{App, AppBuilder, Body, Project, Template}; +use serde::Deserialize; + +use crate::config::AppConfig; +use crate::i18n::{I18n, Translations}; +use crate::user::User; + +// --------------------------------------------------------------------------- +// Handlers +// --------------------------------------------------------------------------- + +async fn index( + session: Session, + db: Database, + i18n: I18n, +) -> cot::Result { + let user = match auth::get_session_user(&session, &db).await { + Some(u) => u, + None => return Ok(auth::redirect("/login")), + }; + let role_label = match user.role { + auth::Role::Admin => format!( + r#"{} | {}"#, + user.role.code(), + i18n.t.nav_admin + ), + _ => user.role.code().to_owned(), + }; + Html::new(format!( + "

{}

{}

{}: {}

", + i18n.t.index_heading, i18n.t.index_status, user.name, role_label + )) + .into_response() +} + +#[derive(Deserialize)] +struct SetLangQuery { + lang: String, + next: Option, +} + +async fn set_lang( + UrlQuery(query): UrlQuery, +) -> cot::Result> { + let lang = i18n::Lang::from_code(&query.lang).unwrap_or(i18n::Lang::En); + let next = query.next.as_deref().unwrap_or("/"); + + let response = cot::http::Response::builder() + .status(cot::http::StatusCode::SEE_OTHER) + .header(cot::http::header::LOCATION, next) + .header(cot::http::header::SET_COOKIE, i18n::lang_cookie(lang)) + .body(Body::fixed("")) + .expect("valid response"); + + Ok(response) +} + +// --------------------------------------------------------------------------- +// Login page +// --------------------------------------------------------------------------- + +#[derive(Debug, Template)] +#[template(path = "login.html")] +struct LoginTemplate { + t: &'static Translations, + auth_password_enabled: bool, + auth_sso_enabled: bool, + oidc_button_text: String, + message: String, +} + +async fn login_page_handler( + i18n: I18n, + _startup_config: &AppConfig, + db: Database, + message: String, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + let template = LoginTemplate { + t: i18n.t, + auth_password_enabled: config.auth_password_enabled, + auth_sso_enabled: config.auth_sso_enabled, + oidc_button_text: config.oidc_button_text, + message, + }; + Ok(Html::new(template.render()?)) +} + +#[derive(Debug, Form)] +struct LoginForm { + username: String, + password: String, +} + +// --------------------------------------------------------------------------- +// Logout +// --------------------------------------------------------------------------- + +async fn logout_handler(session: Session) -> cot::Result { + auth::logout(&session).await?; + Ok(auth::redirect("/login")) +} + +// --------------------------------------------------------------------------- +// App +// --------------------------------------------------------------------------- + +struct FuruApp { + config: Arc, +} + +impl App for FuruApp { + fn name(&self) -> &'static str { + env!("CARGO_PKG_NAME") + } + + fn router(&self) -> Router { + Router::with_urls([ + Route::with_handler_and_name( + "/admin", + get(|| async { Ok::<_, cot::Error>(auth::redirect("/admin/")) }), + "admin_redirect", + ), + Route::with_handler_and_name("/", index, "index"), + Route::with_handler_and_name( + "/login", + get({ + let config = Arc::clone(&self.config); + move |i18n: I18n, db: Database| { + let config = Arc::clone(&config); + async move { + // No users at all → redirect to first-run setup + if User::count_all(&db).await.unwrap_or(0) == 0 { + return Ok(auth::redirect("/admin/setup")); + } + login_page_handler(i18n, &config, db, String::new()) + .await? + .into_response() + } + } + }).post({ + let config = Arc::clone(&self.config); + move |i18n: I18n, db: Database, session: Session, + form: RequestForm| { + let config = Arc::clone(&config); + async move { + let RequestForm(result) = form; + let data = match result { + FormResult::Ok(data) => data, + FormResult::ValidationError(_) => { + let msg = i18n.t.login_invalid.to_owned(); + return login_page_handler(i18n, &config, db, msg) + .await? + .into_response(); + } + }; + + // Try to authenticate + if let Ok(Some(user)) = + User::get_by_username(&db, &data.username).await + { + if let Some(hash) = user.password_ref() { + let password = Password::new(&data.password); + match hash.verify(&password) { + PasswordVerificationResult::Ok + | PasswordVerificationResult::OkObsolete(_) => { + auth::login(&session, user.id_val()).await?; + return Ok(auth::redirect("/")); + } + PasswordVerificationResult::Invalid => {} + } + } + } + + let msg = i18n.t.login_invalid.to_owned(); + login_page_handler(i18n, &config, db, msg) + .await? + .into_response() + } + } + }), + "login", + ), + Route::with_handler_and_name("/logout", get(logout_handler), "logout"), + Route::with_handler_and_name("/set-lang", set_lang, "set_lang"), + Route::with_handler_and_name( + "/auth/oidc/start", + get(oidc::oidc_start_handler), + "oidc_start", + ), + Route::with_handler_and_name( + "/auth/oidc/callback", + get(oidc::oidc_callback_handler), + "oidc_callback", + ), + ]) + } +} + +// --------------------------------------------------------------------------- +// Project +// --------------------------------------------------------------------------- + +struct FuruProject { + app_config: Arc, +} + +impl Project for FuruProject { + fn cli_metadata(&self) -> CliMetadata { + CliMetadata { + description: concat!( + env!("CARGO_PKG_DESCRIPTION"), + "\n\n", + "CONFIGURATION\n", + " All settings are available as FURU_-prefixed environment variables.\n", + " Priority: env var > DB override > compiled default.\n", + "\n", + " Database (required for most features):\n", + " FURU_DATABASE_URL PostgreSQL connection URL\n", + " Example: postgres://user:pass@localhost/furumusic\n", + "\n", + " Server:\n", + " FURU_LOG_LEVEL Tracing filter (default: info)\n", + "\n", + " Authentication:\n", + " FURU_AUTH_PASSWORD_ENABLED Enable password login (default: true)\n", + " FURU_AUTH_SSO_ENABLED Enable SSO/OIDC login (default: false)\n", + " FURU_OIDC_ISSUER OIDC issuer URL\n", + " FURU_OIDC_CLIENT_ID OIDC client ID\n", + " FURU_OIDC_CLIENT_SECRET OIDC client secret\n", + " FURU_OIDC_BUTTON_TEXT SSO button label (default: Sign in with SSO)\n", + " FURU_OIDC_ADMIN_GROUPS OIDC groups that grant admin role\n", + "\n", + "QUICK START\n", + " export FURU_DATABASE_URL=postgres://user:pass@localhost/furumusic\n", + " furumusic run", + ), + ..cot::cli::metadata!() + } + } + + fn config(&self, _config_name: &str) -> cot::Result { + let mut builder = ProjectConfig::builder(); + builder.debug(cfg!(debug_assertions)); + + if !self.app_config.database_url.is_empty() { + builder.database( + DatabaseConfig::builder() + .url(self.app_config.database_url.as_str()) + .build(), + ); + builder.middlewares( + MiddlewareConfig::builder() + .session( + SessionMiddlewareConfig::builder() + .store( + SessionStoreConfig::builder() + .store_type(SessionStoreTypeConfig::Database) + .build(), + ) + .build(), + ) + .build(), + ); + } + + Ok(builder.build()) + } + + fn middlewares( + &self, + handler: cot::project::RootHandlerBuilder, + context: &cot::project::MiddlewareContext, + ) -> cot::project::RootHandler { + handler + .middleware( + SessionMiddleware::from_context(context) + .same_site(cot::config::SameSite::Lax), + ) + .build() + } + + fn register_apps(&self, apps: &mut AppBuilder, _context: &RegisterAppsContext) { + apps.register(cot::session::db::SessionApp::new()); + apps.register_with_views( + FuruApp { + config: Arc::clone(&self.app_config), + }, + "", + ); + apps.register_with_views( + admin::AdminApp::new(Arc::clone(&self.app_config)), + "/admin", + ); + apps.register_with_views(api::ApiApp, "/api"); + } +} + +// --------------------------------------------------------------------------- +// Entrypoint +// --------------------------------------------------------------------------- + +#[cot::main] +fn main() -> impl Project { + let app_config = Arc::new(AppConfig::load()); + + // Initialise tracing subscriber with the configured log level. + // FURU_LOG_LEVEL (or the default "info") is parsed as an EnvFilter + // directive, so values like "debug", "warn,furumusic=trace" all work. + let filter = tracing_subscriber::EnvFilter::try_new(&app_config.log_level) + .unwrap_or_else(|e| { + eprintln!( + "WARNING: invalid FURU_LOG_LEVEL {:?}: {e}; falling back to \"info\"", + app_config.log_level, + ); + tracing_subscriber::EnvFilter::new("info") + }); + tracing_subscriber::fmt().with_env_filter(filter).init(); + + tracing::info!("loaded config: {:?}", app_config); + + FuruProject { app_config } +} diff --git a/src/oidc.rs b/src/oidc.rs new file mode 100644 index 0000000..d406a63 --- /dev/null +++ b/src/oidc.rs @@ -0,0 +1,578 @@ +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; +use std::sync::LazyLock; +use std::time::Instant; + +use cot::db::Database; +use cot::session::Session; +use openidconnect::core::{CoreClient, CoreProviderMetadata}; +use openidconnect::{ + AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet, + EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope, +}; + +use cot::request::RequestHead; +use cot::request::extractors::FromRequestHead; + +use crate::auth; +use crate::config::AppConfig; +use crate::i18n::I18n; +use crate::user::{OidcLink, User}; + +// --------------------------------------------------------------------------- +// Request origin extractor (scheme + host from headers) +// --------------------------------------------------------------------------- + +/// Extracts the origin (e.g. "http://127.0.0.1:3001") from the request so we +/// can build the correct OIDC redirect URI. +pub struct RequestOrigin(pub String); + +impl FromRequestHead for RequestOrigin { + async fn from_request_head(head: &RequestHead) -> cot::Result { + let scheme = head + .headers + .get("x-forwarded-proto") + .and_then(|v| v.to_str().ok()) + .unwrap_or("http"); + + let host = head + .headers + .get(cot::http::header::HOST) + .and_then(|v| v.to_str().ok()) + .unwrap_or("localhost"); + + Ok(RequestOrigin(format!("{scheme}://{host}"))) + } +} + +// --------------------------------------------------------------------------- +// Session keys for OIDC flow state +// --------------------------------------------------------------------------- + +const SESSION_CSRF_STATE: &str = "oidc_csrf_state"; +const SESSION_NONCE: &str = "oidc_nonce"; +const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier"; +const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri"; + +// --------------------------------------------------------------------------- +// Provider cache +// --------------------------------------------------------------------------- + +/// Concrete client type returned by `from_provider_metadata` + `set_redirect_uri`. +/// The provider metadata discovery sets auth URL to EndpointSet, and token/userinfo +/// endpoints to EndpointMaybeSet. The remaining endpoints stay EndpointNotSet. +type ConfiguredClient = CoreClient< + EndpointSet, + EndpointNotSet, + EndpointNotSet, + EndpointNotSet, + EndpointMaybeSet, + EndpointMaybeSet, +>; + +struct CachedProvider { + client: ConfiguredClient, + fetched_at: Instant, + config_hash: u64, +} + +static PROVIDER_CACHE: LazyLock>> = + LazyLock::new(|| tokio::sync::RwLock::new(None)); + +/// TTL for cached provider metadata (1 hour). +const PROVIDER_TTL_SECS: u64 = 3600; + +/// Compute a hash of the OIDC configuration values so we can detect changes. +fn config_hash(issuer: &str, client_id: &str, client_secret: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + issuer.hash(&mut hasher); + client_id.hash(&mut hasher); + client_secret.hash(&mut hasher); + hasher.finish() +} + +fn oidc_http_client() -> reqwest::Client { + reqwest::ClientBuilder::new() + .redirect(reqwest::redirect::Policy::none()) + .build() + .expect("valid reqwest client") +} + +/// Get or refresh the cached OIDC provider. Returns a cloned `ConfiguredClient`. +async fn get_or_refresh_provider( + config: &AppConfig, + http: &reqwest::Client, +) -> Result { + let hash = config_hash( + &config.oidc_issuer, + &config.oidc_client_id, + &config.oidc_client_secret, + ); + + // Fast path: check if we have a valid cached provider. + { + let cache = PROVIDER_CACHE.read().await; + if let Some(ref cached) = *cache { + if cached.config_hash == hash + && cached.fetched_at.elapsed().as_secs() < PROVIDER_TTL_SECS + { + return Ok(cached.client.clone()); + } + } + } + + // Slow path: discover provider metadata + JWKS. + // Strip /.well-known/openid-configuration suffix if the user pasted the + // full discovery URL, so discover_async doesn't double-append it. + let issuer = config + .oidc_issuer + .trim_end_matches('/') + .strip_suffix("/.well-known/openid-configuration") + .unwrap_or(config.oidc_issuer.trim_end_matches('/')) + .to_owned(); + + let issuer_url = IssuerUrl::new(issuer) + .map_err(|e| format!("invalid issuer URL: {e}"))?; + + let metadata = CoreProviderMetadata::discover_async(issuer_url, http) + .await + .map_err(|e| format!("OIDC discovery failed: {e}"))?; + + let client = CoreClient::from_provider_metadata( + metadata, + ClientId::new(config.oidc_client_id.clone()), + Some(ClientSecret::new(config.oidc_client_secret.clone())), + ); + + let mut cache = PROVIDER_CACHE.write().await; + *cache = Some(CachedProvider { + client: client.clone(), + fetched_at: Instant::now(), + config_hash: hash, + }); + + Ok(client) +} + +// --------------------------------------------------------------------------- +// GET /auth/oidc/start +// --------------------------------------------------------------------------- + +pub async fn oidc_start_handler( + origin: RequestOrigin, + i18n: I18n, + db: Database, + session: Session, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + + // Validate SSO is enabled and configured. + if !config.auth_sso_enabled + || config.oidc_issuer.is_empty() + || config.oidc_client_id.is_empty() + || config.oidc_client_secret.is_empty() + { + tracing::warn!("OIDC start requested but SSO is not configured"); + return redirect_login_with_error(i18n.t.login_sso_disabled); + } + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC provider error: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Build redirect URI from the actual request origin. + let redirect_uri_str = format!("{}/auth/oidc/callback", origin.0); + let redirect_url = RedirectUrl::new(redirect_uri_str.clone()) + .map_err(|e| cot::Error::internal(format!("bad redirect URI: {e}")))?; + let client = client.set_redirect_uri(redirect_url); + + // Build PKCE challenge. + let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256(); + + // Build authorization URL. + // The openid scope is added automatically by the crate; only add email + profile. + let (auth_url, csrf_state, nonce) = client + .authorize_url( + openidconnect::AuthenticationFlow::::AuthorizationCode, + CsrfToken::new_random, + Nonce::new_random, + ) + .add_scope(Scope::new("email".to_string())) + .add_scope(Scope::new("profile".to_string())) + .set_pkce_challenge(pkce_challenge) + .url(); + + // Store OIDC flow state in the session. + session + .insert(SESSION_CSRF_STATE, csrf_state.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_NONCE, nonce.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_PKCE_VERIFIER, pkce_verifier.secret().clone()) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + session + .insert(SESSION_REDIRECT_URI, redirect_uri_str) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(auth::redirect(auth_url.as_str())) +} + +// --------------------------------------------------------------------------- +// GET /auth/oidc/callback +// --------------------------------------------------------------------------- + +use serde::Deserialize; + +#[derive(Deserialize)] +pub struct OidcCallbackQuery { + code: String, + state: String, +} + +pub async fn oidc_callback_handler( + i18n: I18n, + db: Database, + session: Session, + cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery, +) -> cot::Result { + let (config, _) = AppConfig::load_with_db(&db).await; + + // Retrieve OIDC flow state from the session. + let saved_csrf: Option = session + .get(SESSION_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_nonce: Option = session + .get(SESSION_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_pkce: Option = session + .get(SESSION_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let saved_redirect_uri: Option = session + .get(SESSION_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + // Validate CSRF state. + let Some(saved_csrf) = saved_csrf else { + tracing::warn!("OIDC callback: no CSRF state in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + if query.state != saved_csrf { + tracing::warn!("OIDC callback: CSRF state mismatch"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + + let Some(nonce_str) = saved_nonce else { + tracing::warn!("OIDC callback: no nonce in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + let Some(pkce_str) = saved_pkce else { + tracing::warn!("OIDC callback: no PKCE verifier in session"); + return redirect_login_with_error(i18n.t.login_oidc_error); + }; + + let nonce = Nonce::new(nonce_str); + let pkce_verifier = PkceCodeVerifier::new(pkce_str); + + let http = oidc_http_client(); + let client = match get_or_refresh_provider(&config, &http).await { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC provider error during callback: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Restore the redirect URI that was used in the authorization request. + let client = if let Some(ref uri) = saved_redirect_uri { + let redirect_url = RedirectUrl::new(uri.clone()) + .map_err(|e| cot::Error::internal(format!("bad redirect URI from session: {e}")))?; + client.set_redirect_uri(redirect_url) + } else { + client + }; + + // Exchange code for tokens. + let token_request = match client + .exchange_code(AuthorizationCode::new(query.code.clone())) + { + Ok(req) => req, + Err(e) => { + tracing::error!("OIDC token endpoint not configured: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + let token_response = token_request + .set_pkce_verifier(pkce_verifier) + .request_async(&http) + .await; + + let token_response = match token_response { + Ok(t) => t, + Err(e) => { + tracing::error!("OIDC token exchange failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Verify and extract ID token claims. + use openidconnect::TokenResponse; + let id_token = match token_response.id_token() { + Some(t) => t, + None => { + tracing::error!("OIDC response missing ID token"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + let claims = match id_token.claims(&client.id_token_verifier(), &nonce) { + Ok(c) => c, + Err(e) => { + tracing::error!("OIDC ID token verification failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + let sub = claims.subject().to_string(); + let issuer = claims.issuer().to_string(); + let email = claims.email().map(|e| e.to_string()); + let name = claims + .name() + .and_then(|n| n.get(None)) + .map(|n| n.to_string()); + + // Extract groups from the raw JWT payload (second dot-separated segment). + // The token is already signature-verified above, so we only need to decode + // the payload to read the non-standard `groups` claim. + let groups: Vec = (|| { + use base64::Engine; + let raw = id_token.to_string(); + let payload_b64 = raw.split('.').nth(1)?; + // JWT payloads use URL-safe base64; try without padding first, then + // fall back to the padded variant (some providers add trailing '='). + let payload_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload_b64) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64)) + .ok()?; + let value: serde_json::Value = serde_json::from_slice(&payload_bytes).ok()?; + let arr = value.get("groups")?.as_array()?; + Some( + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect(), + ) + })() + .unwrap_or_default(); + + tracing::info!( + "OIDC login: sub={sub}, groups={groups:?}, admin_groups={:?}", + config.oidc_admin_groups, + ); + + // User provisioning logic. + let user = match provision_user( + &db, + &issuer, + &sub, + email.as_deref(), + name.as_deref(), + &groups, + &config.oidc_admin_groups, + ) + .await + { + Ok(u) => u, + Err(e) => { + tracing::error!("OIDC user provisioning failed: {e}"); + return redirect_login_with_error(i18n.t.login_oidc_error); + } + }; + + // Log the user in. + auth::login(&session, user.id_val()).await?; + + // Clear OIDC session keys. + let _: Option = session + .remove(SESSION_CSRF_STATE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_NONCE) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_PKCE_VERIFIER) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let _: Option = session + .remove(SESSION_REDIRECT_URI) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + + Ok(auth::redirect("/")) +} + +// --------------------------------------------------------------------------- +// User provisioning +// --------------------------------------------------------------------------- + +/// Resolve the role based on OIDC group membership. +/// If `admin_groups` is non-empty and any user group matches, return "admin"; +/// otherwise return "user". +fn resolve_role(groups: &[String], admin_groups: &str) -> &'static str { + if admin_groups.is_empty() { + return auth::Role::User.code(); + } + let admin_set: std::collections::HashSet<&str> = admin_groups + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + if admin_set.is_empty() { + return auth::Role::User.code(); + } + for g in groups { + if admin_set.contains(g.as_str()) { + return auth::Role::Admin.code(); + } + } + auth::Role::User.code() +} + +async fn provision_user( + db: &Database, + issuer: &str, + sub: &str, + email: Option<&str>, + name: Option<&str>, + groups: &[String], + admin_groups: &str, +) -> Result { + let role = resolve_role(groups, admin_groups); + + // 1. Check for existing OIDC link. + if let Some(mut link) = OidcLink::find_by_issuer_sub(db, issuer, sub) + .await + .map_err(|e| format!("DB error finding OIDC link: {e}"))? + { + // Fetch the linked user. + match User::get_by_id(db, link.user_id()).await { + Ok(Some(mut user)) => { + // Update cached claims. + link.update_claims(db, email, name) + .await + .map_err(|e| format!("DB error updating OIDC link: {e}"))?; + + // Always update role on login. + user.update_role(db, role) + .await + .map_err(|e| format!("DB error updating user role: {e}"))?; + + return Ok(user); + } + Ok(None) => { + // User was deleted but the OIDC link is stale — remove it + // and fall through to re-create the user below. + tracing::warn!( + "OIDC link points to deleted user {}; removing stale link", + link.user_id(), + ); + link.delete(db) + .await + .map_err(|e| format!("DB error deleting stale OIDC link: {e}"))?; + } + Err(e) => return Err(format!("DB error fetching user: {e}")), + } + } + + // 2. No existing link — try to find a user by email. + if let Some(email_str) = email { + if let Some(mut user) = User::get_by_email(db, email_str) + .await + .map_err(|e| format!("DB error finding user by email: {e}"))? + { + // Create OIDC link for existing user. + OidcLink::create_link(db, user.id_val(), issuer, sub, email, name) + .await + .map_err(|e| format!("DB error creating OIDC link: {e}"))?; + + user.update_role(db, role) + .await + .map_err(|e| format!("DB error updating user role: {e}"))?; + + return Ok(user); + } + } + + // 3. Create a brand-new user + OIDC link. + // Generate a unique username from the sub or email. + let username = if let Some(email_str) = email { + email_str.split('@').next().unwrap_or(sub).to_owned() + } else { + sub.to_owned() + }; + + // Ensure username uniqueness by appending a suffix if needed. + let mut candidate = username.clone(); + let mut suffix = 0u32; + loop { + match User::get_by_username(db, &candidate).await { + Ok(None) => break, + Ok(Some(_)) => { + suffix += 1; + candidate = format!("{username}_{suffix}"); + } + Err(e) => return Err(format!("DB error checking username: {e}")), + } + } + + let user = User::create_oidc(db, &candidate, email, name, role) + .await + .map_err(|e| format!("DB error creating user: {e}"))?; + + OidcLink::create_link(db, user.id_val(), issuer, sub, email, name) + .await + .map_err(|e| format!("DB error creating OIDC link: {e}"))?; + + Ok(user) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn redirect_login_with_error(message: &str) -> cot::Result { + let encoded = urlencoded(message); + Ok(auth::redirect(&format!("/login?error={encoded}"))) +} + +/// Minimal percent-encoding for query parameter values. +fn urlencoded(s: &str) -> String { + let mut out = String::with_capacity(s.len() * 2); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + out.push(b as char); + } + _ => { + out.push('%'); + out.push_str(&format!("{b:02X}")); + } + } + } + out +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..ad95d0b --- /dev/null +++ b/src/user.rs @@ -0,0 +1,426 @@ +use cot::auth::PasswordHash; +use cot::common_types::Password; +use cot::db::{Auto, Database, LimitedString, Model}; + +// --------------------------------------------------------------------------- +// User model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct User { + #[model(primary_key)] + id: Auto, + #[model(unique)] + username: LimitedString<255>, + password: Option, + email: Option>, + display_name: Option>, + avatar_url: Option, + role: LimitedString<32>, + is_active: bool, +} + +// --------------------------------------------------------------------------- +// User helper methods +// --------------------------------------------------------------------------- + +impl User { + /// List all users. + pub async fn list_all(db: &Database) -> cot::db::Result> { + Self::objects().all(db).await + } + + /// Get a user by primary key. + pub async fn get_by_id(db: &Database, user_id: i64) -> cot::db::Result> { + Self::get_by_primary_key(db, Auto::Fixed(user_id)).await + } + + /// Create a new user and insert it into the database. + pub async fn create( + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + password: &str, + role: &str, + ) -> cot::db::Result { + let hash = PasswordHash::from_password(&Password::new(password)); + let mut user = Self { + id: Auto::auto(), + username: LimitedString::new(username).unwrap(), + password: Some(hash), + email: email.map(|e| LimitedString::new(e).unwrap()), + display_name: display_name.map(|d| LimitedString::new(d).unwrap()), + avatar_url: None, + role: LimitedString::new(role).unwrap(), + is_active: true, + }; + user.insert(db).await?; + Ok(user) + } + + /// Update an existing user. If `new_password` is `Some`, the password hash + /// is replaced; otherwise the existing hash is kept. + pub async fn update_fields( + &mut self, + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + new_password: Option<&str>, + role: &str, + ) -> cot::db::Result<()> { + self.username = LimitedString::new(username).unwrap(); + self.email = email.map(|e| LimitedString::new(e).unwrap()); + self.display_name = display_name.map(|d| LimitedString::new(d).unwrap()); + if let Some(pw) = new_password { + self.password = Some(PasswordHash::from_password(&Password::new(pw))); + } + self.role = LimitedString::new(role).unwrap(); + self.save(db).await + } + + /// Look up a user by username. + pub async fn get_by_username(db: &Database, username: &str) -> cot::db::Result> { + let Ok(username) = LimitedString::<255>::new(username) else { + return Ok(None); + }; + cot::db::query!(User, $username == username).get(db).await + } + + /// Count all users in the database. + pub async fn count_all(db: &Database) -> cot::db::Result { + Self::objects().count(db).await + } + + /// Return a reference to the password hash, if set. + pub fn password_ref(&self) -> Option<&PasswordHash> { + self.password.as_ref() + } + + /// Parse the stored role code into a `Role`, defaulting to `User`. + pub fn role(&self) -> crate::auth::Role { + crate::auth::Role::from_code(&self.role).unwrap_or(crate::auth::Role::User) + } + + /// Delete this user by primary key. + pub async fn delete_by_id(db: &Database, user_id: i64) -> cot::db::Result<()> { + cot::db::query!(User, $id == Auto::Fixed(user_id)).delete(db).await?; + Ok(()) + } + + // Accessor helpers for templates + pub fn id_val(&self) -> i64 { + self.id.unwrap() + } + pub fn username_str(&self) -> &str { + &self.username + } + pub fn email_str(&self) -> String { + self.email.as_ref().map(|e| e.to_string()).unwrap_or_default() + } + pub fn display_name_str(&self) -> String { + self.display_name.as_ref().map(|d| d.to_string()).unwrap_or_default() + } + pub fn role_str(&self) -> &str { + &self.role + } + pub fn is_active(&self) -> bool { + self.is_active + } + + /// Create a user without a password (for OIDC-only accounts). + pub async fn create_oidc( + db: &Database, + username: &str, + email: Option<&str>, + display_name: Option<&str>, + role: &str, + ) -> cot::db::Result { + let mut user = Self { + id: Auto::auto(), + username: LimitedString::new(username).unwrap(), + password: None, + email: email.map(|e| LimitedString::new(e).unwrap()), + display_name: display_name.map(|d| LimitedString::new(d).unwrap()), + avatar_url: None, + role: LimitedString::new(role).unwrap(), + is_active: true, + }; + user.insert(db).await?; + Ok(user) + } + + /// Update the user's role and persist the change. + pub async fn update_role(&mut self, db: &Database, role: &str) -> cot::db::Result<()> { + self.role = LimitedString::new(role).unwrap(); + self.save(db).await + } + + /// Find a user by email address. + pub async fn get_by_email(db: &Database, email: &str) -> cot::db::Result> { + let Ok(email) = LimitedString::<255>::new(email) else { + return Ok(None); + }; + cot::db::query!(User, $email == Some(email)).get(db).await + } +} + +// --------------------------------------------------------------------------- +// OidcLink model +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +#[cot::db::model] +pub struct OidcLink { + #[model(primary_key)] + id: Auto, + user_id: i64, + issuer: LimitedString<255>, + sub: LimitedString<255>, + email: Option>, + name: Option>, + avatar_url: Option, +} + +// --------------------------------------------------------------------------- +// OidcLink helper methods +// --------------------------------------------------------------------------- + +impl OidcLink { + /// Find an OIDC link by issuer + subject. + pub async fn find_by_issuer_sub( + db: &Database, + issuer: &str, + sub: &str, + ) -> cot::db::Result> { + let Ok(issuer) = LimitedString::<255>::new(issuer) else { + return Ok(None); + }; + let Ok(sub) = LimitedString::<255>::new(sub) else { + return Ok(None); + }; + cot::db::query!(OidcLink, $issuer == issuer && $sub == sub) + .get(db) + .await + } + + /// Create a new OIDC link for a user. + pub async fn create_link( + db: &Database, + user_id: i64, + issuer: &str, + sub: &str, + email: Option<&str>, + name: Option<&str>, + ) -> cot::db::Result { + let mut link = Self { + id: Auto::auto(), + user_id, + issuer: LimitedString::new(issuer).unwrap(), + sub: LimitedString::new(sub).unwrap(), + email: email.map(|e| LimitedString::new(e).unwrap()), + name: name.map(|n| LimitedString::new(n).unwrap()), + avatar_url: None, + }; + link.insert(db).await?; + Ok(link) + } + + /// Update cached claims (email, name) on an existing link. + pub async fn update_claims( + &mut self, + db: &Database, + email: Option<&str>, + name: Option<&str>, + ) -> cot::db::Result<()> { + self.email = email.map(|e| LimitedString::new(e).unwrap()); + self.name = name.map(|n| LimitedString::new(n).unwrap()); + self.save(db).await + } + + /// Delete this OIDC link by primary key. + pub async fn delete(self, db: &Database) -> cot::db::Result<()> { + let link_id = self.id; + cot::db::query!(OidcLink, $id == link_id).delete(db).await?; + Ok(()) + } + + /// Accessor for the linked user ID. + pub fn user_id(&self) -> i64 { + self.user_id + } +} + +// --------------------------------------------------------------------------- +// Migrations +// --------------------------------------------------------------------------- + +pub mod db_migrations { + use cot::db::migrations::{self, Field, Operation, SyncDynMigration}; + use cot::db::{DatabaseField, Identifier, LimitedString}; + use cot::auth::PasswordHash; + + // -- M0003: create furumusic__user ------------------------------------- + + #[derive(Debug, Copy, Clone)] + pub struct M0003CreateUser; + + impl migrations::Migration for M0003CreateUser { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0003_create_user"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ + migrations::MigrationDependency::migration( + "furumusic", + "m_0002_rename_config_table", + ), + ]; + const OPERATIONS: &'static [Operation] = &[ + Operation::create_model() + .table_name(Identifier::new("furumusic__user")) + .fields(&[ + Field::new( + Identifier::new("id"), + ::TYPE, + ) + .primary_key() + .auto(), + Field::new( + Identifier::new("username"), + as DatabaseField>::TYPE, + ) + .unique(), + Field::new( + Identifier::new("password"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("email"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("display_name"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("role"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("is_active"), + ::TYPE, + ), + ]) + .build(), + ]; + } + + // -- M0004: create furumusic__oidc_link -------------------------------- + + #[derive(Debug, Copy, Clone)] + pub struct M0004CreateOidcLink; + + impl migrations::Migration for M0004CreateOidcLink { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0004_create_oidc_link"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ + migrations::MigrationDependency::migration( + "furumusic", + "m_0003_create_user", + ), + ]; + const OPERATIONS: &'static [Operation] = &[ + Operation::create_model() + .table_name(Identifier::new("furumusic__oidc_link")) + .fields(&[ + Field::new( + Identifier::new("id"), + ::TYPE, + ) + .primary_key() + .auto(), + Field::new( + Identifier::new("user_id"), + ::TYPE, + ), + Field::new( + Identifier::new("issuer"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("sub"), + as DatabaseField>::TYPE, + ), + Field::new( + Identifier::new("email"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("name"), + as DatabaseField>::TYPE, + ) + .set_null(true), + Field::new( + Identifier::new("avatar_url"), + ::TYPE, + ) + .set_null(true), + ]) + .build(), + ]; + } + + // -- M0005: indexes on furumusic__oidc_link ---------------------------- + + #[cot::db::migrations::migration_op] + async fn create_oidc_link_indexes( + ctx: migrations::MigrationContext<'_>, + ) -> cot::db::Result<()> { + ctx.db + .raw( + "CREATE UNIQUE INDEX idx_oidc_link_issuer_sub \ + ON furumusic__oidc_link (issuer, sub)", + ) + .await?; + ctx.db + .raw( + "CREATE INDEX idx_oidc_link_user_id \ + ON furumusic__oidc_link (user_id)", + ) + .await?; + Ok(()) + } + + #[derive(Debug, Copy, Clone)] + pub struct M0005OidcLinkIndexes; + + impl migrations::Migration for M0005OidcLinkIndexes { + const APP_NAME: &'static str = "furumusic"; + const MIGRATION_NAME: &'static str = "m_0005_oidc_link_indexes"; + const DEPENDENCIES: &'static [migrations::MigrationDependency] = &[ + migrations::MigrationDependency::migration( + "furumusic", + "m_0004_create_oidc_link", + ), + ]; + const OPERATIONS: &'static [Operation] = &[ + Operation::custom(create_oidc_link_indexes).build(), + ]; + } + + pub const MIGRATIONS: &[&SyncDynMigration] = &[ + &M0003CreateUser, + &M0004CreateOidcLink, + &M0005OidcLinkIndexes, + ]; +} diff --git a/templates/admin/debug.html b/templates/admin/debug.html new file mode 100644 index 0000000..25f087c --- /dev/null +++ b/templates/admin/debug.html @@ -0,0 +1,82 @@ +{% extends "admin/layout.html" %} + +{% block admin_title %}{{ t.nav_debug }}{% endblock admin_title %} + +{% block content %} + + +

{{ t.debug_heading }}

+ +

{{ t.debug_build_info }}

+ + + + + + + + +
{{ t.debug_field }}{{ t.debug_value }}
Package{{ build.pkg_name }}
Version{{ build.pkg_version }}
Profile{{ build.profile }}
Target{{ build.target }}
Rustc{{ build.rustc_version }}
{{ t.debug_db_status }}{{ db_status }}
+ +

{{ t.debug_app_config }}

+ + + + + + + {% for entry in config_entries %} + + + + + + {% endfor %} +
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
+ + {{ entry.key }} + i +
+
env var
+
{{ entry.env_var }}
+
default
+
{% if entry.default_value == "" %}(empty){% else %}{{ entry.default_value }}{% endif %}
+
source
+
{{ entry.source }}
+
+
+
{{ entry.value }}{{ entry.source }}
+{% endblock content %} diff --git a/templates/admin/index.html b/templates/admin/index.html new file mode 100644 index 0000000..8d1933d --- /dev/null +++ b/templates/admin/index.html @@ -0,0 +1,8 @@ +{% extends "admin/layout.html" %} + +{% block admin_title %}{{ t.nav_dashboard }}{% endblock admin_title %} + +{% block content %} +

{{ t.admin_heading }}

+

{{ t.admin_debug_link }}

+{% endblock content %} diff --git a/templates/admin/layout.html b/templates/admin/layout.html new file mode 100644 index 0000000..3b439e4 --- /dev/null +++ b/templates/admin/layout.html @@ -0,0 +1,57 @@ +{% extends "base.html" %} + +{% block title %}{% block admin_title %}{{ t.nav_admin }}{% endblock admin_title %} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block body %} + +
+
+ +
+ EN + RU +
+ {{ t.nav_logout }} +
+
+ {% block content %}{% endblock content %} +
+
+{% endblock body %} diff --git a/templates/admin/settings.html b/templates/admin/settings.html new file mode 100644 index 0000000..39fa674 --- /dev/null +++ b/templates/admin/settings.html @@ -0,0 +1,73 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}{{ t.nav_settings }}{% endblock admin_title %} + +{% block content %} +

{{ t.settings_heading }}

+ +{% if saved %} +

{{ t.settings_saved }}

+{% endif %} + +
+ +

{{ t.settings_auth }}

+ + + + + + + + + + + + + + + + +
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
{{ auth_password_enabled_source }}
{{ auth_sso_enabled_source }}
+ +

{{ t.settings_oidc }}

+

{{ t.settings_oidc_help }}

+

+ {{ t.settings_oidc_callback }}: + +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ t.debug_field }}{{ t.debug_value }}{{ t.debug_source }}
{{ oidc_button_text_source }}

{{ t.settings_oidc_issuer_help }}
{{ oidc_issuer_source }}
{{ oidc_client_id_source }}
{{ oidc_client_secret_source }}

{{ t.settings_oidc_admin_groups_help }}
{{ oidc_admin_groups_source }}
+ +
+{% endblock content %} diff --git a/templates/admin/setup.html b/templates/admin/setup.html new file mode 100644 index 0000000..c19d9ef --- /dev/null +++ b/templates/admin/setup.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block title %}{{ t.setup_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block body %} +
+

{{ t.setup_heading }}

+ + {% if !message.is_empty() %} +
{{ message }}
+ {% endif %} + +
+ + + + + + + +
+
+{% endblock body %} diff --git a/templates/admin/user_form.html b/templates/admin/user_form.html new file mode 100644 index 0000000..9014f70 --- /dev/null +++ b/templates/admin/user_form.html @@ -0,0 +1,40 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}{% endblock admin_title %} + +{% block content %} +

{% if is_edit %}{{ t.users_edit_heading }}{% else %}{{ t.users_new_heading }}{% endif %}

+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + {% if is_edit %}
{{ t.users_password_hint }}{% endif %} +
+ +
+ +
+{% endblock content %} diff --git a/templates/admin/users.html b/templates/admin/users.html new file mode 100644 index 0000000..bbe1b84 --- /dev/null +++ b/templates/admin/users.html @@ -0,0 +1,37 @@ +{% extends "admin/layout.html" %} +{% block admin_title %}{{ t.nav_users }}{% endblock admin_title %} + +{% block content %} +

{{ t.users_heading }}

+ +

+ {{ t.users_add }} +

+ + + + + + + + + + + {% for u in users %} + + + + + + + + + {% endfor %} +
{{ t.users_username }}{{ t.users_email }}{{ t.users_display_name }}{{ t.users_role }}{{ t.users_active }}{{ t.users_actions }}
{{ u.username_str() }}{{ u.email_str() }}{{ u.display_name_str() }}{{ u.role_str() }}{{ u.is_active() }} + {{ t.users_edit }} +  |  +
+ +
+
+{% endblock content %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5028260 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,14 @@ + + + + + + {% block title %}{{ t.site_name }}{% endblock title %} + {% block head_extra %}{% endblock head_extra %} + + +{% block body %} + {% block content %}{% endblock content %} +{% endblock body %} + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..50ed686 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,56 @@ +{% extends "base.html" %} + +{% block title %}{{ t.login_heading }} | {{ t.site_name }}{% endblock title %} + +{% block head_extra %} + +{% endblock head_extra %} + +{% block body %} + +{% endblock body %}