commit c7a592933e50bb09c6cec81d2985a128571036f0 Author: pavel Date: Fri Feb 13 17:45:29 2026 +0100 init diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ccc88d2 --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/chattz + +# Authentik OIDC app values +OIDC_CLIENT_ID=replace-me +OIDC_CLIENT_SECRET=replace-me +OIDC_AUTHORIZE_URL=https://auth.example.com/application/o/authorize/ +OIDC_TOKEN_URL=https://auth.example.com/application/o/token/ +OIDC_USERINFO_URL=https://auth.example.com/application/o/userinfo/ +OIDC_REDIRECT_URL=http://localhost:3000/auth/callback +OIDC_SCOPES="openid profile email" + +# WebRTC ICE servers (comma-separated). STUN defaults to Google STUN if omitted. +STUN_URLS=stun:stun.l.google.com:19302 +TURN_URLS=turn:turn.example.com:3478?transport=udp,turn:turn.example.com:3478?transport=tcp +TURN_USERNAME=replace-me +TURN_PASSWORD=replace-me + +# 32+ random chars; used to sign session cookies +SESSION_SECRET=replace-with-long-random-secret +COOKIE_SECURE=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0b745e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +.env \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..bcfbb24 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3302 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aliasable" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd" + +[[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 = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +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 = "chattz" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "dotenvy", + "futures-util", + "jsonwebtoken", + "reqwest", + "sea-orm", + "sea-orm-migration", + "serde", + "serde_json", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "urlencoding", + "uuid", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[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 = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[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-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[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 = [ + "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 = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[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 = "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 = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-executor" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +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.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[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.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[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 = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.6", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "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.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[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.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[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.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[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.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[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 = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[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.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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 = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ouroboros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0f050db9c44b97a94723127e6be766ac5c340c48f2c4bb3ffa11713744be59" +dependencies = [ + "aliasable", + "ouroboros_macro", + "static_assertions", +] + +[[package]] +name = "ouroboros_macro" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c7028bdd3d43083f6d8d4d5187680d0d3560d54df4cc9d752005268b41e64d0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[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.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +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 = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn", +] + +[[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 = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[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", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "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 1.0.6", +] + +[[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-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[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.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +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 = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sea-bae" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" +dependencies = [ + "heck 0.4.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sea-orm" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d945f62558fac19e5988680d2fdf747b734c2dbc6ce2cb81ba33ed8dde5b103" +dependencies = [ + "async-stream", + "async-trait", + "chrono", + "derive_more", + "futures-util", + "log", + "ouroboros", + "sea-orm-macros", + "sea-query", + "sea-query-binder", + "serde", + "sqlx", + "strum", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "sea-orm-cli" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c94492e2ab6c045b4cc38013809ce255d14c3d352c9f0d11e6b920e2adc948ad" +dependencies = [ + "chrono", + "glob", + "regex", + "sea-schema", + "sqlx", + "tokio", + "tracing", + "tracing-subscriber", + "url", +] + +[[package]] +name = "sea-orm-macros" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c2e64a50a9cc8339f10a27577e10062c7f995488e469f2c95762c5ee847832" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "sea-bae", + "syn", + "unicode-ident", +] + +[[package]] +name = "sea-orm-migration" +version = "1.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7315c0cadb7e60fb17ee2bb282aa27d01911fc2a7e5836ec1d4ac37d19250bb4" +dependencies = [ + "async-trait", + "sea-orm", + "sea-orm-cli", + "sea-schema", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "sea-query" +version = "0.32.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a5d1c518eaf5eda38e5773f902b26ab6d5e9e9e2bb2349ca6c64cf96f80448c" +dependencies = [ + "chrono", + "inherent", + "ordered-float", + "sea-query-derive", + "uuid", +] + +[[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", + "uuid", +] + +[[package]] +name = "sea-query-derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae0cbad6ab996955664982739354128c58d16e126114fe88c2a493642502aab" +dependencies = [ + "darling", + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", + "thiserror", +] + +[[package]] +name = "sea-schema" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2239ff574c04858ca77485f112afea1a15e53135d3097d0c86509cef1def1338" +dependencies = [ + "futures", + "sea-query", + "sea-query-binder", + "sea-schema-derive", + "sqlx", +] + +[[package]] +name = "sea-schema-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "debdc8729c37fdbf88472f97fd470393089f997a909e535ff67c544d18cfccf0" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[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_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_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_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "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 = "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 = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.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", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[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 0.5.0", + "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", + "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.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "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.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" + +[[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.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" +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 = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +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 = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "iri-string", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[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.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" + +[[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-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", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.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.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +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.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +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 = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +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..4d48561 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "chattz" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["macros", "ws"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +jsonwebtoken = "9" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +sea-orm = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls", "macros", "with-chrono", "with-uuid"] } +sea-orm-migration = { version = "1.1", default-features = false, features = ["sqlx-postgres", "runtime-tokio-rustls"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +futures-util = "0.3" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } +tower-http = { version = "0.6", features = ["trace", "fs"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +urlencoding = "2" +uuid = { version = "1", features = ["v4", "serde"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..836399f --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Chattz + +A simple single-instance Discord-style monolith in Rust using: +- `axum` HTTP server +- `SeaORM` + PostgreSQL for persistence +- Authentik OIDC for login + +## What this includes + +- OIDC login flow (`/auth/login`, `/auth/callback`, `/auth/logout`) +- Signed session cookie auth +- Channel voice chat over WebRTC (P2P mesh) with server WebSocket signaling +- Guild invite codes (create + join) +- Direct messages (DM) between users +- Core resources: + - users + - guilds + guild membership + - channels + - messages + - direct_messages +- Basic JSON APIs for creating guilds/channels and posting/listing messages + +## Quick start + +1. Create DB and apply schema: +1. Create DB: + +```sql +CREATE DATABASE chattz; +``` + +2. Configure env: + +```bash +cp .env.example .env +# edit .env values +``` + +For voice reliability on restrictive networks, configure TURN in `.env`: +- `TURN_URLS` +- `TURN_USERNAME` +- `TURN_PASSWORD` + +3. Run app: + +```bash +cargo run +``` + +Migrations are applied automatically during startup. + +Server starts on `http://localhost:3000`. +Web UI is available at `http://localhost:3000/`. + +## Authentik setup notes + +Create an Authentik OAuth2/OIDC provider + application and set: +- Redirect URI: `http://localhost:3000/auth/callback` +- Scopes including at least: `openid profile email` + +Then copy provider endpoints into env: +- `OIDC_AUTHORIZE_URL` +- `OIDC_TOKEN_URL` +- `OIDC_USERINFO_URL` + +For Authentik these are commonly under `/application/o/...` for the app slug. + +## API summary + +- `GET /health` +- `GET /auth/login` +- `GET /auth/callback?code=...&state=...` +- `POST /auth/logout` +- `GET /me` +- `GET /dms` +- `GET /dms/:other_user_id/messages?limit=50` +- `POST /dms/:other_user_id/messages` body: `{ "body": "hello" }` +- `GET /rtc-config` +- `GET /guilds` +- `POST /guilds` body: `{ "name": "My Guild" }` +- `GET /guilds/:guild_id/members` +- `GET /guilds/:guild_id/voice-presence` +- `POST /guilds/:guild_id/invites` body: `{ "max_uses": 50, "expires_in_hours": 24 }` +- `POST /invites/:code/join` +- `GET /guilds/:guild_id/channels` +- `POST /channels` body: `{ "guild_id": "...", "name": "general", "kind": "text|voice" }` +- `GET /channels/:channel_id/messages?limit=50` +- `POST /channels/:channel_id/messages` body: `{ "body": "hello" }` +- `GET /channels/:channel_id/voice/ws` (WebSocket signaling) + +All endpoints except health and auth flow require the session cookie from successful login. + +## Notes + +This is intentionally minimal and monolithic (single process, single Postgres instance). +Voice is implemented as browser-to-browser WebRTC audio with signaling in this server. +For two users behind strict NAT/firewall, you may need TURN for reliable connectivity. +The web UI remembers the last selected guild in browser local storage and auto-selects it on reload. + +Mic filter modes in the UI: +- `NSNet2 (Compat)`: always-on denoising mode (implemented using DeepFilterNet3 with lighter suppression preset) + +Noise processing requires browsers with `AudioWorklet` support (modern Chrome/Edge/Firefox). diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql new file mode 100644 index 0000000..1ede373 --- /dev/null +++ b/migrations/0001_init.sql @@ -0,0 +1,44 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + oidc_sub TEXT UNIQUE NOT NULL, + email TEXT, + display_name TEXT NOT NULL, + avatar_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE guilds ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + owner_user_id UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE guild_members ( + guild_id UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (guild_id, user_id) +); + +CREATE TABLE channels ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + guild_id UUID NOT NULL REFERENCES guilds(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + author_user_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX idx_guild_members_user_id ON guild_members(user_id); +CREATE INDEX idx_channels_guild_id ON channels(guild_id); +CREATE INDEX idx_messages_channel_created_at ON messages(channel_id, created_at DESC); diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..66fd67a --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,201 @@ +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{Context, Result, anyhow}; +use axum::{ + Json, + extract::{FromRef, FromRequestParts}, + http::{StatusCode, request::Parts}, + response::{IntoResponse, Response}, +}; +use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation, decode, encode}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{AppState, db}; + +const SESSION_COOKIE: &str = "chattz_session"; +const OAUTH_STATE_COOKIE: &str = "chattz_oauth_state"; +const SESSION_TTL_SECS: u64 = 60 * 60 * 24 * 7; + +#[derive(Debug, Serialize, Deserialize, Clone)] +struct SessionClaims { + sub: String, + exp: usize, + iat: usize, +} + +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: Uuid, +} + +#[derive(Debug)] +pub struct ApiError { + pub status: StatusCode, + pub message: String, +} + +impl ApiError { + pub fn unauthorized(msg: &str) -> Self { + Self { + status: StatusCode::UNAUTHORIZED, + message: msg.to_string(), + } + } + + pub fn bad_request(msg: &str) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + message: msg.to_string(), + } + } + + pub fn internal(msg: &str) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + message: msg.to_string(), + } + } +} + +#[derive(Serialize)] +struct ErrorBody<'a> { + error: &'a str, +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.status, Json(ErrorBody { error: &self.message })).into_response() + } +} + +impl From for ApiError { + fn from(err: anyhow::Error) -> Self { + Self::internal(&err.to_string()) + } +} + +impl FromRequestParts for AuthUser +where + AppState: axum::extract::FromRef, + S: Send + Sync, +{ + type Rejection = ApiError; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app = AppState::from_ref(state); + let token = read_cookie(parts, SESSION_COOKIE).ok_or_else(|| ApiError::unauthorized("missing session"))?; + let user_id = verify_session(&token, &app.settings.session_secret) + .map_err(|_| ApiError::unauthorized("invalid session"))?; + + let exists = db::user_exists(&app.db, user_id) + .await + .map_err(|_| ApiError::unauthorized("session user not found"))?; + if !exists { + return Err(ApiError::unauthorized("session user not found")); + } + + Ok(Self { id: user_id }) + } +} + +pub fn new_oauth_state() -> String { + Uuid::new_v4().to_string() +} + +pub fn make_oauth_state_cookie(value: &str, secure: bool) -> String { + format!( + "{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=600{secure_flag}", + name = OAUTH_STATE_COOKIE, + secure_flag = if secure { "; Secure" } else { "" } + ) +} + +pub fn clear_oauth_state_cookie(secure: bool) -> String { + format!( + "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", + name = OAUTH_STATE_COOKIE, + secure_flag = if secure { "; Secure" } else { "" } + ) +} + +pub fn read_oauth_state_from_headers(headers: &axum::http::HeaderMap) -> Option { + let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + raw.split(';').find_map(|pair| { + let mut kv = pair.trim().splitn(2, '='); + let key = kv.next()?; + let value = kv.next()?; + (key == OAUTH_STATE_COOKIE).then(|| value.to_string()) + }) +} + +pub fn new_session_cookie(user_id: Uuid, secret: &str, secure: bool) -> Result { + let now = now_ts(); + let claims = SessionClaims { + sub: user_id.to_string(), + iat: now as usize, + exp: (now + SESSION_TTL_SECS) as usize, + }; + + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .context("failed to encode session token")?; + + Ok(format!( + "{name}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={ttl}{secure_flag}", + name = SESSION_COOKIE, + ttl = SESSION_TTL_SECS, + secure_flag = if secure { "; Secure" } else { "" } + )) +} + +pub fn clear_session_cookie(secure: bool) -> String { + format!( + "{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0{secure_flag}", + name = SESSION_COOKIE, + secure_flag = if secure { "; Secure" } else { "" } + ) +} + +fn verify_session(token: &str, secret: &str) -> Result { + let mut validation = Validation::default(); + validation.validate_exp = true; + + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &validation, + ) + .context("failed to decode session token")?; + + let user_id = Uuid::parse_str(&data.claims.sub).context("invalid sub in session token")?; + Ok(user_id) +} + +pub fn validate_oauth_state(expected_cookie: Option, query_state: &str) -> Result<()> { + let expected = expected_cookie.ok_or_else(|| anyhow!("missing oauth state cookie"))?; + if expected != query_state { + return Err(anyhow!("invalid oauth state")); + } + Ok(()) +} + +fn read_cookie(parts: &Parts, name: &str) -> Option { + let raw = parts.headers.get(axum::http::header::COOKIE)?.to_str().ok()?; + raw.split(';').find_map(|pair| { + let mut kv = pair.trim().splitn(2, '='); + let key = kv.next()?; + let value = kv.next()?; + (key == name).then(|| value.to_string()) + }) +} + +fn now_ts() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_else(|_| Duration::from_secs(0)) + .as_secs() +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..4887314 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,63 @@ +use anyhow::{Context, Result}; + +#[derive(Clone, Debug)] +pub struct Settings { + pub database_url: String, + pub oidc_client_id: String, + pub oidc_client_secret: String, + pub oidc_authorize_url: String, + pub oidc_token_url: String, + pub oidc_userinfo_url: String, + pub oidc_redirect_url: String, + pub oidc_scopes: String, + pub session_secret: String, + pub cookie_secure: bool, + pub stun_urls: Vec, + pub turn_urls: Vec, + pub turn_username: Option, + pub turn_password: Option, +} + +impl Settings { + pub fn from_env() -> Result { + Ok(Self { + database_url: required("DATABASE_URL")?, + oidc_client_id: required("OIDC_CLIENT_ID")?, + oidc_client_secret: required("OIDC_CLIENT_SECRET")?, + oidc_authorize_url: required("OIDC_AUTHORIZE_URL")?, + oidc_token_url: required("OIDC_TOKEN_URL")?, + oidc_userinfo_url: required("OIDC_USERINFO_URL")?, + oidc_redirect_url: required("OIDC_REDIRECT_URL")?, + oidc_scopes: std::env::var("OIDC_SCOPES").unwrap_or_else(|_| "openid profile email".to_string()), + session_secret: required("SESSION_SECRET")?, + cookie_secure: std::env::var("COOKIE_SECURE") + .unwrap_or_else(|_| "false".into()) + .parse() + .context("COOKIE_SECURE must be true/false")?, + stun_urls: parse_csv_env("STUN_URLS", "stun:stun.l.google.com:19302"), + turn_urls: parse_csv_env("TURN_URLS", ""), + turn_username: optional("TURN_USERNAME"), + turn_password: optional("TURN_PASSWORD"), + }) + } +} + +fn required(name: &str) -> Result { + std::env::var(name).with_context(|| format!("missing env var {name}")) +} + +fn optional(name: &str) -> Option { + std::env::var(name) + .ok() + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) +} + +fn parse_csv_env(name: &str, default_value: &str) -> Vec { + let raw = std::env::var(name).unwrap_or_else(|_| default_value.to_string()); + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect() +} diff --git a/src/db.rs b/src/db.rs new file mode 100644 index 0000000..df877ed --- /dev/null +++ b/src/db.rs @@ -0,0 +1,505 @@ +use anyhow::{Result, anyhow}; +use chrono::{Duration, Utc}; +use sea_orm::{ + ActiveModelTrait, ActiveValue::Set, ColumnTrait, DatabaseConnection, + Condition, DatabaseTransaction, EntityTrait, PaginatorTrait, QueryFilter, QueryOrder, QuerySelect, + TransactionTrait, sea_query::OnConflict, +}; +use uuid::Uuid; + +use crate::{ + entity::{channels, direct_messages, guild_members, guilds, invites, messages, users}, + models::{BasicUser, Channel, DmConversation, DmMessageWithAuthor, Guild, Invite, MessageWithAuthor, User}, +}; + +pub const CHANNEL_KIND_TEXT: &str = "text"; +pub const CHANNEL_KIND_VOICE: &str = "voice"; + +pub async fn user_exists(db: &DatabaseConnection, user_id: Uuid) -> Result { + let count = users::Entity::find_by_id(user_id).count(db).await?; + Ok(count > 0) +} + +pub async fn upsert_user_from_oidc( + db: &DatabaseConnection, + oidc_sub: &str, + email: Option<&str>, + display_name: &str, + avatar_url: Option<&str>, +) -> Result { + let user = users::Entity::insert(users::ActiveModel { + id: Set(Uuid::new_v4()), + oidc_sub: Set(oidc_sub.to_string()), + email: Set(email.map(ToString::to_string)), + display_name: Set(display_name.to_string()), + avatar_url: Set(avatar_url.map(ToString::to_string)), + ..Default::default() + }) + .on_conflict( + OnConflict::column(users::Column::OidcSub) + .update_columns([ + users::Column::Email, + users::Column::DisplayName, + users::Column::AvatarUrl, + ]) + .value(users::Column::UpdatedAt, sea_orm::sea_query::Expr::current_timestamp()) + .to_owned(), + ) + .exec_with_returning(db) + .await?; + + Ok(map_user(user)) +} + +pub async fn get_user_by_id(db: &DatabaseConnection, user_id: Uuid) -> Result> { + let row = users::Entity::find_by_id(user_id).one(db).await?; + Ok(row.map(map_user)) +} + +pub async fn list_guild_members(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let rows = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.eq(guild_id)) + .find_also_related(users::Entity) + .order_by_asc(guild_members::Column::CreatedAt) + .all(db) + .await?; + + Ok(rows + .into_iter() + .filter_map(|(_, user)| user) + .map(|u| BasicUser { + id: u.id, + display_name: u.display_name, + avatar_url: u.avatar_url, + }) + .collect()) +} + +pub async fn list_guilds_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result> { + let rows = guild_members::Entity::find() + .filter(guild_members::Column::UserId.eq(user_id)) + .find_also_related(guilds::Entity) + .all(db) + .await?; + + Ok(rows + .into_iter() + .filter_map(|(_, guild)| guild.map(map_guild)) + .collect()) +} + +pub async fn create_guild(db: &DatabaseConnection, owner_user_id: Uuid, name: &str) -> Result { + let guild = guilds::Entity::insert(guilds::ActiveModel { + id: Set(Uuid::new_v4()), + name: Set(name.to_string()), + owner_user_id: Set(owner_user_id), + ..Default::default() + }) + .exec_with_returning(db) + .await?; + + guild_members::Entity::insert(guild_members::ActiveModel { + guild_id: Set(guild.id), + user_id: Set(owner_user_id), + ..Default::default() + }) + .on_conflict( + OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) + .do_nothing() + .to_owned(), + ) + .exec(db) + .await?; + + Ok(map_guild(guild)) +} + +pub async fn create_invite( + db: &DatabaseConnection, + guild_id: Uuid, + created_by_user_id: Uuid, + max_uses: Option, + expires_in_hours: Option, +) -> Result { + let expires_at = expires_in_hours.map(|h| (Utc::now() + Duration::hours(h)).fixed_offset()); + + for _ in 0..8 { + let code = generate_invite_code(); + let insert = invites::Entity::insert(invites::ActiveModel { + code: Set(code.clone()), + guild_id: Set(guild_id), + created_by_user_id: Set(created_by_user_id), + expires_at: Set(expires_at), + max_uses: Set(max_uses), + ..Default::default() + }) + .exec(db) + .await; + + match insert { + Ok(_) => { + let row = invites::Entity::find_by_id(code) + .one(db) + .await? + .ok_or_else(|| anyhow!("created invite missing"))?; + return Ok(map_invite(row)); + } + Err(err) if err.to_string().contains("duplicate key") => continue, + Err(err) => return Err(err.into()), + } + } + + Err(anyhow!("failed to generate unique invite code")) +} + +pub async fn join_invite(db: &DatabaseConnection, code: &str, user_id: Uuid) -> Result { + let txn = db.begin().await?; + + let invite = invites::Entity::find_by_id(code.to_string()) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("invite not found"))?; + + validate_invite(&invite)?; + let guild_id = invite.guild_id; + + let already_member = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.eq(invite.guild_id)) + .filter(guild_members::Column::UserId.eq(user_id)) + .one(&txn) + .await? + .is_some(); + + guild_members::Entity::insert(guild_members::ActiveModel { + guild_id: Set(invite.guild_id), + user_id: Set(user_id), + ..Default::default() + }) + .on_conflict( + OnConflict::columns([guild_members::Column::GuildId, guild_members::Column::UserId]) + .do_nothing() + .to_owned(), + ) + .exec(&txn) + .await?; + + if !already_member { + increment_invite_use_count(&txn, invite).await?; + } + + let guild = guilds::Entity::find_by_id(guild_id) + .one(&txn) + .await? + .ok_or_else(|| anyhow!("guild for invite not found"))?; + + txn.commit().await?; + Ok(map_guild(guild)) +} + +pub async fn create_channel( + db: &DatabaseConnection, + guild_id: Uuid, + name: &str, + kind: &str, +) -> Result { + let channel = channels::Entity::insert(channels::ActiveModel { + id: Set(Uuid::new_v4()), + guild_id: Set(guild_id), + name: Set(name.to_string()), + kind: Set(kind.to_string()), + ..Default::default() + }) + .exec_with_returning(db) + .await?; + + Ok(map_channel(channel)) +} + +pub async fn get_channel_by_id(db: &DatabaseConnection, channel_id: Uuid) -> Result> { + let row = channels::Entity::find_by_id(channel_id).one(db).await?; + Ok(row.map(map_channel)) +} + +pub async fn list_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let channels = channels::Entity::find() + .filter(channels::Column::GuildId.eq(guild_id)) + .order_by_asc(channels::Column::CreatedAt) + .all(db) + .await?; + + Ok(channels.into_iter().map(map_channel).collect()) +} + +pub async fn list_voice_channels_for_guild(db: &DatabaseConnection, guild_id: Uuid) -> Result> { + let channels = channels::Entity::find() + .filter(channels::Column::GuildId.eq(guild_id)) + .filter(channels::Column::Kind.eq(CHANNEL_KIND_VOICE)) + .order_by_asc(channels::Column::CreatedAt) + .all(db) + .await?; + + Ok(channels.into_iter().map(map_channel).collect()) +} + +pub async fn is_member_of_guild(db: &DatabaseConnection, guild_id: Uuid, user_id: Uuid) -> Result { + let count = guild_members::Entity::find() + .filter(guild_members::Column::GuildId.eq(guild_id)) + .filter(guild_members::Column::UserId.eq(user_id)) + .count(db) + .await?; + + Ok(count > 0) +} + +pub async fn guild_id_for_channel(db: &DatabaseConnection, channel_id: Uuid) -> Result> { + let guild_id = channels::Entity::find_by_id(channel_id) + .select_only() + .column(channels::Column::GuildId) + .into_tuple::() + .one(db) + .await?; + + Ok(guild_id) +} + +pub async fn create_message( + db: &DatabaseConnection, + channel_id: Uuid, + author_user_id: Uuid, + body: &str, +) -> Result<()> { + messages::Entity::insert(messages::ActiveModel { + id: Set(Uuid::new_v4()), + channel_id: Set(channel_id), + author_user_id: Set(author_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec(db) + .await?; + + Ok(()) +} + +pub async fn list_messages( + db: &DatabaseConnection, + channel_id: Uuid, + limit: i64, +) -> Result> { + let rows = messages::Entity::find() + .filter(messages::Column::ChannelId.eq(channel_id)) + .find_also_related(users::Entity) + .order_by_desc(messages::Column::CreatedAt) + .limit(limit as u64) + .all(db) + .await?; + + Ok(rows + .into_iter() + .map(|(msg, user)| { + let author_display_name = user + .map(|u| u.display_name) + .unwrap_or_else(|| "Unknown User".to_string()); + MessageWithAuthor { + id: msg.id, + channel_id: msg.channel_id, + author_user_id: msg.author_user_id, + author_display_name, + body: msg.body, + created_at: msg.created_at, + } + }) + .collect()) +} + +pub async fn create_direct_message( + db: &DatabaseConnection, + sender_user_id: Uuid, + recipient_user_id: Uuid, + body: &str, +) -> Result<()> { + direct_messages::Entity::insert(direct_messages::ActiveModel { + id: Set(Uuid::new_v4()), + sender_user_id: Set(sender_user_id), + recipient_user_id: Set(recipient_user_id), + body: Set(body.to_string()), + ..Default::default() + }) + .exec(db) + .await?; + + Ok(()) +} + +pub async fn list_direct_messages( + db: &DatabaseConnection, + current_user_id: Uuid, + other_user_id: Uuid, + limit: i64, +) -> Result> { + let rows = direct_messages::Entity::find() + .filter( + Condition::any() + .add( + Condition::all() + .add(direct_messages::Column::SenderUserId.eq(current_user_id)) + .add(direct_messages::Column::RecipientUserId.eq(other_user_id)), + ) + .add( + Condition::all() + .add(direct_messages::Column::SenderUserId.eq(other_user_id)) + .add(direct_messages::Column::RecipientUserId.eq(current_user_id)), + ), + ) + .order_by_desc(direct_messages::Column::CreatedAt) + .limit(limit as u64) + .all(db) + .await?; + + let mut author_ids = rows.iter().map(|m| m.sender_user_id).collect::>(); + author_ids.sort_unstable(); + author_ids.dedup(); + + let authors = users::Entity::find() + .filter(users::Column::Id.is_in(author_ids)) + .all(db) + .await?; + let author_names: std::collections::HashMap = authors + .into_iter() + .map(|u| (u.id, u.display_name)) + .collect(); + + Ok(rows + .into_iter() + .map(|msg| DmMessageWithAuthor { + id: msg.id, + author_user_id: msg.sender_user_id, + recipient_user_id: msg.recipient_user_id, + author_display_name: author_names + .get(&msg.sender_user_id) + .cloned() + .unwrap_or_else(|| "Unknown User".to_string()), + body: msg.body, + created_at: msg.created_at, + }) + .collect()) +} + +pub async fn list_dm_conversations(db: &DatabaseConnection, current_user_id: Uuid) -> Result> { + let rows = direct_messages::Entity::find() + .filter( + Condition::any() + .add(direct_messages::Column::SenderUserId.eq(current_user_id)) + .add(direct_messages::Column::RecipientUserId.eq(current_user_id)), + ) + .order_by_desc(direct_messages::Column::CreatedAt) + .all(db) + .await?; + + let mut latest_by_peer = std::collections::HashMap::>::new(); + for row in rows { + let peer_id = if row.sender_user_id == current_user_id { + row.recipient_user_id + } else { + row.sender_user_id + }; + latest_by_peer.entry(peer_id).or_insert(row.created_at); + } + + if latest_by_peer.is_empty() { + return Ok(Vec::new()); + } + + let peer_ids: Vec = latest_by_peer.keys().copied().collect(); + let peers = users::Entity::find() + .filter(users::Column::Id.is_in(peer_ids)) + .all(db) + .await?; + + let mut conversations: Vec = peers + .into_iter() + .filter_map(|u| { + let last = latest_by_peer.get(&u.id)?; + Some(DmConversation { + user_id: u.id, + display_name: u.display_name, + avatar_url: u.avatar_url, + last_message_at: *last, + }) + }) + .collect(); + + conversations.sort_by(|a, b| b.last_message_at.cmp(&a.last_message_at)); + Ok(conversations) +} + +fn map_user(model: users::Model) -> User { + User { + id: model.id, + oidc_sub: model.oidc_sub, + email: model.email, + display_name: model.display_name, + avatar_url: model.avatar_url, + created_at: model.created_at, + updated_at: model.updated_at, + } +} + +fn map_guild(model: guilds::Model) -> Guild { + Guild { + id: model.id, + name: model.name, + owner_user_id: model.owner_user_id, + created_at: model.created_at, + } +} + +fn map_channel(model: channels::Model) -> Channel { + Channel { + id: model.id, + guild_id: model.guild_id, + name: model.name, + kind: model.kind, + created_at: model.created_at, + } +} + +fn map_invite(model: invites::Model) -> Invite { + Invite { + code: model.code, + guild_id: model.guild_id, + created_by_user_id: model.created_by_user_id, + created_at: model.created_at, + expires_at: model.expires_at, + max_uses: model.max_uses, + use_count: model.use_count, + } +} + +fn generate_invite_code() -> String { + Uuid::new_v4().simple().to_string()[..10].to_uppercase() +} + +fn validate_invite(invite: &invites::Model) -> Result<()> { + if let Some(expires_at) = invite.expires_at { + if expires_at < Utc::now().fixed_offset() { + return Err(anyhow!("invite expired")); + } + } + + if let Some(max_uses) = invite.max_uses { + if invite.use_count >= max_uses { + return Err(anyhow!("invite exhausted")); + } + } + + Ok(()) +} + +async fn increment_invite_use_count(txn: &DatabaseTransaction, invite: invites::Model) -> Result<()> { + let next_count = invite.use_count + 1; + let mut active: invites::ActiveModel = invite.into(); + active.use_count = Set(next_count); + active.update(txn).await?; + Ok(()) +} diff --git a/src/entity/channels.rs b/src/entity/channels.rs new file mode 100644 index 0000000..8d7529c --- /dev/null +++ b/src/entity/channels.rs @@ -0,0 +1,32 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "channels")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub guild_id: Uuid, + pub name: String, + pub kind: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::guilds::Entity", + from = "Column::GuildId", + to = "super::guilds::Column::Id" + )] + Guilds, + #[sea_orm(has_many = "super::messages::Entity")] + Messages, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Guilds.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/direct_messages.rs b/src/entity/direct_messages.rs new file mode 100644 index 0000000..d6fbaed --- /dev/null +++ b/src/entity/direct_messages.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; +use uuid::Uuid; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)] +#[sea_orm(table_name = "direct_messages")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: Uuid, + pub sender_user_id: Uuid, + pub recipient_user_id: Uuid, + pub body: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::SenderUserId", + to = "super::users::Column::Id" + )] + SenderUser, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::RecipientUserId", + to = "super::users::Column::Id" + )] + RecipientUser, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/guild_members.rs b/src/entity/guild_members.rs new file mode 100644 index 0000000..19896be --- /dev/null +++ b/src/entity/guild_members.rs @@ -0,0 +1,41 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "guild_members")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub guild_id: Uuid, + #[sea_orm(primary_key, auto_increment = false)] + pub user_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::guilds::Entity", + from = "Column::GuildId", + to = "super::guilds::Column::Id" + )] + Guilds, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::UserId", + to = "super::users::Column::Id" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Guilds.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/guilds.rs b/src/entity/guilds.rs new file mode 100644 index 0000000..c335824 --- /dev/null +++ b/src/entity/guilds.rs @@ -0,0 +1,31 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "guilds")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub name: String, + pub owner_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::OwnerUserId", + to = "super::users::Column::Id" + )] + Users, + #[sea_orm(has_many = "super::channels::Entity")] + Channels, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/invites.rs b/src/entity/invites.rs new file mode 100644 index 0000000..9b3c16d --- /dev/null +++ b/src/entity/invites.rs @@ -0,0 +1,44 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "invites")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub code: String, + pub guild_id: Uuid, + pub created_by_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub expires_at: Option, + pub max_uses: Option, + pub use_count: i32, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::guilds::Entity", + from = "Column::GuildId", + to = "super::guilds::Column::Id" + )] + Guilds, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::CreatedByUserId", + to = "super::users::Column::Id" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Guilds.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/messages.rs b/src/entity/messages.rs new file mode 100644 index 0000000..48e55b4 --- /dev/null +++ b/src/entity/messages.rs @@ -0,0 +1,42 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "messages")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub channel_id: Uuid, + pub author_user_id: Uuid, + pub body: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::channels::Entity", + from = "Column::ChannelId", + to = "super::channels::Column::Id" + )] + Channels, + #[sea_orm( + belongs_to = "super::users::Entity", + from = "Column::AuthorUserId", + to = "super::users::Column::Id" + )] + Users, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Channels.def() + } +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Users.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/entity/mod.rs b/src/entity/mod.rs new file mode 100644 index 0000000..4c990ec --- /dev/null +++ b/src/entity/mod.rs @@ -0,0 +1,7 @@ +pub mod channels; +pub mod direct_messages; +pub mod guild_members; +pub mod guilds; +pub mod invites; +pub mod messages; +pub mod users; diff --git a/src/entity/users.rs b/src/entity/users.rs new file mode 100644 index 0000000..207cfc2 --- /dev/null +++ b/src/entity/users.rs @@ -0,0 +1,30 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "users")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: Uuid, + pub oidc_sub: String, + pub email: Option, + pub display_name: String, + pub avatar_url: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm(has_many = "super::guilds::Entity")] + Guilds, + #[sea_orm(has_many = "super::messages::Entity")] + Messages, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::Guilds.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src/handlers.rs b/src/handlers.rs new file mode 100644 index 0000000..d86deec --- /dev/null +++ b/src/handlers.rs @@ -0,0 +1,617 @@ +use axum::{ + Json, Router, + extract::{Path, Query, State, WebSocketUpgrade}, + http::{HeaderMap, StatusCode, header}, + response::{Html, IntoResponse, Redirect}, + routing::{get, post}, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + AppState, + auth::{self, ApiError, AuthUser}, + db, voice, +}; + +pub fn routes() -> Router { + Router::new() + .route("/", get(index)) + .route("/auth/login", get(auth_login)) + .route("/auth/callback", get(auth_callback)) + .route("/auth/logout", post(auth_logout)) + .route("/me", get(me)) + .route("/dms", get(list_dm_conversations)) + .route("/dms/{other_user_id}/messages", get(list_dm_messages).post(send_dm_message)) + .route("/rtc-config", get(rtc_config)) + .route("/guilds", get(list_guilds).post(create_guild)) + .route("/guilds/{guild_id}/members", get(list_guild_members)) + .route("/guilds/{guild_id}/channels", get(list_channels)) + .route("/guilds/{guild_id}/voice-presence", get(guild_voice_presence)) + .route("/guilds/{guild_id}/invites", post(create_invite)) + .route("/invites/{code}/join", post(join_invite)) + .route("/channels", post(create_channel)) + .route("/channels/{channel_id}/messages", get(list_messages).post(send_message)) + .route("/channels/{channel_id}/voice/ws", get(voice_ws)) +} + +pub async fn health() -> &'static str { + "ok" +} + +async fn index() -> Html<&'static str> { + Html(include_str!("../static/index.html")) +} + +async fn auth_login(State(state): State) -> Result { + let oauth_state = auth::new_oauth_state(); + + let authorize_url = format!( + "{}?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}", + state.settings.oidc_authorize_url, + urlencoding::encode(&state.settings.oidc_client_id), + urlencoding::encode(&state.settings.oidc_redirect_url), + urlencoding::encode(&state.settings.oidc_scopes), + urlencoding::encode(&oauth_state) + ); + + let mut headers = HeaderMap::new(); + headers.insert( + header::SET_COOKIE, + auth::make_oauth_state_cookie(&oauth_state, state.settings.cookie_secure) + .parse() + .map_err(|_| ApiError::internal("failed to set oauth cookie"))?, + ); + + Ok((headers, Redirect::to(&authorize_url))) +} + +#[derive(Deserialize)] +struct AuthCallbackQuery { + code: String, + state: String, +} + +#[derive(Deserialize)] +struct OidcTokenResponse { + access_token: String, +} + +#[derive(Deserialize)] +struct OidcUserInfo { + sub: String, + email: Option, + preferred_username: Option, + nickname: Option, + given_name: Option, + family_name: Option, + name: Option, + picture: Option, +} + +fn normalized_claim(value: Option<&str>) -> Option { + let v = value?.trim(); + if v.is_empty() || v.eq_ignore_ascii_case("null") { + None + } else { + Some(v.to_string()) + } +} + +fn choose_display_name(profile: &OidcUserInfo) -> String { + if let Some(v) = normalized_claim(profile.name.as_deref()) { + return v; + } + if let Some(v) = normalized_claim(profile.nickname.as_deref()) { + return v; + } + if let Some(v) = normalized_claim(profile.preferred_username.as_deref()) { + return v; + } + + let given = normalized_claim(profile.given_name.as_deref()); + let family = normalized_claim(profile.family_name.as_deref()); + match (given, family) { + (Some(g), Some(f)) => format!("{g} {f}"), + (Some(g), None) => g, + (None, Some(f)) => f, + (None, None) => "User".to_string(), + } +} + +async fn auth_callback( + State(state): State, + Query(query): Query, + headers: HeaderMap, +) -> Result { + auth::validate_oauth_state(auth::read_oauth_state_from_headers(&headers), &query.state) + .map_err(|e| ApiError::bad_request(&e.to_string()))?; + + let token_res = state + .http + .post(&state.settings.oidc_token_url) + .form(&[ + ("grant_type", "authorization_code"), + ("code", query.code.as_str()), + ("redirect_uri", state.settings.oidc_redirect_url.as_str()), + ("client_id", state.settings.oidc_client_id.as_str()), + ("client_secret", state.settings.oidc_client_secret.as_str()), + ]) + .send() + .await + .map_err(|e| ApiError::bad_request(&format!("token exchange failed: {e}")))?; + + if !token_res.status().is_success() { + let text = token_res.text().await.unwrap_or_else(|_| "".to_string()); + return Err(ApiError::bad_request(&format!( + "token exchange returned non-success: {text}" + ))); + } + + let token: OidcTokenResponse = token_res + .json() + .await + .map_err(|e| ApiError::bad_request(&format!("invalid token response: {e}")))?; + + let userinfo_res = state + .http + .get(&state.settings.oidc_userinfo_url) + .bearer_auth(&token.access_token) + .send() + .await + .map_err(|e| ApiError::bad_request(&format!("userinfo request failed: {e}")))?; + + if !userinfo_res.status().is_success() { + let text = userinfo_res.text().await.unwrap_or_else(|_| "".to_string()); + return Err(ApiError::bad_request(&format!( + "userinfo returned non-success: {text}" + ))); + } + + let profile: OidcUserInfo = userinfo_res + .json() + .await + .map_err(|e| ApiError::bad_request(&format!("invalid userinfo response: {e}")))?; + + let display_name = choose_display_name(&profile); + + let user = db::upsert_user_from_oidc( + &state.db, + &profile.sub, + profile.email.as_deref(), + &display_name, + profile.picture.as_deref(), + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to persist user: {e}")))?; + + let session_cookie = + auth::new_session_cookie(user.id, &state.settings.session_secret, state.settings.cookie_secure) + .map_err(|e| ApiError::internal(&e.to_string()))?; + + let mut headers = HeaderMap::new(); + headers.append( + header::SET_COOKIE, + session_cookie + .parse() + .map_err(|_| ApiError::internal("failed to set session cookie"))?, + ); + headers.append( + header::SET_COOKIE, + auth::clear_oauth_state_cookie(state.settings.cookie_secure) + .parse() + .map_err(|_| ApiError::internal("failed to clear oauth state cookie"))?, + ); + + Ok((headers, Redirect::to("/"))) +} + +async fn auth_logout(State(state): State) -> Result { + let mut headers = HeaderMap::new(); + headers.append( + header::SET_COOKIE, + auth::clear_session_cookie(state.settings.cookie_secure) + .parse() + .map_err(|_| ApiError::internal("failed to clear session cookie"))?, + ); + + Ok((StatusCode::NO_CONTENT, headers)) +} + +#[derive(Serialize)] +struct RtcConfigResponse { + ice_servers: Vec, +} + +#[derive(Serialize)] +struct RtcIceServer { + urls: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + credential: Option, +} + +async fn rtc_config(State(state): State, _user: AuthUser) -> Result { + let mut ice_servers = Vec::new(); + + if !state.settings.stun_urls.is_empty() { + ice_servers.push(RtcIceServer { + urls: state.settings.stun_urls.clone(), + username: None, + credential: None, + }); + } + + if !state.settings.turn_urls.is_empty() { + ice_servers.push(RtcIceServer { + urls: state.settings.turn_urls.clone(), + username: state.settings.turn_username.clone(), + credential: state.settings.turn_password.clone(), + }); + } + + if ice_servers.is_empty() { + ice_servers.push(RtcIceServer { + urls: vec!["stun:stun.l.google.com:19302".to_string()], + username: None, + credential: None, + }); + } + + Ok(Json(RtcConfigResponse { ice_servers })) +} + +async fn me(State(state): State, user: AuthUser) -> Result { + let me = db::get_user_by_id(&state.db, user.id) + .await + .map_err(|e| ApiError::internal(&format!("failed to load user: {e}")))? + .ok_or_else(|| ApiError::unauthorized("no user"))?; + + Ok(Json(me)) +} + +async fn list_dm_conversations( + State(state): State, + user: AuthUser, +) -> Result { + let conversations = db::list_dm_conversations(&state.db, user.id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list dm conversations: {e}")))?; + Ok(Json(conversations)) +} + +async fn list_guild_members( + State(state): State, + user: AuthUser, + Path(guild_id): Path, +) -> Result { + ensure_guild_member(&state, guild_id, user.id).await?; + let members = db::list_guild_members(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guild members: {e}")))?; + Ok(Json(members)) +} + +async fn list_guilds( + State(state): State, + user: AuthUser, +) -> Result { + let guilds = db::list_guilds_for_user(&state.db, user.id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list guilds: {e}")))?; + + Ok(Json(guilds)) +} + +#[derive(Deserialize)] +struct CreateGuildBody { + name: String, +} + +async fn create_guild( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> Result { + let trimmed = body.name.trim(); + if trimmed.is_empty() || trimmed.len() > 64 { + return Err(ApiError::bad_request("guild name must be 1..64 chars")); + } + + let guild = db::create_guild(&state.db, user.id, trimmed) + .await + .map_err(|e| ApiError::internal(&format!("failed to create guild: {e}")))?; + Ok((StatusCode::CREATED, Json(guild))) +} + +#[derive(Deserialize)] +struct CreateInviteBody { + max_uses: Option, + expires_in_hours: Option, +} + +async fn create_invite( + State(state): State, + user: AuthUser, + Path(guild_id): Path, + Json(body): Json, +) -> Result { + ensure_guild_member(&state, guild_id, user.id).await?; + + if let Some(v) = body.max_uses + && v <= 0 + { + return Err(ApiError::bad_request("max_uses must be > 0")); + } + + if let Some(v) = body.expires_in_hours + && v <= 0 + { + return Err(ApiError::bad_request("expires_in_hours must be > 0")); + } + + let invite = db::create_invite( + &state.db, + guild_id, + user.id, + body.max_uses, + body.expires_in_hours, + ) + .await + .map_err(|e| ApiError::internal(&format!("failed to create invite: {e}")))?; + + Ok((StatusCode::CREATED, Json(invite))) +} + +async fn join_invite( + State(state): State, + user: AuthUser, + Path(code): Path, +) -> Result { + let guild = db::join_invite(&state.db, &code.to_uppercase(), user.id) + .await + .map_err(|e| ApiError::bad_request(&format!("failed to join invite: {e}")))?; + + Ok(Json(guild)) +} + +async fn list_channels( + State(state): State, + user: AuthUser, + Path(guild_id): Path, +) -> Result { + ensure_guild_member(&state, guild_id, user.id).await?; + + let channels = db::list_channels_for_guild(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list channels: {e}")))?; + Ok(Json(channels)) +} + +#[derive(Serialize)] +struct GuildVoicePresenceItem { + channel_id: Uuid, + participants: Vec, +} + +#[derive(Serialize)] +struct GuildVoicePresenceResponse { + channels: Vec, +} + +async fn guild_voice_presence( + State(state): State, + user: AuthUser, + Path(guild_id): Path, +) -> Result { + ensure_guild_member(&state, guild_id, user.id).await?; + + let voice_channels = db::list_voice_channels_for_guild(&state.db, guild_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to list voice channels: {e}")))?; + + let mut channels = Vec::with_capacity(voice_channels.len()); + for ch in voice_channels { + channels.push(GuildVoicePresenceItem { + channel_id: ch.id, + participants: state.voice.participants(ch.id).await, + }); + } + + Ok(Json(GuildVoicePresenceResponse { channels })) +} + +#[derive(Deserialize)] +struct CreateChannelBody { + guild_id: Uuid, + name: String, + kind: Option, +} + +async fn create_channel( + State(state): State, + user: AuthUser, + Json(body): Json, +) -> Result { + ensure_guild_member(&state, body.guild_id, user.id).await?; + + let trimmed = body.name.trim(); + if trimmed.is_empty() || trimmed.len() > 64 { + return Err(ApiError::bad_request("channel name must be 1..64 chars")); + } + + let kind = body.kind.unwrap_or_else(|| db::CHANNEL_KIND_TEXT.to_string()); + if kind != db::CHANNEL_KIND_TEXT && kind != db::CHANNEL_KIND_VOICE { + return Err(ApiError::bad_request("channel kind must be 'text' or 'voice'")); + } + + let channel = db::create_channel(&state.db, body.guild_id, trimmed, &kind) + .await + .map_err(|e| ApiError::internal(&format!("failed to create channel: {e}")))?; + Ok((StatusCode::CREATED, Json(channel))) +} + +#[derive(Deserialize)] +struct ListMessagesQuery { + limit: Option, +} + +async fn list_messages( + State(state): State, + user: AuthUser, + Path(channel_id): Path, + Query(query): Query, +) -> Result { + ensure_channel_member(&state, channel_id, user.id).await?; + + let limit = query.limit.unwrap_or(50).clamp(1, 200); + + let messages = db::list_messages(&state.db, channel_id, limit) + .await + .map_err(|e| ApiError::internal(&format!("failed to list messages: {e}")))?; + + Ok(Json(messages)) +} + +#[derive(Deserialize)] +struct SendMessageBody { + body: String, +} + +#[derive(Serialize)] +struct SendMessageResponse { + ok: bool, +} + +async fn send_message( + State(state): State, + user: AuthUser, + Path(channel_id): Path, + Json(body): Json, +) -> Result { + ensure_channel_member(&state, channel_id, user.id).await?; + + let content = body.body.trim(); + if content.is_empty() || content.len() > 4000 { + return Err(ApiError::bad_request("message body must be 1..4000 chars")); + } + + db::create_message(&state.db, channel_id, user.id, content) + .await + .map_err(|e| ApiError::internal(&format!("failed to create message: {e}")))?; + + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) +} + +async fn list_dm_messages( + State(state): State, + user: AuthUser, + Path(other_user_id): Path, + Query(query): Query, +) -> Result { + if other_user_id == user.id { + return Err(ApiError::bad_request("cannot open dm with yourself")); + } + + let other_user_exists = db::user_exists(&state.db, other_user_id) + .await + .map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?; + if !other_user_exists { + return Err(ApiError { + status: StatusCode::NOT_FOUND, + message: "user not found".to_string(), + }); + } + + let limit = query.limit.unwrap_or(50).clamp(1, 200); + let messages = db::list_direct_messages(&state.db, user.id, other_user_id, limit) + .await + .map_err(|e| ApiError::internal(&format!("failed to list dm messages: {e}")))?; + + Ok(Json(messages)) +} + +async fn send_dm_message( + State(state): State, + user: AuthUser, + Path(other_user_id): Path, + Json(body): Json, +) -> Result { + if other_user_id == user.id { + return Err(ApiError::bad_request("cannot send dm to yourself")); + } + + let other_user_exists = db::user_exists(&state.db, other_user_id) + .await + .map_err(|e| ApiError::internal(&format!("user check failed: {e}")))?; + if !other_user_exists { + return Err(ApiError { + status: StatusCode::NOT_FOUND, + message: "user not found".to_string(), + }); + } + + let content = body.body.trim(); + if content.is_empty() || content.len() > 4000 { + return Err(ApiError::bad_request("message body must be 1..4000 chars")); + } + + db::create_direct_message(&state.db, user.id, other_user_id, content) + .await + .map_err(|e| ApiError::internal(&format!("failed to create dm message: {e}")))?; + + Ok((StatusCode::CREATED, Json(SendMessageResponse { ok: true }))) +} + +async fn voice_ws( + ws: WebSocketUpgrade, + State(state): State, + user: AuthUser, + Path(channel_id): Path, +) -> Result { + ensure_channel_member(&state, channel_id, user.id).await?; + + let channel = db::get_channel_by_id(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("failed to load channel: {e}")))? + .ok_or_else(|| ApiError { + status: StatusCode::NOT_FOUND, + message: "channel not found".to_string(), + })?; + + if channel.kind != db::CHANNEL_KIND_VOICE { + return Err(ApiError::bad_request("voice websocket requires a voice channel")); + } + + Ok(ws.on_upgrade(move |socket| { + voice::handle_socket(state, socket, channel_id, user.id) + })) +} + +async fn ensure_guild_member(state: &AppState, guild_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { + let is_member = db::is_member_of_guild(&state.db, guild_id, user_id) + .await + .map_err(|e| ApiError::internal(&format!("membership check failed: {e}")))?; + + if !is_member { + return Err(ApiError { + status: StatusCode::FORBIDDEN, + message: "not a member of this guild".to_string(), + }); + } + + Ok(()) +} + +async fn ensure_channel_member(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<(), ApiError> { + let guild_id = db::guild_id_for_channel(&state.db, channel_id) + .await + .map_err(|e| ApiError::internal(&format!("channel lookup failed: {e}")))? + .ok_or_else(|| ApiError { + status: StatusCode::NOT_FOUND, + message: "channel not found".to_string(), + })?; + + ensure_guild_member(state, guild_id, user_id).await +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a90eb39 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,71 @@ +mod auth; +mod config; +mod db; +mod entity; +mod handlers; +mod migration; +mod models; +mod voice; + +use std::{net::SocketAddr, sync::Arc}; + +use anyhow::Context; +use axum::{Router, routing::get}; +use sea_orm::Database; +use sea_orm_migration::MigratorTrait; +use tower_http::{services::ServeDir, trace::TraceLayer}; +use tracing::info; + +use crate::{config::Settings, handlers::routes, migration::Migrator, voice::VoiceHub}; + +#[derive(Clone)] +pub struct AppState { + pub db: sea_orm::DatabaseConnection, + pub settings: Arc, + pub http: reqwest::Client, + pub voice: Arc, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + init_tracing(); + + let settings = Arc::new(Settings::from_env()?); + + let db = Database::connect(&settings.database_url) + .await + .with_context(|| "failed to connect to postgres")?; + + Migrator::up(&db, None) + .await + .with_context(|| "failed to run migrations")?; + + let state = AppState { + db, + settings, + http: reqwest::Client::new(), + voice: Arc::new(VoiceHub::default()), + }; + + let app = Router::new() + .route("/health", get(handlers::health)) + .nest_service("/static", ServeDir::new("static")) + .merge(routes()) + .with_state(state) + .layer(TraceLayer::new_for_http()); + + let addr: SocketAddr = "0.0.0.0:3000".parse()?; + info!(%addr, "server started"); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} + +fn init_tracing() { + let filter = tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "chattz=info,tower_http=info".into()); + + tracing_subscriber::fmt().with_env_filter(filter).init(); +} diff --git a/src/migration/m20260213_000001_init.rs b/src/migration/m20260213_000001_init.rs new file mode 100644 index 0000000..9c671d4 --- /dev/null +++ b/src/migration/m20260213_000001_init.rs @@ -0,0 +1,274 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Users::Table) + .if_not_exists() + .col( + ColumnDef::new(Users::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Users::OidcSub).string().not_null().unique_key()) + .col(ColumnDef::new(Users::Email).string()) + .col(ColumnDef::new(Users::DisplayName).string().not_null()) + .col(ColumnDef::new(Users::AvatarUrl).string()) + .col( + ColumnDef::new(Users::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col( + ColumnDef::new(Users::UpdatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Guilds::Table) + .if_not_exists() + .col( + ColumnDef::new(Guilds::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Guilds::Name).string().not_null()) + .col(ColumnDef::new(Guilds::OwnerUserId).uuid().not_null()) + .col( + ColumnDef::new(Guilds::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_guilds_owner_user") + .from(Guilds::Table, Guilds::OwnerUserId) + .to(Users::Table, Users::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(GuildMembers::Table) + .if_not_exists() + .col(ColumnDef::new(GuildMembers::GuildId).uuid().not_null()) + .col(ColumnDef::new(GuildMembers::UserId).uuid().not_null()) + .col( + ColumnDef::new(GuildMembers::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .primary_key( + Index::create() + .name("pk_guild_members") + .col(GuildMembers::GuildId) + .col(GuildMembers::UserId), + ) + .foreign_key( + ForeignKey::create() + .name("fk_guild_members_guild") + .from(GuildMembers::Table, GuildMembers::GuildId) + .to(Guilds::Table, Guilds::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_guild_members_user") + .from(GuildMembers::Table, GuildMembers::UserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Channels::Table) + .if_not_exists() + .col( + ColumnDef::new(Channels::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Channels::GuildId).uuid().not_null()) + .col(ColumnDef::new(Channels::Name).string().not_null()) + .col( + ColumnDef::new(Channels::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_channels_guild") + .from(Channels::Table, Channels::GuildId) + .to(Guilds::Table, Guilds::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_table( + Table::create() + .table(Messages::Table) + .if_not_exists() + .col( + ColumnDef::new(Messages::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(Messages::ChannelId).uuid().not_null()) + .col(ColumnDef::new(Messages::AuthorUserId).uuid().not_null()) + .col(ColumnDef::new(Messages::Body).text().not_null()) + .col( + ColumnDef::new(Messages::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_messages_channel") + .from(Messages::Table, Messages::ChannelId) + .to(Channels::Table, Channels::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_messages_author") + .from(Messages::Table, Messages::AuthorUserId) + .to(Users::Table, Users::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_guild_members_user_id") + .table(GuildMembers::Table) + .col(GuildMembers::UserId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_channels_guild_id") + .table(Channels::Table) + .col(Channels::GuildId) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_messages_channel_created_at") + .table(Messages::Table) + .col(Messages::ChannelId) + .col(Messages::CreatedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Messages::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Channels::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(GuildMembers::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Guilds::Table).to_owned()) + .await?; + manager + .drop_table(Table::drop().table(Users::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, + OidcSub, + Email, + DisplayName, + AvatarUrl, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum Guilds { + Table, + Id, + Name, + OwnerUserId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum GuildMembers { + Table, + GuildId, + UserId, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Channels { + Table, + Id, + GuildId, + Name, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Messages { + Table, + Id, + ChannelId, + AuthorUserId, + Body, + CreatedAt, +} diff --git a/src/migration/m20260213_000002_invites.rs b/src/migration/m20260213_000002_invites.rs new file mode 100644 index 0000000..e99c28d --- /dev/null +++ b/src/migration/m20260213_000002_invites.rs @@ -0,0 +1,91 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(Invites::Table) + .if_not_exists() + .col(ColumnDef::new(Invites::Code).string().not_null().primary_key()) + .col(ColumnDef::new(Invites::GuildId).uuid().not_null()) + .col(ColumnDef::new(Invites::CreatedByUserId).uuid().not_null()) + .col( + ColumnDef::new(Invites::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .col(ColumnDef::new(Invites::ExpiresAt).timestamp_with_time_zone()) + .col(ColumnDef::new(Invites::MaxUses).integer()) + .col( + ColumnDef::new(Invites::UseCount) + .integer() + .not_null() + .default(0), + ) + .foreign_key( + ForeignKey::create() + .name("fk_invites_guild") + .from(Invites::Table, Invites::GuildId) + .to(Guilds::Table, Guilds::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_invites_created_by") + .from(Invites::Table, Invites::CreatedByUserId) + .to(Users::Table, Users::Id), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_invites_guild_id") + .table(Invites::Table) + .col(Invites::GuildId) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(Invites::Table).to_owned()) + .await?; + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Invites { + Table, + Code, + GuildId, + CreatedByUserId, + CreatedAt, + ExpiresAt, + MaxUses, + UseCount, +} + +#[derive(DeriveIden)] +enum Guilds { + Table, + Id, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/src/migration/m20260213_000003_channel_kind.rs b/src/migration/m20260213_000003_channel_kind.rs new file mode 100644 index 0000000..bf9645e --- /dev/null +++ b/src/migration/m20260213_000003_channel_kind.rs @@ -0,0 +1,65 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .alter_table( + Table::alter() + .table(Channels::Table) + .add_column( + ColumnDef::new(Channels::Kind) + .string() + .not_null() + .default("text"), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_channels_guild_kind") + .table(Channels::Table) + .col(Channels::GuildId) + .col(Channels::Kind) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_index( + Index::drop() + .name("idx_channels_guild_kind") + .table(Channels::Table) + .to_owned(), + ) + .await?; + + manager + .alter_table( + Table::alter() + .table(Channels::Table) + .drop_column(Channels::Kind) + .to_owned(), + ) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum Channels { + Table, + GuildId, + Kind, +} diff --git a/src/migration/m20260213_000004_direct_messages.rs b/src/migration/m20260213_000004_direct_messages.rs new file mode 100644 index 0000000..4b723ab --- /dev/null +++ b/src/migration/m20260213_000004_direct_messages.rs @@ -0,0 +1,95 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(DirectMessages::Table) + .if_not_exists() + .col( + ColumnDef::new(DirectMessages::Id) + .uuid() + .not_null() + .primary_key(), + ) + .col(ColumnDef::new(DirectMessages::SenderUserId).uuid().not_null()) + .col(ColumnDef::new(DirectMessages::RecipientUserId).uuid().not_null()) + .col(ColumnDef::new(DirectMessages::Body).text().not_null()) + .col( + ColumnDef::new(DirectMessages::CreatedAt) + .timestamp_with_time_zone() + .not_null() + .default(Expr::current_timestamp()), + ) + .foreign_key( + ForeignKey::create() + .name("fk_direct_messages_sender_user") + .from(DirectMessages::Table, DirectMessages::SenderUserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .foreign_key( + ForeignKey::create() + .name("fk_direct_messages_recipient_user") + .from(DirectMessages::Table, DirectMessages::RecipientUserId) + .to(Users::Table, Users::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_direct_messages_sender_created_at") + .table(DirectMessages::Table) + .col(DirectMessages::SenderUserId) + .col(DirectMessages::CreatedAt) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_direct_messages_recipient_created_at") + .table(DirectMessages::Table) + .col(DirectMessages::RecipientUserId) + .col(DirectMessages::CreatedAt) + .to_owned(), + ) + .await?; + + Ok(()) + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table(Table::drop().table(DirectMessages::Table).to_owned()) + .await?; + + Ok(()) + } +} + +#[derive(DeriveIden)] +enum DirectMessages { + Table, + Id, + SenderUserId, + RecipientUserId, + Body, + CreatedAt, +} + +#[derive(DeriveIden)] +enum Users { + Table, + Id, +} diff --git a/src/migration/mod.rs b/src/migration/mod.rs new file mode 100644 index 0000000..b05f91c --- /dev/null +++ b/src/migration/mod.rs @@ -0,0 +1,20 @@ +use sea_orm_migration::prelude::*; + +mod m20260213_000001_init; +mod m20260213_000002_invites; +mod m20260213_000003_channel_kind; +mod m20260213_000004_direct_messages; + +pub struct Migrator; + +#[async_trait::async_trait] +impl MigratorTrait for Migrator { + fn migrations() -> Vec> { + vec![ + Box::new(m20260213_000001_init::Migration), + Box::new(m20260213_000002_invites::Migration), + Box::new(m20260213_000003_channel_kind::Migration), + Box::new(m20260213_000004_direct_messages::Migration), + ] + } +} diff --git a/src/models.rs b/src/models.rs new file mode 100644 index 0000000..3384ec4 --- /dev/null +++ b/src/models.rs @@ -0,0 +1,86 @@ +use sea_orm::prelude::DateTimeWithTimeZone; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize)] +pub struct User { + pub id: Uuid, + pub oidc_sub: String, + pub email: Option, + pub display_name: String, + pub avatar_url: Option, + pub created_at: DateTimeWithTimeZone, + pub updated_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Guild { + pub id: Uuid, + pub name: String, + pub owner_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Channel { + pub id: Uuid, + pub guild_id: Uuid, + pub name: String, + pub kind: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Message { + pub id: Uuid, + pub channel_id: Uuid, + pub author_user_id: Uuid, + pub body: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct BasicUser { + pub id: Uuid, + pub display_name: String, + pub avatar_url: Option, +} + +#[derive(Debug, Clone, Serialize, sea_orm::FromQueryResult)] +pub struct MessageWithAuthor { + pub id: Uuid, + pub channel_id: Uuid, + pub author_user_id: Uuid, + pub author_display_name: String, + pub body: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DmConversation { + pub user_id: Uuid, + pub display_name: String, + pub avatar_url: Option, + pub last_message_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct DmMessageWithAuthor { + pub id: Uuid, + pub author_user_id: Uuid, + pub recipient_user_id: Uuid, + pub author_display_name: String, + pub body: String, + pub created_at: DateTimeWithTimeZone, +} + +#[derive(Debug, Clone, Serialize)] +pub struct Invite { + pub code: String, + pub guild_id: Uuid, + pub created_by_user_id: Uuid, + pub created_at: DateTimeWithTimeZone, + pub expires_at: Option, + pub max_uses: Option, + pub use_count: i32, +} diff --git a/src/voice.rs b/src/voice.rs new file mode 100644 index 0000000..98d9fb9 --- /dev/null +++ b/src/voice.rs @@ -0,0 +1,199 @@ +use std::collections::HashMap; + +use axum::extract::ws::{Message, WebSocket}; +use futures_util::{SinkExt, StreamExt}; +use serde::{Deserialize, Serialize}; +use tokio::sync::{RwLock, mpsc}; +use uuid::Uuid; + +use crate::{AppState, db}; + +#[derive(Default)] +pub struct VoiceHub { + rooms: RwLock>>, +} + +#[derive(Clone)] +struct ClientHandle { + display_name: String, + tx: mpsc::UnboundedSender, +} + +#[derive(Serialize, Clone)] +pub struct VoiceParticipant { + pub user_id: Uuid, + pub display_name: String, +} + +#[derive(Serialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ServerEvent { + Peers { peers: Vec }, + PeerJoined { user_id: Uuid, display_name: String }, + PeerLeft { user_id: Uuid }, + Signal { + from_user_id: Uuid, + kind: String, + data: serde_json::Value, + }, + Error { message: String }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +enum ClientEvent { + Signal { + to_user_id: Uuid, + kind: String, + data: serde_json::Value, + }, +} + +impl VoiceHub { + pub async fn participants(&self, room_id: Uuid) -> Vec { + let rooms = self.rooms.read().await; + let Some(room) = rooms.get(&room_id) else { + return Vec::new(); + }; + + room.iter() + .map(|(user_id, handle)| VoiceParticipant { + user_id: *user_id, + display_name: handle.display_name.clone(), + }) + .collect() + } + + async fn join( + &self, + room_id: Uuid, + user_id: Uuid, + display_name: String, + tx: mpsc::UnboundedSender, + ) -> Vec { + let mut rooms = self.rooms.write().await; + let room = rooms.entry(room_id).or_default(); + + let peers = room + .iter() + .map(|(peer_id, peer)| VoiceParticipant { + user_id: *peer_id, + display_name: peer.display_name.clone(), + }) + .collect::>(); + + room.insert( + user_id, + ClientHandle { + display_name: display_name.clone(), + tx, + }, + ); + + for (peer_id, peer) in room.iter() { + if *peer_id != user_id { + let _ = peer.tx.send(ServerEvent::PeerJoined { + user_id, + display_name: display_name.clone(), + }); + } + } + + peers + } + + async fn leave(&self, room_id: Uuid, user_id: Uuid) { + let mut rooms = self.rooms.write().await; + let Some(room) = rooms.get_mut(&room_id) else { + return; + }; + + room.remove(&user_id); + + for peer in room.values() { + let _ = peer.tx.send(ServerEvent::PeerLeft { user_id }); + } + + if room.is_empty() { + rooms.remove(&room_id); + } + } + + async fn relay_signal( + &self, + room_id: Uuid, + from_user_id: Uuid, + to_user_id: Uuid, + kind: String, + data: serde_json::Value, + ) { + let rooms = self.rooms.read().await; + let Some(room) = rooms.get(&room_id) else { + return; + }; + + if let Some(target) = room.get(&to_user_id) { + let _ = target.tx.send(ServerEvent::Signal { + from_user_id, + kind, + data, + }); + } + } +} + +pub async fn handle_socket(state: AppState, socket: WebSocket, room_id: Uuid, user_id: Uuid) { + let Some(user) = db::get_user_by_id(&state.db, user_id).await.ok().flatten() else { + return; + }; + + let (mut ws_sender, mut ws_receiver) = socket.split(); + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let peers = state + .voice + .join(room_id, user_id, user.display_name.clone(), tx.clone()) + .await; + let _ = tx.send(ServerEvent::Peers { peers }); + + let send_task = tokio::spawn(async move { + while let Some(event) = rx.recv().await { + let Ok(payload) = serde_json::to_string(&event) else { + continue; + }; + if ws_sender.send(Message::Text(payload.into())).await.is_err() { + break; + } + } + }); + + while let Some(Ok(msg)) = ws_receiver.next().await { + match msg { + Message::Text(text) => { + let parsed = serde_json::from_str::(&text); + match parsed { + Ok(ClientEvent::Signal { + to_user_id, + kind, + data, + }) => { + state + .voice + .relay_signal(room_id, user_id, to_user_id, kind, data) + .await; + } + Err(err) => { + let _ = tx.send(ServerEvent::Error { + message: format!("invalid voice message: {err}"), + }); + } + } + } + Message::Close(_) => break, + _ => {} + } + } + + send_task.abort(); + state.voice.leave(room_id, user_id).await; +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..5d0ad15 --- /dev/null +++ b/static/app.js @@ -0,0 +1,862 @@ +const state = { + me: null, + guilds: [], + channels: [], + dmConversations: [], + members: [], + voicePresence: new Map(), + selectedGuildId: null, + selectedTextChannelId: null, + selectedDmUserId: null, + selectedDmDisplayName: null, + selectedVoiceChannelId: null, + voice: { + ws: null, + joinedChannelId: null, + localStream: null, + rawStream: null, + audioContext: null, + denoiserNode: null, + deepFilterCore: null, + peerConnections: new Map(), + muted: false, + iceServers: [{ urls: "stun:stun.l.google.com:19302" }], + }, + voicePresencePollId: null, +}; + +const el = { + authScreen: document.getElementById("auth-screen"), + loginBtn: document.getElementById("login-btn"), + main: document.getElementById("main"), + status: document.getElementById("status"), + + // Guilds + guildList: document.getElementById("guild-list"), + addGuildBtn: document.getElementById("add-guild-btn"), + guildTitle: document.getElementById("guild-title"), + createInviteBtn: document.getElementById("create-invite-btn"), + + // Channels + channelList: document.getElementById("channel-list"), + voiceChannelList: document.getElementById("voice-channel-list"), + addTextBtn: document.getElementById("add-text-btn"), + addVoiceBtn: document.getElementById("add-voice-btn"), + dmList: document.getElementById("dm-list"), + channelTitle: document.getElementById("channel-title"), + + // Messages + messageList: document.getElementById("message-list"), + messageForm: document.getElementById("message-form"), + messageBody: document.getElementById("message-body"), + + // User Panel + userName: document.getElementById("user-name"), + userAvatar: document.getElementById("user-avatar"), + logoutBtn: document.getElementById("logout-btn"), + + // Voice Connection + voiceConnection: document.getElementById("voice-connection"), + vcChannelName: document.getElementById("vc-channel-name"), + voiceMuteBtn: document.getElementById("voice-mute-btn"), + voiceLeaveBtn: document.getElementById("voice-leave-btn"), + + // Members + memberList: document.getElementById("member-list"), + + // Modals + modalContainer: document.getElementById("modal-container"), + guildForm: document.getElementById("guild-form"), + guildName: document.getElementById("guild-name"), + modalCancel: document.getElementById("modal-cancel"), + + channelModal: document.getElementById("channel-modal"), + channelForm: document.getElementById("channel-form"), + channelName: document.getElementById("channel-name"), + channelModalCancel: document.getElementById("channel-modal-cancel"), +}; + +// --- API Helpers --- + +async function api(path, options = {}) { + const res = await fetch(path, { + ...options, + headers: { + "content-type": "application/json", + ...(options.headers || {}), + }, + credentials: "same-origin", + }); + + if (!res.ok) { + let detail = "request failed"; + try { + const body = await res.json(); + detail = body.error || detail; + } catch {} + throw new Error(`${res.status}: ${detail}`); + } + + if (res.status === 204) return null; + return res.json(); +} + +// --- Utils --- + +function shortName(name) { + if (!name || typeof name !== 'string') return "?"; + const trimmed = name.trim(); + if (!trimmed) return "?"; + const words = trimmed.split(/\s+/).filter(w => w.length > 0).slice(0, 2); + if (words.length === 0) return "?"; + if (words.length === 1) return words[0].substring(0, 2).toUpperCase(); + return words.map((w) => w[0]?.toUpperCase() || "").join(""); +} + +function escapeHtml(s) { + if (s === null || s === undefined) return ""; + return String(s) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function formatDate(isoString) { + const d = new Date(isoString); + const now = new Date(); + const isToday = d.toDateString() === now.toDateString(); + + if (isToday) { + return `Today at ${d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`; + } + return d.toLocaleDateString(); +} + +const DEEPFILTERNET_LIB_PATH = "/static/vendor/deepfilternet3/index.esm.js"; +const NSNET2_COMPAT_SUPPRESSION = 56; + +let deepFilterLibPromise = null; +const LAST_GUILD_STORAGE_KEY = "chattz:lastGuildId"; + +// --- Renderers --- + +function renderGuilds() { + el.guildList.innerHTML = ""; + + for (const guild of state.guilds) { + const btn = document.createElement("button"); + btn.className = `guild-pill ${state.selectedGuildId === guild.id ? "active" : ""}`; + btn.title = guild.name; + btn.textContent = shortName(guild.name); + btn.onclick = async () => { + state.selectedGuildId = guild.id; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + renderDMs(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + startVoicePresencePolling(); + renderMessages([]); + updateHeaderLabels(); + }; + el.guildList.appendChild(btn); + } +} + +function renderChannels() { + el.channelList.innerHTML = ""; + el.voiceChannelList.innerHTML = ""; + + for (const channel of state.channels) { + const row = document.createElement("button"); + row.className = `channel-row ${ + (channel.kind === 'text' && state.selectedTextChannelId === channel.id) || + (channel.kind === 'voice' && state.selectedVoiceChannelId === channel.id) ? "active" : "" + }`; + + const iconName = channel.kind === 'text' ? 'hash' : 'volume-2'; + row.innerHTML = ` ${escapeHtml(channel.name)}`; + + row.onclick = async () => { + if (channel.kind === 'text') { + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + state.selectedTextChannelId = channel.id; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + const messages = await api(`/channels/${channel.id}/messages?limit=100`); + renderMessages(messages); + } else { + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + state.selectedVoiceChannelId = channel.id; + renderChannels(); + renderDMs(); + joinVoice(); + } + }; + + if (channel.kind === 'text') { + el.channelList.appendChild(row); + } else { + el.voiceChannelList.appendChild(row); + + const participants = state.voicePresence.get(channel.id) || []; + if (participants.length > 0) { + const pList = document.createElement("div"); + pList.className = "voice-row-members channel-list"; + pList.style.paddingLeft = "24px"; + for (const p of participants) { + const pRow = document.createElement("div"); + pRow.className = "channel-row"; + pRow.style.padding = "2px 8px"; + pRow.innerHTML = `
${shortName(p.display_name)}
${escapeHtml(p.display_name)}`; + pList.appendChild(pRow); + } + el.voiceChannelList.appendChild(pList); + } + } + } + lucide.createIcons(); +} + +function renderDMs() { + el.dmList.innerHTML = ""; + for (const dm of state.dmConversations) { + const row = document.createElement("button"); + row.className = `channel-row ${state.selectedDmUserId === dm.user_id ? "active" : ""}`; + row.innerHTML = ` ${escapeHtml(dm.display_name)}`; + row.onclick = async () => { + state.selectedDmUserId = dm.user_id; + state.selectedDmDisplayName = dm.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + const messages = await api(`/dms/${dm.user_id}/messages?limit=100`); + renderMessages(messages); + }; + el.dmList.appendChild(row); + } + + lucide.createIcons(); +} + +function renderMessages(messages) { + el.messageList.innerHTML = ""; + let lastAuthorId = null; + let lastTime = null; + + for (const m of messages.slice().reverse()) { + const row = document.createElement("div"); + const mDate = new Date(m.created_at); + const isGrouped = lastAuthorId === m.author_user_id && + lastTime && (mDate - lastTime < 300000); // 5 minutes + + row.className = `msg ${isGrouped ? "msg-grouped" : ""}`; + + const displayName = m.author_display_name || "Unknown User"; + + if (isGrouped) { + row.innerHTML = `
${escapeHtml(m.body)}
`; + } else { + row.innerHTML = ` +
${shortName(displayName)}
+
+
+ ${escapeHtml(displayName)} + ${formatDate(m.created_at)} +
+
${escapeHtml(m.body)}
+
+ `; + } + el.messageList.appendChild(row); + lastAuthorId = m.author_user_id; + lastTime = mDate; + } + + el.messageList.scrollTop = el.messageList.scrollHeight; +} + +function renderMembers() { + el.memberList.innerHTML = ""; + for (const m of state.members) { + const row = document.createElement("div"); + row.className = "member-row"; + row.innerHTML = ` +
${shortName(m.display_name)}
+
${escapeHtml(m.display_name)}
+ `; + row.style.cursor = "pointer"; + row.onclick = async () => { + if (state.me && m.id === state.me.id) return; + state.selectedDmUserId = m.id; + state.selectedDmDisplayName = m.display_name; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + const messages = await api(`/dms/${m.id}/messages?limit=100`); + renderMessages(messages); + }; + el.memberList.appendChild(row); + } +} + +function updateHeaderLabels() { + const guild = state.guilds.find((g) => g.id === state.selectedGuildId); + el.guildTitle.textContent = guild ? guild.name : "No server selected"; + + if (state.selectedDmUserId) { + const dmFromConversations = state.dmConversations.find((u) => u.user_id === state.selectedDmUserId); + const dmFromMembers = state.members.find((m) => m.id === state.selectedDmUserId); + const displayName = dmFromConversations?.display_name || dmFromMembers?.display_name || state.selectedDmDisplayName; + const dmName = displayName ? `@${displayName}` : "Direct Message"; + el.channelTitle.textContent = dmName; + el.messageBody.placeholder = displayName + ? `Message @${displayName}` + : "Message"; + return; + } + + const channel = state.channels.find((c) => c.id === state.selectedTextChannelId); + el.channelTitle.textContent = channel ? channel.name : "Select a channel"; + el.messageBody.placeholder = channel ? `Message #${channel.name}` : "Select a channel"; +} + +async function createInviteLink() { + if (!state.selectedGuildId) { + alert("Select a server first."); + return; + } + try { + const invite = await api(`/guilds/${state.selectedGuildId}/invites`, { + method: "POST", + body: JSON.stringify({ max_uses: 50, expires_in_hours: 24 }), + }); + const link = `${location.origin}/?invite=${encodeURIComponent(invite.code)}`; + try { + await navigator.clipboard.writeText(link); + alert(`Invite link copied:\n${link}`); + } catch { + prompt("Copy invite link:", link); + } + } catch (err) { + alert(err.message); + } +} + +// --- Logic --- + +async function loadGuilds() { + state.guilds = await api("/guilds"); + renderGuilds(); +} + +async function loadDMConversations() { + state.dmConversations = await api("/dms"); + renderDMs(); +} + +async function loadGuildMembers() { + if (!state.selectedGuildId) { + state.members = []; + renderMembers(); + return; + } + state.members = await api(`/guilds/${state.selectedGuildId}/members`); + renderMembers(); +} + +async function loadChannels() { + if (!state.selectedGuildId) { + state.channels = []; + renderChannels(); + return; + } + + state.channels = await api(`/guilds/${state.selectedGuildId}/channels`); + renderChannels(); +} + +async function refreshVoicePresence() { + if (!state.selectedGuildId) { + state.voicePresence.clear(); + renderChannels(); + return; + } + + try { + const res = await api(`/guilds/${state.selectedGuildId}/voice-presence`); + state.voicePresence.clear(); + for (const entry of res.channels || []) { + state.voicePresence.set(entry.channel_id, entry.participants || []); + } + renderChannels(); + } catch (err) { + console.warn("voice presence failed", err); + } +} + +function startVoicePresencePolling() { + if (state.voicePresencePollId) clearInterval(state.voicePresencePollId); + state.voicePresencePollId = setInterval(() => { + refreshVoicePresence().catch(() => {}); + }, 3000); +} + +// --- Voice --- + +function getVoiceWsUrl(channelId) { + const proto = location.protocol === "https:" ? "wss" : "ws"; + return `${proto}://${location.host}/channels/${channelId}/voice/ws`; +} + +function shouldInitiateOffer(peerId) { + if (!state.me || !state.me.id) return false; + return state.me.id > peerId; +} + +async function loadDeepFilterLib() { + if (!deepFilterLibPromise) { + deepFilterLibPromise = import(DEEPFILTERNET_LIB_PATH); + } + return deepFilterLibPromise; +} + +async function buildDenoiserNode(audioContext) { + if (!audioContext.audioWorklet) { + throw new Error("AudioWorklet is not supported in this browser"); + } + + const df = await loadDeepFilterLib(); + const core = new df.DeepFilterNet3Core({ + sampleRate: 48000, + noiseReductionLevel: NSNET2_COMPAT_SUPPRESSION, + assetConfig: { + cdnUrl: "/static/vendor/deepfilternet3", + }, + }); + await core.initialize(); + const workletNode = await core.createAudioWorkletNode(audioContext); + state.voice.deepFilterCore = core; + return workletNode; +} + +function stopAndClearAudioPipeline() { + if (state.voice.deepFilterCore) { + state.voice.deepFilterCore.destroy(); + state.voice.deepFilterCore = null; + } + if (state.voice.denoiserNode && typeof state.voice.denoiserNode.destroy === "function") { + state.voice.denoiserNode.destroy(); + } + if (state.voice.localStream) { + for (const track of state.voice.localStream.getTracks()) track.stop(); + } + if (state.voice.rawStream && state.voice.rawStream !== state.voice.localStream) { + for (const track of state.voice.rawStream.getTracks()) track.stop(); + } + if (state.voice.audioContext) { + state.voice.audioContext.close().catch(() => {}); + } + state.voice.localStream = null; + state.voice.rawStream = null; + state.voice.audioContext = null; + state.voice.denoiserNode = null; + state.voice.deepFilterCore = null; +} + +async function buildAudioPipeline(rawStream) { + const audioContext = new AudioContext(); + const source = audioContext.createMediaStreamSource(rawStream); + const destination = audioContext.createMediaStreamDestination(); + state.voice.audioContext = audioContext; + + let head = source; + + const denoiserNode = await buildDenoiserNode(audioContext); + if (denoiserNode) { + head.connect(denoiserNode); + head = denoiserNode; + } + head.connect(destination); + + state.voice.denoiserNode = denoiserNode; + return destination.stream; +} + +async function createLocalVoiceStream() { + const constraints = { + audio: { + channelCount: 1, + sampleRate: 48000, + echoCancellation: true, + noiseSuppression: false, + autoGainControl: false, + }, + video: false, + }; + const rawStream = await navigator.mediaDevices.getUserMedia(constraints); + const localStream = await buildAudioPipeline(rawStream); + state.voice.rawStream = rawStream; + state.voice.localStream = localStream; + if (state.voice.muted) { + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = false; + }); + } +} + +function ensurePeerConnection(peerId) { + if (state.voice.peerConnections.has(peerId)) { + return state.voice.peerConnections.get(peerId); + } + + const pc = new RTCPeerConnection({ iceServers: state.voice.iceServers }); + + if (state.voice.localStream) { + for (const track of state.voice.localStream.getTracks()) { + pc.addTrack(track, state.voice.localStream); + } + } + + pc.onicecandidate = (event) => { + if (!event.candidate || !state.voice.ws) return; + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "ice", + data: event.candidate, + })); + }; + + pc.ontrack = (event) => { + let audio = document.getElementById(`audio-${peerId}`); + if (!audio) { + audio = document.createElement("audio"); + audio.id = `audio-${peerId}`; + audio.autoplay = true; + audio.playsInline = true; + document.body.appendChild(audio); + } + audio.srcObject = event.streams[0]; + }; + + state.voice.peerConnections.set(peerId, pc); + return pc; +} + +async function sendOffer(peerId) { + const pc = ensurePeerConnection(peerId); + const offer = await pc.createOffer(); + await pc.setLocalDescription(offer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: peerId, + kind: "offer", + data: offer, + })); +} + +async function handleSignal(fromPeerId, kind, data) { + const pc = ensurePeerConnection(fromPeerId); + + if (kind === "offer") { + await pc.setRemoteDescription(new RTCSessionDescription(data)); + const answer = await pc.createAnswer(); + await pc.setLocalDescription(answer); + state.voice.ws.send(JSON.stringify({ + type: "signal", + to_user_id: fromPeerId, + kind: "answer", + data: answer, + })); + } else if (kind === "answer") { + await pc.setRemoteDescription(new RTCSessionDescription(data)); + } else if (kind === "ice") { + try { + await pc.addIceCandidate(data ? new RTCIceCandidate(data) : null); + } catch (err) { + console.warn("failed to add ice candidate", err); + } + } +} + +async function joinVoice() { + if (!state.selectedVoiceChannelId) return; + if (state.voice.joinedChannelId === state.selectedVoiceChannelId) return; + + await leaveVoice(); + + try { + await createLocalVoiceStream(); + } catch (err) { + console.error("microphone denied", err); + return; + } + + const ws = new WebSocket(getVoiceWsUrl(state.selectedVoiceChannelId)); + state.voice.ws = ws; + state.voice.joinedChannelId = state.selectedVoiceChannelId; + + ws.onopen = () => { + const channel = state.channels.find(c => c.id === state.selectedVoiceChannelId); + el.vcChannelName.textContent = channel ? channel.name : "Voice"; + el.voiceConnection.classList.remove("hidden"); + refreshVoicePresence().catch(() => {}); + }; + + ws.onmessage = async (event) => { + const msg = JSON.parse(event.data); + if (msg.type === "peers") { + for (const peer of msg.peers) { + if (shouldInitiateOffer(peer.user_id)) await sendOffer(peer.user_id); + } + } else if (msg.type === "peer_joined") { + if (shouldInitiateOffer(msg.user_id)) await sendOffer(msg.user_id); + } else if (msg.type === "peer_left") { + const pc = state.voice.peerConnections.get(msg.user_id); + if (pc) { + pc.close(); + state.voice.peerConnections.delete(msg.user_id); + } + document.getElementById(`audio-${msg.user_id}`)?.remove(); + } else if (msg.type === "signal") { + await handleSignal(msg.from_user_id, msg.kind, msg.data); + } + refreshVoicePresence().catch(() => {}); + }; + + ws.onclose = () => { + el.voiceConnection.classList.add("hidden"); + for (const pc of state.voice.peerConnections.values()) pc.close(); + state.voice.peerConnections.clear(); + stopAndClearAudioPipeline(); + state.voice.joinedChannelId = null; + state.voice.ws = null; + refreshVoicePresence().catch(() => {}); + }; +} + +async function leaveVoice() { + if (state.voice.ws) state.voice.ws.close(); +} + +function toggleMute() { + if (!state.voice.localStream) return; + state.voice.muted = !state.voice.muted; + state.voice.localStream.getAudioTracks().forEach((t) => { + t.enabled = !state.voice.muted; + }); + el.voiceMuteBtn.innerHTML = state.voice.muted ? '' : ''; + el.voiceMuteBtn.style.color = state.voice.muted ? 'var(--danger)' : 'var(--text-muted)'; + lucide.createIcons(); +} + +// --- Initialization --- + +async function init() { + lucide.createIcons(); + + el.loginBtn.onclick = () => { location.href = "/auth/login"; }; + + el.logoutBtn.onclick = async () => { + await leaveVoice(); + await api("/auth/logout", { method: "POST" }); + location.reload(); + }; + + el.addGuildBtn.onclick = () => { el.modalContainer.classList.remove("hidden"); }; + el.createInviteBtn.onclick = createInviteLink; + el.modalCancel.onclick = () => { el.modalContainer.classList.add("hidden"); }; + + el.addTextBtn.onclick = () => { + if (!state.selectedGuildId) { + alert("Create or select a server first."); + return; + } + document.querySelector('input[name="channel-kind"][value="text"]').checked = true; + el.channelModal.classList.remove("hidden"); + }; + el.addVoiceBtn.onclick = () => { + if (!state.selectedGuildId) { + alert("Create or select a server first."); + return; + } + document.querySelector('input[name="channel-kind"][value="voice"]').checked = true; + el.channelModal.classList.remove("hidden"); + }; + el.channelModalCancel.onclick = () => { el.channelModal.classList.add("hidden"); }; + + el.guildForm.onsubmit = async (e) => { + e.preventDefault(); + try { + const guild = await api("/guilds", { + method: "POST", + body: JSON.stringify({ name: el.guildName.value }), + }); + el.guildName.value = ""; + el.modalContainer.classList.add("hidden"); + state.guilds.push(guild); + state.selectedGuildId = guild.id; + state.selectedTextChannelId = null; + state.selectedVoiceChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + renderMessages([]); + updateHeaderLabels(); + } catch (err) { alert(err.message); } + }; + + el.channelForm.onsubmit = async (e) => { + e.preventDefault(); + if (!state.selectedGuildId) { + alert("Select a server first."); + return; + } + const kindInput = document.querySelector('input[name="channel-kind"]:checked'); + const kind = kindInput ? kindInput.value : "text"; + const channelName = el.channelName.value.trim(); + if (!channelName) { + alert("Channel name is required."); + return; + } + try { + const created = await api("/channels", { + method: "POST", + body: JSON.stringify({ + guild_id: state.selectedGuildId, + name: channelName, + kind: kind, + }), + }); + el.channelName.value = ""; + el.channelModal.classList.add("hidden"); + await loadChannels(); + if (created.kind === "text") { + state.selectedTextChannelId = created.id; + state.selectedVoiceChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + const messages = await api(`/channels/${created.id}/messages?limit=100`); + renderMessages(messages); + } else { + state.selectedVoiceChannelId = created.id; + state.selectedTextChannelId = null; + state.selectedDmUserId = null; + state.selectedDmDisplayName = null; + renderChannels(); + renderDMs(); + updateHeaderLabels(); + } + } catch (err) { alert(err.message); } + }; + + el.messageForm.onsubmit = async (e) => { + e.preventDefault(); + const body = el.messageBody.value.trim(); + if (!body) return; + + try { + if (state.selectedTextChannelId) { + await api(`/channels/${state.selectedTextChannelId}/messages`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } else if (state.selectedDmUserId) { + await api(`/dms/${state.selectedDmUserId}/messages`, { + method: "POST", + body: JSON.stringify({ body }), + }); + } else { + return; + } + el.messageBody.value = ""; + const messages = state.selectedTextChannelId + ? await api(`/channels/${state.selectedTextChannelId}/messages?limit=100`) + : await api(`/dms/${state.selectedDmUserId}/messages?limit=100`); + renderMessages(messages); + await loadDMConversations(); + renderDMs(); + } catch (err) { alert(err.message); } + }; + + el.voiceMuteBtn.onclick = toggleMute; + el.voiceLeaveBtn.onclick = leaveVoice; + + try { + state.me = await api("/me"); + if (state.me && state.me.display_name) { + el.userName.textContent = state.me.display_name; + el.userAvatar.textContent = shortName(state.me.display_name); + } + el.authScreen.classList.add("hidden"); + el.main.classList.remove("hidden"); + + const params = new URLSearchParams(location.search); + const inviteCode = params.get("invite"); + if (inviteCode) { + try { + const joinedGuild = await api(`/invites/${encodeURIComponent(inviteCode)}/join`, { + method: "POST", + }); + localStorage.setItem(LAST_GUILD_STORAGE_KEY, joinedGuild.id); + } catch (err) { + alert(`Failed to join invite: ${err.message}`); + } finally { + params.delete("invite"); + const nextQuery = params.toString(); + const nextUrl = `${location.pathname}${nextQuery ? `?${nextQuery}` : ""}`; + history.replaceState(null, "", nextUrl); + } + } + + await loadGuilds(); + await loadDMConversations(); + + if (state.guilds.length > 0) { + const lastGuildId = localStorage.getItem(LAST_GUILD_STORAGE_KEY); + const guild = state.guilds.find((g) => g.id === lastGuildId) || state.guilds[0]; + state.selectedGuildId = guild.id; + localStorage.setItem(LAST_GUILD_STORAGE_KEY, guild.id); + renderGuilds(); + await loadChannels(); + await loadGuildMembers(); + await refreshVoicePresence(); + updateHeaderLabels(); + } else { + state.members = []; + renderMembers(); + updateHeaderLabels(); + } + + startVoicePresencePolling(); + lucide.createIcons(); + } catch (err) { + console.error("init failed", err); + el.authScreen.classList.remove("hidden"); + el.main.classList.add("hidden"); + } +} + +init(); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..ef09364 --- /dev/null +++ b/static/index.html @@ -0,0 +1,195 @@ + + + + + + Chattz + + + + + +
+
+
C
+

Chattz

+

Sign in to open your servers.

+ +

+
+
+ + + + + + + + + + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..053d078 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,526 @@ +:root { + --bg-darker: #1e1f22; + --bg-sidebar: #2b2d31; + --bg-main: #313338; + --bg-secondary: #232428; + --bg-tertiary: #111214; + --bg-modifier-selected: rgba(78, 80, 88, 0.6); + --bg-modifier-hover: rgba(78, 80, 88, 0.3); + --bg-input: #383a40; + + --text-normal: #dbdee1; + --text-muted: #949ba4; + --text-strong: #f2f3f5; + --text-link: #00a8fc; + + --brand: #5865f2; + --brand-hover: #4752c4; + --green: #23a559; + --danger: #f23f43; + --yellow: #f0b232; + + --font-main: "gg sans", "Inter", "Noto Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + height: 100vh; + background: var(--bg-darker); + color: var(--text-normal); + font-family: var(--font-main); + overflow: hidden; +} + +/* Scrollbars */ +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--bg-tertiary); border-radius: 4px; } +::-webkit-scrollbar-thumb:hover { background: #242529; } + +button { border: 0; cursor: pointer; transition: all 150ms ease; background: none; color: inherit; font: inherit; padding: 0; } +input, select { border: 0; outline: none; background: var(--bg-input); color: var(--text-normal); font: inherit; } + +.hidden { display: none !important; } + +/* Auth Screen */ +.auth-screen { + display: grid; + place-items: center; + height: 100vh; + background-image: url("https://discord.com/assets/f9a15998e94589d343f7.png"); + background-size: cover; +} +.auth-card { + width: min(480px, 92vw); + background: var(--bg-sidebar); + border-radius: 8px; + padding: 32px; + box-shadow: 0 2px 10px rgba(0,0,0,0.2); + text-align: center; +} +.brand-large { + width: 80px; + height: 80px; + background: var(--brand); + color: #fff; + border-radius: 20px; + display: grid; + place-items: center; + font-size: 40px; + font-weight: 800; + margin: 0 auto 20px; +} +.auth-card h1 { margin: 0 0 8px; color: var(--text-strong); } +.auth-card p { margin: 0 0 24px; color: var(--text-muted); } +#login-btn { + width: 100%; + background: var(--brand); + color: #fff; + padding: 12px; + border-radius: 3px; + font-weight: 600; + font-size: 16px; +} +#login-btn:hover { background: var(--brand-hover); } + +/* Main Layout */ +.shell { + height: 100vh; + display: grid; + grid-template-columns: 72px 240px 1fr 240px; + background: var(--bg-main); +} + +/* Server Rail */ +.server-rail { + background: var(--bg-darker); + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 0; + gap: 8px; + overflow-y: auto; + scrollbar-width: none; +} +.server-rail::-webkit-scrollbar { display: none; } +.guild-list { + display: flex; + flex-direction: column; + gap: 8px; + padding: 6px 0; +} + +.brand, .guild-pill { + width: 48px; + height: 48px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + position: relative; + background: var(--bg-sidebar); +} + +.brand { background: var(--brand); color: #fff; border-radius: 16px; margin-bottom: 2px; } +.brand:hover { border-radius: 16px; } + +.separator { + width: 32px; + height: 2px; + background: var(--bg-modifier-selected); + margin-bottom: 2px; + border-radius: 1px; +} + +.guild-pill:hover, .guild-pill.active { + border-radius: 16px; + background: var(--brand); + color: #fff; +} + +.guild-pill::before { + content: ""; + position: absolute; + left: -12px; + width: 4px; + height: 0; + background: #fff; + border-radius: 0 4px 4px 0; + transition: all 0.2s ease; +} +.guild-pill:hover::before { height: 20px; } +.guild-pill.active::before { height: 40px; } + +.action-pill { color: var(--green); } +.action-pill:hover { background: var(--green); color: #fff; } + +/* Channel Sidebar */ +.channel-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} + +.sidebar-header { + padding: 0 16px; + height: 48px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--bg-darker); + box-shadow: 0 1px 0 rgba(0,0,0,0.1); + cursor: pointer; + transition: background-color 0.1s; +} +.sidebar-header:hover { background: var(--bg-modifier-hover); } +.sidebar-header h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.sidebar-header-actions { + display: flex; + align-items: center; + gap: 8px; +} +.header-action-btn { + width: 24px; + height: 24px; + border-radius: 4px; + display: grid; + place-items: center; + color: var(--text-muted); +} +.header-action-btn:hover { + background: var(--bg-modifier-hover); + color: var(--text-normal); +} +.header-action-btn i { + width: 16px; + height: 16px; +} + +.sidebar-scroll { flex: 1; overflow-y: auto; padding-top: 12px; } + +.sidebar-group { margin-bottom: 20px; } +.group-title { + padding: 0 8px 0 2px; + display: flex; + align-items: center; + color: var(--text-muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.24px; + cursor: pointer; +} +.group-title:hover { color: var(--text-normal); } +.group-toggle { width: 12px; height: 12px; margin-right: 2px; } +.group-title span { flex: 1; } +.add-btn { + width: 20px; + height: 20px; + border-radius: 4px; + opacity: 0.7; + transition: opacity 0.2s, background-color 0.2s; + display: grid; + place-items: center; +} +.add-btn:hover { + opacity: 1; + background: var(--bg-modifier-hover); +} +.add-btn i { width: 16px; height: 16px; } + +.channel-list { padding: 0 8px; display: flex; flex-direction: column; gap: 2px; } +.channel-row { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + color: var(--text-muted); + font-weight: 500; + gap: 6px; +} +.channel-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } +.channel-row.active { background: var(--bg-modifier-selected); color: var(--text-strong); } +.channel-row i { width: 20px; height: 20px; opacity: 0.6; } + +/* Sidebar Footer */ +.sidebar-footer { background: var(--bg-secondary); padding: 0; } + +.user-panel { + padding: 8px; + display: flex; + align-items: center; + gap: 8px; + height: 52px; +} +.user-panel:hover { background: var(--bg-modifier-hover); } + +.avatar-wrapper { position: relative; } +.avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand); + color: #fff; + display: grid; + place-items: center; + font-weight: 700; + font-size: 14px; +} +.status-dot { + position: absolute; + bottom: -2px; + right: -2px; + width: 14px; + height: 14px; + border-radius: 50%; + border: 3px solid var(--bg-secondary); +} +.status-dot.online { background: var(--green); } + +.user-info { flex: 1; min-width: 0; } +.display-name { + font-size: 14px; + font-weight: 600; + color: var(--text-strong); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.user-status { font-size: 12px; color: var(--text-muted); } + +.user-actions { display: flex; gap: 2px; } +.user-actions button { + width: 32px; + height: 32px; + border-radius: 4px; + display: grid; + place-items: center; + color: var(--text-muted); +} +.user-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } +.user-actions i { width: 20px; height: 20px; } + +/* Voice Connection */ +.voice-connection { + padding: 8px; + background: var(--bg-secondary); + border-bottom: 1px solid rgba(255,255,255,0.05); +} +.vc-info { display: flex; align-items: center; gap: 8px; } +.vc-icon { color: var(--green); width: 20px; } +.vc-text { flex: 1; display: flex; flex-direction: column; } +.vc-status { color: var(--green); font-size: 14px; font-weight: 700; } +.vc-name { color: var(--text-muted); font-size: 12px; } +.vc-actions { display: flex; gap: 4px; } +.vc-actions button { + width: 32px; + height: 32px; + border-radius: 4px; + color: var(--text-muted); + display: grid; + place-items: center; +} +.vc-actions button:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } + +/* Chat Pane */ +.chat-pane { + display: flex; + flex-direction: column; + background: var(--bg-main); + min-width: 0; +} + +.chat-header { + height: 48px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid rgba(0,0,0,0.2); + box-shadow: 0 1px 0 rgba(0,0,0,0.1); +} +.header-icon { color: var(--text-muted); width: 24px; } +.chat-header h3 { margin: 0; font-size: 16px; font-weight: 700; color: var(--text-strong); } + +.message-list { + flex: 1; + overflow-y: scroll; + padding: 16px 0; +} + +.msg { + padding: 2px 16px; + display: flex; + gap: 16px; + margin-top: 1.0625rem; +} +.msg:hover { background: rgba(0,0,0,0.05); } + +.msg-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--brand); + flex-shrink: 0; + display: grid; + place-items: center; + color: #fff; + font-weight: 600; +} + +.msg-content { flex: 1; min-width: 0; } +.msg-header { display: flex; align-items: baseline; gap: 8px; margin-bottom: 4px; } +.msg-author { + font-weight: 600; + color: var(--text-strong); + cursor: pointer; + font-size: 1rem; +} +.msg-author:hover { text-decoration: underline; } +.msg-time { font-size: 12px; color: var(--text-muted); } +.msg-body { color: var(--text-normal); line-height: 1.375rem; white-space: pre-wrap; word-wrap: break-word; } + +.msg-grouped { margin-top: 0; padding-top: 0; padding-bottom: 0; } +.msg-grouped .msg-avatar, .msg-grouped .msg-header { display: none; } +.msg-grouped .msg-content { padding-left: 56px; } + +/* Chat Input */ +.chat-input-wrapper { padding: 0 16px 24px; } +.message-form { + background: var(--bg-input); + border-radius: 8px; + padding: 11px 16px; +} +.message-form input { + width: 100%; + background: transparent; + color: var(--text-normal); + font-size: 16px; +} +.message-form input::placeholder { color: var(--text-muted); } + +/* Member List */ +.utility-sidebar { + background: var(--bg-sidebar); + display: flex; + flex-direction: column; +} +.member-list-wrapper { flex: 1; overflow-y: auto; padding: 12px 8px; } +.member-row { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 8px; + border-radius: 4px; + color: var(--text-muted); + cursor: pointer; +} +.member-row:hover { background: var(--bg-modifier-hover); color: var(--text-normal); } +.member-avatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--brand); + color: #fff; + display: grid; + place-items: center; + font-size: 14px; + font-weight: 600; + flex-shrink: 0; +} +.member-name { + font-weight: 500; + font-size: 15px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Modals */ +.modal-container { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.85); + display: grid; + place-items: center; + z-index: 1000; +} +.modal { + background: var(--bg-sidebar); + width: min(440px, 95vw); + border-radius: 5px; + padding: 24px; + color: var(--text-normal); +} +.modal h2 { margin: 0 0 16px; text-align: center; color: var(--text-strong); } +.form-item { margin-bottom: 20px; } +.form-item label { + display: block; + font-size: 12px; + font-weight: 700; + color: var(--text-muted); + margin-bottom: 8px; +} +.form-item input { + width: 100%; + padding: 10px; + border-radius: 3px; + background: var(--bg-darker); +} + +.modal-footer { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 16px; +} +.cancel-btn { padding: 10px 20px; color: var(--text-strong); font-weight: 500; } +.cancel-btn:hover { text-decoration: underline; } +.submit-btn { + background: var(--brand); + color: #fff; + padding: 10px 24px; + border-radius: 3px; + font-weight: 600; +} +.submit-btn:hover { background: var(--brand-hover); } + +/* Radio Group for Channel Type */ +.radio-group { display: flex; flex-direction: column; gap: 8px; } +.radio-item { cursor: pointer; position: relative; } +.radio-item input { position: absolute; opacity: 0; } +.radio-box { + display: flex; + align-items: center; + gap: 12px; + padding: 10px; + background: var(--bg-modifier-hover); + border-radius: 4px; + transition: all 0.1s; +} +.radio-item input:checked + .radio-box { background: var(--bg-modifier-selected); color: var(--text-strong); } +.radio-box i { width: 24px; height: 24px; color: var(--text-muted); } +.radio-text { display: flex; flex-direction: column; } +.radio-text strong { font-size: 16px; } +.radio-text span { font-size: 12px; color: var(--text-muted); } + +@media (max-width: 1100px) { + .shell { grid-template-columns: 72px 240px 1fr; } + .utility-sidebar { display: none; } +} + +@media (max-width: 768px) { + .shell { grid-template-columns: 72px 1fr; } + .channel-sidebar { display: none; } +} diff --git a/static/vendor/deepfilternet3/index.esm.js b/static/vendor/deepfilternet3/index.esm.js new file mode 100644 index 0000000..7ad3ee9 --- /dev/null +++ b/static/vendor/deepfilternet3/index.esm.js @@ -0,0 +1,249 @@ +class AssetLoader { + constructor(config = {}) { + this.cdnUrl = config.cdnUrl ?? 'https://cdn.mezon.ai/AI/models/datas/noise_suppression/deepfilternet3'; + } + getCdnUrl(relativePath) { + return `${this.cdnUrl}/${relativePath}`; + } + getAssetUrls() { + return { + wasm: this.getCdnUrl('v2/pkg/df_bg.wasm'), + model: this.getCdnUrl('v2/models/DeepFilterNet3_onnx.tar.gz') + }; + } + async fetchAsset(url) { + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch asset: ${response.statusText}`); + } + return response.arrayBuffer(); + } +} +let defaultLoader = null; +function getAssetLoader(config) { + if (!defaultLoader || config) { + defaultLoader = new AssetLoader(config); + } + return defaultLoader; +} + +/** + * Creates a worklet module URL from inline code string + * This approach works with all bundlers without special configuration + */ +async function createWorkletModule(audioContext, workletCode) { + const blob = new Blob([workletCode], { type: 'application/javascript' }); + const blobUrl = URL.createObjectURL(blob); + await audioContext.audioWorklet.addModule(blobUrl); +} + +const WorkletMessageTypes = { + SET_SUPPRESSION_LEVEL: 'SET_SUPPRESSION_LEVEL', + SET_BYPASS: 'SET_BYPASS' +}; + +var workletCode = "(function () {\n 'use strict';\n\n let wasm;\n\n const heap = new Array(128).fill(undefined);\n\n heap.push(undefined, null, true, false);\n\n function getObject(idx) { return heap[idx]; }\n\n let heap_next = heap.length;\n\n function dropObject(idx) {\n if (idx < 132) return;\n heap[idx] = heap_next;\n heap_next = idx;\n }\n\n function takeObject(idx) {\n const ret = getObject(idx);\n dropObject(idx);\n return ret;\n }\n\n const cachedTextDecoder = (typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8', { ignoreBOM: true, fatal: true }) : { decode: () => { throw Error('TextDecoder not available') } } );\n\n if (typeof TextDecoder !== 'undefined') { cachedTextDecoder.decode(); }\n let cachedUint8Memory0 = null;\n\n function getUint8Memory0() {\n if (cachedUint8Memory0 === null || cachedUint8Memory0.byteLength === 0) {\n cachedUint8Memory0 = new Uint8Array(wasm.memory.buffer);\n }\n return cachedUint8Memory0;\n }\n\n function getStringFromWasm0(ptr, len) {\n ptr = ptr >>> 0;\n return cachedTextDecoder.decode(getUint8Memory0().subarray(ptr, ptr + len));\n }\n\n function addHeapObject(obj) {\n if (heap_next === heap.length) heap.push(heap.length + 1);\n const idx = heap_next;\n heap_next = heap[idx];\n\n heap[idx] = obj;\n return idx;\n }\n /**\n * Set DeepFilterNet attenuation limit.\n *\n * Args:\n * - lim_db: New attenuation limit in dB.\n * @param {number} st\n * @param {number} lim_db\n */\n function df_set_atten_lim(st, lim_db) {\n wasm.df_set_atten_lim(st, lim_db);\n }\n\n /**\n * Get DeepFilterNet frame size in samples.\n * @param {number} st\n * @returns {number}\n */\n function df_get_frame_length(st) {\n const ret = wasm.df_get_frame_length(st);\n return ret >>> 0;\n }\n\n let WASM_VECTOR_LEN = 0;\n\n function passArray8ToWasm0(arg, malloc) {\n const ptr = malloc(arg.length * 1, 1) >>> 0;\n getUint8Memory0().set(arg, ptr / 1);\n WASM_VECTOR_LEN = arg.length;\n return ptr;\n }\n /**\n * Create a DeepFilterNet Model\n *\n * Args:\n * - path: File path to a DeepFilterNet tar.gz onnx model\n * - atten_lim: Attenuation limit in dB.\n *\n * Returns:\n * - DF state doing the full processing: stft, DNN noise reduction, istft.\n * @param {Uint8Array} model_bytes\n * @param {number} atten_lim\n * @returns {number}\n */\n function df_create(model_bytes, atten_lim) {\n const ptr0 = passArray8ToWasm0(model_bytes, wasm.__wbindgen_malloc);\n const len0 = WASM_VECTOR_LEN;\n const ret = wasm.df_create(ptr0, len0, atten_lim);\n return ret >>> 0;\n }\n\n let cachedFloat32Memory0 = null;\n\n function getFloat32Memory0() {\n if (cachedFloat32Memory0 === null || cachedFloat32Memory0.byteLength === 0) {\n cachedFloat32Memory0 = new Float32Array(wasm.memory.buffer);\n }\n return cachedFloat32Memory0;\n }\n\n function passArrayF32ToWasm0(arg, malloc) {\n const ptr = malloc(arg.length * 4, 4) >>> 0;\n getFloat32Memory0().set(arg, ptr / 4);\n WASM_VECTOR_LEN = arg.length;\n return ptr;\n }\n /**\n * Processes a chunk of samples.\n *\n * Args:\n * - df_state: Created via df_create()\n * - input: Input buffer of length df_get_frame_length()\n * - output: Output buffer of length df_get_frame_length()\n *\n * Returns:\n * - Local SNR of the current frame.\n * @param {number} st\n * @param {Float32Array} input\n * @returns {Float32Array}\n */\n function df_process_frame(st, input) {\n const ptr0 = passArrayF32ToWasm0(input, wasm.__wbindgen_malloc);\n const len0 = WASM_VECTOR_LEN;\n const ret = wasm.df_process_frame(st, ptr0, len0);\n return takeObject(ret);\n }\n\n function handleError(f, args) {\n try {\n return f.apply(this, args);\n } catch (e) {\n wasm.__wbindgen_exn_store(addHeapObject(e));\n }\n }\n\n (typeof FinalizationRegistry === 'undefined')\n ? { }\n : new FinalizationRegistry(ptr => wasm.__wbg_dfstate_free(ptr >>> 0));\n\n function __wbg_get_imports() {\n const imports = {};\n imports.wbg = {};\n imports.wbg.__wbindgen_object_drop_ref = function(arg0) {\n takeObject(arg0);\n };\n imports.wbg.__wbg_crypto_566d7465cdbb6b7a = function(arg0) {\n const ret = getObject(arg0).crypto;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_is_object = function(arg0) {\n const val = getObject(arg0);\n const ret = typeof(val) === 'object' && val !== null;\n return ret;\n };\n imports.wbg.__wbg_process_dc09a8c7d59982f6 = function(arg0) {\n const ret = getObject(arg0).process;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_versions_d98c6400c6ca2bd8 = function(arg0) {\n const ret = getObject(arg0).versions;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_node_caaf83d002149bd5 = function(arg0) {\n const ret = getObject(arg0).node;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_is_string = function(arg0) {\n const ret = typeof(getObject(arg0)) === 'string';\n return ret;\n };\n imports.wbg.__wbg_require_94a9da52636aacbf = function() { return handleError(function () {\n const ret = module.require;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_is_function = function(arg0) {\n const ret = typeof(getObject(arg0)) === 'function';\n return ret;\n };\n imports.wbg.__wbindgen_string_new = function(arg0, arg1) {\n const ret = getStringFromWasm0(arg0, arg1);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_msCrypto_0b84745e9245cdf6 = function(arg0) {\n const ret = getObject(arg0).msCrypto;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_randomFillSync_290977693942bf03 = function() { return handleError(function (arg0, arg1) {\n getObject(arg0).randomFillSync(takeObject(arg1));\n }, arguments) };\n imports.wbg.__wbg_getRandomValues_260cc23a41afad9a = function() { return handleError(function (arg0, arg1) {\n getObject(arg0).getRandomValues(getObject(arg1));\n }, arguments) };\n imports.wbg.__wbg_newnoargs_e258087cd0daa0ea = function(arg0, arg1) {\n const ret = new Function(getStringFromWasm0(arg0, arg1));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_new_63b92bc8671ed464 = function(arg0) {\n const ret = new Uint8Array(getObject(arg0));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_new_9efabd6b6d2ce46d = function(arg0) {\n const ret = new Float32Array(getObject(arg0));\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_buffer_12d079cc21e14bdb = function(arg0) {\n const ret = getObject(arg0).buffer;\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithbyteoffsetandlength_aa4a17c33a06e5cb = function(arg0, arg1, arg2) {\n const ret = new Uint8Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithlength_e9b4878cebadb3d3 = function(arg0) {\n const ret = new Uint8Array(arg0 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_set_a47bac70306a19a7 = function(arg0, arg1, arg2) {\n getObject(arg0).set(getObject(arg1), arg2 >>> 0);\n };\n imports.wbg.__wbg_subarray_a1f73cd4b5b42fe1 = function(arg0, arg1, arg2) {\n const ret = getObject(arg0).subarray(arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_newwithbyteoffsetandlength_4a659d079a1650e0 = function(arg0, arg1, arg2) {\n const ret = new Float32Array(getObject(arg0), arg1 >>> 0, arg2 >>> 0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_self_ce0dbfc45cf2f5be = function() { return handleError(function () {\n const ret = self.self;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_window_c6fb939a7f436783 = function() { return handleError(function () {\n const ret = window.window;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_globalThis_d1e6af4856ba331b = function() { return handleError(function () {\n const ret = globalThis.globalThis;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbg_global_207b558942527489 = function() { return handleError(function () {\n const ret = global.global;\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_is_undefined = function(arg0) {\n const ret = getObject(arg0) === undefined;\n return ret;\n };\n imports.wbg.__wbg_call_27c0f87801dedf93 = function() { return handleError(function (arg0, arg1) {\n const ret = getObject(arg0).call(getObject(arg1));\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_object_clone_ref = function(arg0) {\n const ret = getObject(arg0);\n return addHeapObject(ret);\n };\n imports.wbg.__wbg_call_b3ca7c6051f9bec1 = function() { return handleError(function (arg0, arg1, arg2) {\n const ret = getObject(arg0).call(getObject(arg1), getObject(arg2));\n return addHeapObject(ret);\n }, arguments) };\n imports.wbg.__wbindgen_memory = function() {\n const ret = wasm.memory;\n return addHeapObject(ret);\n };\n imports.wbg.__wbindgen_throw = function(arg0, arg1) {\n throw new Error(getStringFromWasm0(arg0, arg1));\n };\n\n return imports;\n }\n\n function __wbg_finalize_init(instance, module) {\n wasm = instance.exports;\n cachedFloat32Memory0 = null;\n cachedUint8Memory0 = null;\n\n\n return wasm;\n }\n\n function initSync(module) {\n if (wasm !== undefined) return wasm;\n\n const imports = __wbg_get_imports();\n\n if (!(module instanceof WebAssembly.Module)) {\n module = new WebAssembly.Module(module);\n }\n\n const instance = new WebAssembly.Instance(module, imports);\n\n return __wbg_finalize_init(instance);\n }\n\n const WorkletMessageTypes = {\n SET_SUPPRESSION_LEVEL: 'SET_SUPPRESSION_LEVEL',\n SET_BYPASS: 'SET_BYPASS'\n };\n\n class DeepFilterAudioProcessor extends AudioWorkletProcessor {\n constructor(options) {\n super();\n this.dfModel = null;\n this.inputWritePos = 0;\n this.inputReadPos = 0;\n this.outputWritePos = 0;\n this.outputReadPos = 0;\n this.bypass = false;\n this.isInitialized = false;\n this.tempFrame = null;\n this.bufferSize = 8192;\n this.inputBuffer = new Float32Array(this.bufferSize);\n this.outputBuffer = new Float32Array(this.bufferSize);\n try {\n // Initialize WASM from pre-compiled module\n initSync(options.processorOptions.wasmModule);\n const modelBytes = new Uint8Array(options.processorOptions.modelBytes);\n const handle = df_create(modelBytes, options.processorOptions.suppressionLevel ?? 50);\n const frameLength = df_get_frame_length(handle);\n this.dfModel = { handle, frameLength };\n this.bufferSize = frameLength * 4;\n this.inputBuffer = new Float32Array(this.bufferSize);\n this.outputBuffer = new Float32Array(this.bufferSize);\n // Pre-allocate temp frame buffer for processing\n this.tempFrame = new Float32Array(frameLength);\n this.isInitialized = true;\n this.port.onmessage = (event) => {\n this.handleMessage(event.data);\n };\n }\n catch (error) {\n console.error('Failed to initialize DeepFilter in AudioWorklet:', error);\n this.isInitialized = false;\n }\n }\n handleMessage(data) {\n switch (data.type) {\n case WorkletMessageTypes.SET_SUPPRESSION_LEVEL:\n if (this.dfModel && typeof data.value === 'number') {\n const level = Math.max(0, Math.min(100, Math.floor(data.value)));\n df_set_atten_lim(this.dfModel.handle, level);\n }\n break;\n case WorkletMessageTypes.SET_BYPASS:\n this.bypass = Boolean(data.value);\n break;\n }\n }\n getInputAvailable() {\n return (this.inputWritePos - this.inputReadPos + this.bufferSize) % this.bufferSize;\n }\n getOutputAvailable() {\n return (this.outputWritePos - this.outputReadPos + this.bufferSize) % this.bufferSize;\n }\n process(inputList, outputList) {\n const sourceLimit = Math.min(inputList.length, outputList.length);\n const input = inputList[0]?.[0];\n if (!input) {\n return true;\n }\n // Passthrough mode - copy input to all output channels\n if (!this.isInitialized || !this.dfModel || this.bypass || !this.tempFrame) {\n for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {\n const output = outputList[inputNum];\n const channelCount = output.length;\n for (let channelNum = 0; channelNum < channelCount; channelNum++) {\n output[channelNum].set(input);\n }\n }\n return true;\n }\n // Write input to ring buffer\n for (let i = 0; i < input.length; i++) {\n this.inputBuffer[this.inputWritePos] = input[i];\n this.inputWritePos = (this.inputWritePos + 1) % this.bufferSize;\n }\n const frameLength = this.dfModel.frameLength;\n while (this.getInputAvailable() >= frameLength) {\n // Extract frame from ring buffer\n for (let i = 0; i < frameLength; i++) {\n this.tempFrame[i] = this.inputBuffer[this.inputReadPos];\n this.inputReadPos = (this.inputReadPos + 1) % this.bufferSize;\n }\n const processed = df_process_frame(this.dfModel.handle, this.tempFrame);\n // Write to output ring buffer\n for (let i = 0; i < processed.length; i++) {\n this.outputBuffer[this.outputWritePos] = processed[i];\n this.outputWritePos = (this.outputWritePos + 1) % this.bufferSize;\n }\n }\n const outputAvailable = this.getOutputAvailable();\n if (outputAvailable >= 128) {\n for (let inputNum = 0; inputNum < sourceLimit; inputNum++) {\n const output = outputList[inputNum];\n const channelCount = output.length;\n for (let channelNum = 0; channelNum < channelCount; channelNum++) {\n const outputChannel = output[channelNum];\n let readPos = this.outputReadPos;\n for (let i = 0; i < 128; i++) {\n outputChannel[i] = this.outputBuffer[readPos];\n readPos = (readPos + 1) % this.bufferSize;\n }\n }\n }\n this.outputReadPos = (this.outputReadPos + 128) % this.bufferSize;\n }\n return true;\n }\n }\n registerProcessor('deepfilter-audio-processor', DeepFilterAudioProcessor);\n\n})();\n"; + +class DeepFilterNet3Core { + constructor(config = {}) { + this.assets = null; + this.workletNode = null; + this.isInitialized = false; + this.bypassEnabled = false; + this.config = { + sampleRate: config.sampleRate ?? 48000, + noiseReductionLevel: config.noiseReductionLevel ?? 50, + assetConfig: config.assetConfig + }; + this.assetLoader = getAssetLoader(config.assetConfig); + } + async initialize() { + if (this.isInitialized) + return; + // Fetch and compile WASM on main thread + const assetUrls = this.assetLoader.getAssetUrls(); + const [wasmBytes, modelBytes] = await Promise.all([ + this.assetLoader.fetchAsset(assetUrls.wasm), + this.assetLoader.fetchAsset(assetUrls.model) + ]); + // Compile WASM module + const wasmModule = await WebAssembly.compile(wasmBytes); + this.assets = { wasmModule, modelBytes }; + this.isInitialized = true; + } + async createAudioWorkletNode(audioContext) { + this.ensureInitialized(); + if (!this.assets) { + throw new Error('Assets not loaded'); + } + await createWorkletModule(audioContext, workletCode); + this.workletNode = new AudioWorkletNode(audioContext, 'deepfilter-audio-processor', { + processorOptions: { + wasmModule: this.assets.wasmModule, + modelBytes: this.assets.modelBytes, + suppressionLevel: this.config.noiseReductionLevel + } + }); + return this.workletNode; + } + setSuppressionLevel(level) { + if (!this.workletNode || typeof level !== 'number' || isNaN(level)) + return; + const clampedLevel = Math.max(0, Math.min(100, Math.floor(level))); + this.workletNode.port.postMessage({ + type: WorkletMessageTypes.SET_SUPPRESSION_LEVEL, + value: clampedLevel + }); + } + destroy() { + if (!this.isInitialized) + return; + if (this.workletNode) { + this.workletNode.disconnect(); + this.workletNode = null; + } + this.assets = null; + this.isInitialized = false; + } + isReady() { + return this.isInitialized && this.workletNode !== null; + } + setNoiseSuppressionEnabled(enabled) { + if (!this.workletNode) + return; + this.bypassEnabled = !enabled; + this.workletNode.port.postMessage({ + type: WorkletMessageTypes.SET_BYPASS, + value: !enabled + }); + } + isNoiseSuppressionEnabled() { + return !this.bypassEnabled; + } + ensureInitialized() { + if (!this.isInitialized) { + throw new Error('Processor not initialized. Call initialize() first.'); + } + } +} + +class DeepFilterNoiseFilterProcessor { + constructor(options = {}) { + this.name = 'deepfilternet3-noise-filter'; + this.audioContext = null; + this.sourceNode = null; + this.workletNode = null; + this.destination = null; + this.enabled = true; + this.init = async (opts) => { + const track = opts.track ?? opts.mediaStreamTrack; + if (!track) { + throw new Error('DeepFilterNoiseFilterProcessor.init: missing MediaStreamTrack'); + } + this.originalTrack = track; + await this.ensureGraph(); + }; + this.restart = async (opts) => { + const track = opts.track ?? opts.mediaStreamTrack; + if (track) { + this.originalTrack = track; + } + await this.ensureGraph(); + }; + this.setEnabled = async (enable) => { + this.enabled = enable; + this.processor.setNoiseSuppressionEnabled(enable); + return this.enabled; + }; + this.suspend = async () => { + if (this.audioContext && this.audioContext.state === 'running') { + await this.audioContext.suspend(); + } + }; + this.resume = async () => { + if (this.audioContext && this.audioContext.state === 'suspended') { + await this.audioContext.resume(); + } + }; + this.destroy = async () => { + await this.teardownGraph(); + this.processor.destroy(); + }; + const cfg = { + sampleRate: options.sampleRate ?? 48000, + noiseReductionLevel: options.noiseReductionLevel ?? 80, + assetConfig: options.assetConfig + }; + this.enabled = options.enabled ?? true; + this.processor = new DeepFilterNet3Core(cfg); + } + static isSupported() { + return typeof AudioContext !== 'undefined' && typeof WebAssembly !== 'undefined'; + } + setSuppressionLevel(level) { + this.processor.setSuppressionLevel(level); + } + isEnabled() { + return this.enabled; + } + isNoiseSuppressionEnabled() { + return this.processor.isNoiseSuppressionEnabled(); + } + async ensureGraph() { + if (!this.originalTrack) { + throw new Error('No source track'); + } + this.audioContext ?? (this.audioContext = new AudioContext({ sampleRate: 48000 })); + if (this.audioContext.state !== 'running') { + try { + await this.audioContext.resume(); + } + catch { + // Ignore resume errors + } + } + await this.processor.initialize(); + if (!this.workletNode) { + const node = await this.processor.createAudioWorkletNode(this.audioContext); + this.workletNode = node; + } + if (!this.destination) { + this.destination = this.audioContext.createMediaStreamDestination(); + this.processedTrack = this.destination.stream.getAudioTracks()[0]; + } + if (this.sourceNode) { + this.sourceNode.disconnect(); + } + this.sourceNode = this.audioContext.createMediaStreamSource(new MediaStream([this.originalTrack])); + this.sourceNode.connect(this.workletNode).connect(this.destination); + await this.setEnabled(this.enabled); + } + async teardownGraph() { + try { + if (this.workletNode) { + this.workletNode.disconnect(); + this.workletNode = null; + } + if (this.sourceNode) { + this.sourceNode.disconnect(); + this.sourceNode = null; + } + if (this.destination) { + this.destination.disconnect(); + this.destination = null; + } + if (this.audioContext) { + void this.audioContext.close(); + this.audioContext = null; + } + } + catch { + // Ignore disconnect errors + } + } +} +function DeepFilterNoiseFilter(options) { + return new DeepFilterNoiseFilterProcessor(options); +} + +export { AssetLoader, DeepFilterNet3Core, DeepFilterNoiseFilter, DeepFilterNoiseFilterProcessor, getAssetLoader }; diff --git a/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz b/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz new file mode 100644 index 0000000..1c4f4ff Binary files /dev/null and b/static/vendor/deepfilternet3/v2/models/DeepFilterNet3_onnx.tar.gz differ diff --git a/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm b/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm new file mode 100644 index 0000000..fac0f9e Binary files /dev/null and b/static/vendor/deepfilternet3/v2/pkg/df_bg.wasm differ