From b5556a78ac61e9531eeaa50ff63f498893f40486 Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Mon, 11 Aug 2025 00:20:14 +0200 Subject: [PATCH] AI-generate first working version of the app --- .env.example | 14 + Cargo.lock | 2485 ++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 26 +- login.html | 664 +++++++++++++ src/auth.rs | 390 ++++++++ src/config.rs | 45 + src/database.rs | 113 +++ src/jellyfin.rs | 106 ++ src/main.rs | 63 +- src/proxy.rs | 172 ++++ src/server.rs | 898 +++++++++++++++++ src/sftp.rs | 73 ++ src/websocket.rs | 186 ++++ 13 files changed, 5232 insertions(+), 3 deletions(-) create mode 100644 .env.example create mode 100644 login.html create mode 100644 src/auth.rs create mode 100644 src/config.rs create mode 100644 src/database.rs create mode 100644 src/jellyfin.rs create mode 100644 src/proxy.rs create mode 100644 src/server.rs create mode 100644 src/sftp.rs create mode 100644 src/websocket.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f1a042b --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# SFTP Configuration for Jellyfin Database Access +SFTP_HOST=your.jellyfin.server.com +SFTP_PORT=22 +SFTP_USER=your_username +SFTP_PASSWORD=your_password +SFTP_PATH=/var/lib/jellyfin/data/jellyfin.db + +# Jellyfin Server Configuration +JELLYFIN_URL=http://your.jellyfin.server.com:8096 +JELLYFIN_API_KEY=your_api_key_here + +# Power Management Commands +JELLYFIN_POWER_ON_COMMAND=wakeonlan aa:bb:cc:dd:ee:ff +JELLYFIN_HIBERNATE_COMMAND=ssh user@server "sudo systemctl hibernate" diff --git a/Cargo.lock b/Cargo.lock index 3c1da48..bad3027 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,3 +5,2488 @@ version = 4 [[package]] name = "AutoJellyProxy" version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64 0.22.1", + "chrono", + "dotenv", + "futures-util", + "hex", + "hmac", + "pbkdf2", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "ssh2", + "tokio", + "tokio-tungstenite 0.21.0", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "addr2line" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[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.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" + +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "base64 0.22.1", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper 1.0.2", + "tokio", + "tokio-tungstenite 0.24.0", + "tower 0.5.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "backtrace" +version = "0.3.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-targets 0.52.6", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2352e5597e9c544d5e6d9c95190d5d27738ade584fa8db0a16e130e5c2b5296e" +dependencies = [ + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268" + +[[package]] +name = "chrono" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[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 = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "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 = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[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-macro", + "futures-sink", + "futures-task", + "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.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.2+wasi-0.2.4", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.3.1", +] + +[[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 1.3.1", + "http-body 1.0.1", + "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 = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9b05277c7e8da2c93a568989bb6207bef0112e8d17df7a6eda4a3cf143bc5e" +dependencies = [ + "bytes", + "futures-core", + "http 1.3.1", + "http-body 1.0.1", + "hyper 1.6.0", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8" +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.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e" +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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown 0.15.5", +] + +[[package]] +name = "io-uring" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d93587f37623a1a17d94ef2bc9ada592f5465fe7732084ab7beefabe5c77c0c4" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.174" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libssh2-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "220e4f05ad4a218192533b300327f5150e809b54c4ec83b5a1d91833601811b9" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b70e7a7df205e92a1a4cd9aaae7898dac0aa555503cc0a649494d0d60e7651d" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12" + +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + +[[package]] +name = "lock_api" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94" + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.59.0", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nu-ansi-term" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +dependencies = [ + "overload", + "winapi", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.36.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "overload" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" + +[[package]] +name = "parking_lot" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.52.6", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", + "password-hash", + "sha2", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[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 = "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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +dependencies = [ + "zerovec", +] + +[[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-macro2" +version = "1.0.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +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", + "rand_core", +] + +[[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", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "redox_syscall" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags 2.9.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" + +[[package]] +name = "rustix" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" +dependencies = [ + "bitflags 2.9.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.9.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.219" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.142" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "030fedb782600dcbd6f02d479bf0d817ac3bb40d644745b769d6a96bc3afc5a7" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59fab13f937fa393d08645bf3a84bdfe86e296747b506ada67bb15f10f218b2a" +dependencies = [ + "itoa", + "serde", +] + +[[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 = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + +[[package]] +name = "ssh2" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f84d13b3b8a0d4e91a2629911e951db1bb8671512f5c09d7d4ba34500ba68c8" +dependencies = [ + "bitflags 2.9.1", + "libc", + "libssh2-sys", + "parking_lot", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[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 = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.47.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +dependencies = [ + "backtrace", + "bytes", + "io-uring", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "slab", + "socket2 0.6.0", + "tokio-macros", + "windows-sys 0.59.0", +] + +[[package]] +name = "tokio-macros" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.21.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.24.0", +] + +[[package]] +name = "tokio-util" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags 2.9.1", + "bytes", + "futures-util", + "http 1.3.1", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "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.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +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.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "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.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http 1.3.1", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512" + +[[package]] +name = "url" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[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.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cf4199d1e5d15ddd86a694e4d0dffa9c323ce759fea589f00fef9d81cc1931d" +dependencies = [ + "getrandom 0.3.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.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 = "wasi" +version = "0.14.2+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +dependencies = [ + "wit-bindgen-rt", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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.3", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen-rt" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" +dependencies = [ + "bitflags 2.9.1", +] + +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039dd0d3c310cf05de012d8a39ff557cb0d23087fd44cad61df08fc31907a2f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ecf5b4cc5364572d7f4c329661bcc82724222973f2cab6f050a4e5c22f75181" +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 = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/Cargo.toml b/Cargo.toml index 3b9632d..d38bfd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,30 @@ [package] name = "AutoJellyProxy" version = "0.1.0" -edition = "2024" +edition = "2021" [dependencies] +tokio = { version = "1.0", features = ["full"] } +axum = { version = "0.7", features = ["ws"] } +tower = "0.4" +tower-http = { version = "0.5", features = ["fs", "cors"] } +tokio-tungstenite = "0.21" +futures-util = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +reqwest = { version = "0.11", features = ["json"] } +rusqlite = { version = "0.31", features = ["bundled"] } +ssh2 = "0.9" +sha2 = "0.10" +hex = "0.4" +pbkdf2 = { version = "0.12", features = ["simple"] } +hmac = "0.12" +dotenv = "0.15" +tracing = "0.1" +tracing-subscriber = "0.3" +anyhow = "1.0" +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1.0", features = ["v4"] } +base64 = "0.22" +url = "2.5" +urlencoding = "2.1" diff --git a/login.html b/login.html new file mode 100644 index 0000000..6250b06 --- /dev/null +++ b/login.html @@ -0,0 +1,664 @@ + + + + + + Jellyfin + + + +
+ +
+ + + +
+
+
+ +
+ +

Your personal media universe

+

Server is offline - will start automatically after login

+
+ + +
+ + + + \ No newline at end of file diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..705dc2f --- /dev/null +++ b/src/auth.rs @@ -0,0 +1,390 @@ +use anyhow::{anyhow, Result}; +use pbkdf2::{ + password_hash::{PasswordHash, PasswordVerifier}, + Pbkdf2, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize)] +pub struct AuthRequest { + #[serde(rename = "Username")] + pub username: String, + #[serde(rename = "Pw")] + pub pw: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthResponse { + #[serde(rename = "User")] + pub user: AuthUser, + #[serde(rename = "SessionInfo")] + pub session_info: SessionInfo, + #[serde(rename = "AccessToken")] + pub access_token: String, + #[serde(rename = "ServerId")] + pub server_id: String, +} + +#[derive(Debug, Serialize)] +pub struct AuthUser { + #[serde(rename = "Name")] + pub name: String, + #[serde(rename = "ServerId")] + pub server_id: String, + #[serde(rename = "Id")] + pub id: String, + #[serde(rename = "HasPassword")] + pub has_password: bool, + #[serde(rename = "HasConfiguredPassword")] + pub has_configured_password: bool, + #[serde(rename = "HasConfiguredEasyPassword")] + pub has_configured_easy_password: bool, + #[serde(rename = "EnableAutoLogin")] + pub enable_auto_login: bool, + #[serde(rename = "LastLoginDate")] + pub last_login_date: Option, + #[serde(rename = "LastActivityDate")] + pub last_activity_date: Option, + #[serde(rename = "Configuration")] + pub configuration: UserConfiguration, + #[serde(rename = "Policy")] + pub policy: UserPolicy, +} + +#[derive(Debug, Serialize)] +pub struct UserConfiguration { + #[serde(rename = "PlayDefaultAudioTrack")] + pub play_default_audio_track: bool, + #[serde(rename = "SubtitleLanguagePreference")] + pub subtitle_language_preference: String, + #[serde(rename = "DisplayMissingEpisodes")] + pub display_missing_episodes: bool, + #[serde(rename = "GroupedFolders")] + pub grouped_folders: Vec, + #[serde(rename = "SubtitleMode")] + pub subtitle_mode: String, + #[serde(rename = "DisplayCollectionsView")] + pub display_collections_view: bool, + #[serde(rename = "EnableLocalPassword")] + pub enable_local_password: bool, + #[serde(rename = "OrderedViews")] + pub ordered_views: Vec, + #[serde(rename = "LatestItemsExcludes")] + pub latest_items_excludes: Vec, + #[serde(rename = "MyMediaExcludes")] + pub my_media_excludes: Vec, + #[serde(rename = "HidePlayedInLatest")] + pub hide_played_in_latest: bool, + #[serde(rename = "RememberAudioSelections")] + pub remember_audio_selections: bool, + #[serde(rename = "RememberSubtitleSelections")] + pub remember_subtitle_selections: bool, + #[serde(rename = "EnableNextEpisodeAutoPlay")] + pub enable_next_episode_auto_play: bool, + #[serde(rename = "CastReceiverId")] + pub cast_receiver_id: Option, +} + +#[derive(Debug, Serialize)] +pub struct UserPolicy { + #[serde(rename = "IsAdministrator")] + pub is_administrator: bool, + #[serde(rename = "IsHidden")] + pub is_hidden: bool, + #[serde(rename = "EnableCollectionManagement")] + pub enable_collection_management: bool, + #[serde(rename = "EnableSubtitleManagement")] + pub enable_subtitle_management: bool, + #[serde(rename = "EnableLyricManagement")] + pub enable_lyric_management: bool, + #[serde(rename = "IsDisabled")] + pub is_disabled: bool, + #[serde(rename = "MaxParentalRating")] + pub max_parental_rating: Option, + #[serde(rename = "BlockedTags")] + pub blocked_tags: Vec, + #[serde(rename = "AllowedTags")] + pub allowed_tags: Vec, + #[serde(rename = "EnableUserPreferenceAccess")] + pub enable_user_preference_access: bool, + #[serde(rename = "AccessSchedules")] + pub access_schedules: Vec, + #[serde(rename = "BlockUnratedItems")] + pub block_unrated_items: Vec, + #[serde(rename = "EnableRemoteControlOfOtherUsers")] + pub enable_remote_control_of_other_users: bool, + #[serde(rename = "EnableSharedDeviceControl")] + pub enable_shared_device_control: bool, + #[serde(rename = "EnableRemoteAccess")] + pub enable_remote_access: bool, + #[serde(rename = "EnableLiveTvManagement")] + pub enable_live_tv_management: bool, + #[serde(rename = "EnableLiveTvAccess")] + pub enable_live_tv_access: bool, + #[serde(rename = "EnableMediaPlayback")] + pub enable_media_playback: bool, + #[serde(rename = "EnableAudioPlaybackTranscoding")] + pub enable_audio_playback_transcoding: bool, + #[serde(rename = "EnableVideoPlaybackTranscoding")] + pub enable_video_playback_transcoding: bool, + #[serde(rename = "EnablePlaybackRemuxing")] + pub enable_playback_remuxing: bool, + #[serde(rename = "ForceRemoteSourceTranscoding")] + pub force_remote_source_transcoding: bool, + #[serde(rename = "EnableContentDeletion")] + pub enable_content_deletion: bool, + #[serde(rename = "EnableContentDeletionFromFolders")] + pub enable_content_deletion_from_folders: Vec, + #[serde(rename = "EnableContentDownloading")] + pub enable_content_downloading: bool, + #[serde(rename = "EnableSyncTranscoding")] + pub enable_sync_transcoding: bool, + #[serde(rename = "EnableMediaConversion")] + pub enable_media_conversion: bool, + #[serde(rename = "EnabledDevices")] + pub enabled_devices: Vec, + #[serde(rename = "EnableAllDevices")] + pub enable_all_devices: bool, + #[serde(rename = "EnabledChannels")] + pub enabled_channels: Vec, + #[serde(rename = "EnableAllChannels")] + pub enable_all_channels: bool, + #[serde(rename = "EnabledFolders")] + pub enabled_folders: Vec, + #[serde(rename = "EnableAllFolders")] + pub enable_all_folders: bool, + #[serde(rename = "InvalidLoginAttemptCount")] + pub invalid_login_attempt_count: i32, + #[serde(rename = "LoginAttemptsBeforeLockout")] + pub login_attempts_before_lockout: i32, + #[serde(rename = "MaxActiveSessions")] + pub max_active_sessions: i32, + #[serde(rename = "EnablePublicSharing")] + pub enable_public_sharing: bool, + #[serde(rename = "BlockedMediaFolders")] + pub blocked_media_folders: Vec, + #[serde(rename = "BlockedChannels")] + pub blocked_channels: Vec, + #[serde(rename = "RemoteClientBitrateLimit")] + pub remote_client_bitrate_limit: i32, + #[serde(rename = "AuthenticationProviderId")] + pub authentication_provider_id: String, + #[serde(rename = "PasswordResetProviderId")] + pub password_reset_provider_id: String, + #[serde(rename = "SyncPlayAccess")] + pub sync_play_access: String, +} + +#[derive(Debug, Serialize)] +pub struct SessionInfo { + #[serde(rename = "PlayState")] + pub play_state: PlayState, + #[serde(rename = "AdditionalUsers")] + pub additional_users: Vec, + #[serde(rename = "Capabilities")] + pub capabilities: Capabilities, + #[serde(rename = "RemoteEndPoint")] + pub remote_end_point: String, + #[serde(rename = "Id")] + pub id: String, + #[serde(rename = "UserId")] + pub user_id: String, + #[serde(rename = "UserName")] + pub user_name: String, + #[serde(rename = "Client")] + pub client: String, + #[serde(rename = "LastActivityDate")] + pub last_activity_date: String, + #[serde(rename = "LastPlaybackCheckIn")] + pub last_playback_check_in: String, + #[serde(rename = "DeviceName")] + pub device_name: String, + #[serde(rename = "DeviceType")] + pub device_type: String, + #[serde(rename = "NowPlayingItem")] + pub now_playing_item: Option, + #[serde(rename = "DeviceId")] + pub device_id: String, + #[serde(rename = "ApplicationVersion")] + pub application_version: String, + #[serde(rename = "IsActive")] + pub is_active: bool, + #[serde(rename = "SupportsMediaControl")] + pub supports_media_control: bool, + #[serde(rename = "SupportsRemoteControl")] + pub supports_remote_control: bool, + #[serde(rename = "HasCustomDeviceName")] + pub has_custom_device_name: bool, + #[serde(rename = "ServerId")] + pub server_id: String, + #[serde(rename = "SupportedCommands")] + pub supported_commands: Vec, +} + +#[derive(Debug, Serialize)] +pub struct PlayState { + #[serde(rename = "CanSeek")] + pub can_seek: bool, + #[serde(rename = "IsPaused")] + pub is_paused: bool, + #[serde(rename = "IsMuted")] + pub is_muted: bool, + #[serde(rename = "RepeatMode")] + pub repeat_mode: String, + #[serde(rename = "ShuffleMode")] + pub shuffle_mode: String, +} + +#[derive(Debug, Serialize)] +pub struct Capabilities { + #[serde(rename = "PlayableMediaTypes")] + pub playable_media_types: Vec, + #[serde(rename = "SupportedCommands")] + pub supported_commands: Vec, + #[serde(rename = "SupportsMediaControl")] + pub supports_media_control: bool, + #[serde(rename = "SupportsContentUploading")] + pub supports_content_uploading: bool, + #[serde(rename = "SupportsPersistentIdentifier")] + pub supports_persistent_identifier: bool, + #[serde(rename = "SupportsSync")] + pub supports_sync: bool, +} + +pub fn verify_password(password: &str, stored_hash: &str) -> Result { + // Handle PBKDF2-SHA512 format: $PBKDF2-SHA512$iterations=210000$salt$hash + if stored_hash.starts_with("$PBKDF2-SHA512$") { + let parts: Vec<&str> = stored_hash.split('$').collect(); + if parts.len() != 5 { + return Err(anyhow!("Invalid password hash format")); + } + + let iterations_part = parts[2]; + let salt_part = parts[3]; + let hash_part = parts[4]; + + let iterations: u32 = iterations_part + .strip_prefix("iterations=") + .ok_or_else(|| anyhow!("Invalid iterations format"))? + .parse()?; + + let salt = hex::decode(salt_part)?; + let expected_hash = hex::decode(hash_part)?; + + let mut result = vec![0u8; expected_hash.len()]; + pbkdf2::pbkdf2_hmac::(password.as_bytes(), &salt, iterations, &mut result); + + Ok(result == expected_hash) + } else { + // Fallback for other hash formats + match PasswordHash::new(stored_hash) { + Ok(parsed_hash) => Ok(Pbkdf2.verify_password(password.as_bytes(), &parsed_hash).is_ok()), + Err(_) => Ok(false), + } + } +} + +pub fn parse_authorization_header(auth_header: &str) -> Option<(String, String, String, String)> { + // Parse MediaBrowser authorization header + // Format: MediaBrowser Client="...", Version="...", DeviceId="...", Device="...", Token="..." + if !auth_header.starts_with("MediaBrowser ") { + return None; + } + + let params_part = &auth_header[12..]; // Remove "MediaBrowser " + let mut client = String::new(); + let mut version = String::new(); + let mut device_id = String::new(); + let mut device = String::new(); + + for param in params_part.split(", ") { + if let Some((key, value)) = param.split_once('=') { + let value = value.trim_matches('"'); + match key { + "Client" => client = value.replace('+', " "), + "Version" => version = value.to_string(), + "DeviceId" => device_id = value.to_string(), + "Device" => device = value.replace('+', " "), + _ => {} + } + } + } + + if !client.is_empty() && !version.is_empty() && !device_id.is_empty() && !device.is_empty() { + Some((client, version, device_id, device)) + } else { + None + } +} + +impl Default for UserConfiguration { + fn default() -> Self { + Self { + play_default_audio_track: true, + subtitle_language_preference: String::new(), + display_missing_episodes: false, + grouped_folders: Vec::new(), + subtitle_mode: "Default".to_string(), + display_collections_view: false, + enable_local_password: false, + ordered_views: Vec::new(), + latest_items_excludes: Vec::new(), + my_media_excludes: Vec::new(), + hide_played_in_latest: true, + remember_audio_selections: true, + remember_subtitle_selections: true, + enable_next_episode_auto_play: true, + cast_receiver_id: None, + } + } +} + +impl Default for UserPolicy { + fn default() -> Self { + Self { + is_administrator: true, + is_hidden: false, + enable_collection_management: true, + enable_subtitle_management: true, + enable_lyric_management: true, + is_disabled: false, + max_parental_rating: None, + blocked_tags: Vec::new(), + allowed_tags: Vec::new(), + enable_user_preference_access: true, + access_schedules: Vec::new(), + block_unrated_items: Vec::new(), + enable_remote_control_of_other_users: true, + enable_shared_device_control: true, + enable_remote_access: true, + enable_live_tv_management: true, + enable_live_tv_access: true, + enable_media_playback: true, + enable_audio_playback_transcoding: true, + enable_video_playback_transcoding: true, + enable_playback_remuxing: true, + force_remote_source_transcoding: false, + enable_content_deletion: true, + enable_content_deletion_from_folders: Vec::new(), + enable_content_downloading: true, + enable_sync_transcoding: true, + enable_media_conversion: true, + enabled_devices: Vec::new(), + enable_all_devices: true, + enabled_channels: Vec::new(), + enable_all_channels: true, + enabled_folders: Vec::new(), + enable_all_folders: true, + invalid_login_attempt_count: 0, + login_attempts_before_lockout: -1, + max_active_sessions: 0, + enable_public_sharing: true, + blocked_media_folders: Vec::new(), + blocked_channels: Vec::new(), + remote_client_bitrate_limit: 0, + authentication_provider_id: "Jellyfin.Server.Implementations.Users.DefaultAuthenticationProvider".to_string(), + password_reset_provider_id: "Jellyfin.Server.Implementations.Users.DefaultPasswordResetProvider".to_string(), + sync_play_access: "CreateAndJoinGroups".to_string(), + } + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..28ad474 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,45 @@ +use anyhow::{anyhow, Result}; +use serde::{Deserialize, Serialize}; +use std::env; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub sftp_host: String, + pub sftp_port: u16, + pub sftp_user: String, + pub sftp_password: String, + pub sftp_path: String, + pub jellyfin_url: String, + pub jellyfin_api_key: String, + pub jellyfin_power_on_command: String, + pub jellyfin_hibernate_command: String, +} + +impl Config { + pub fn load() -> Result { + dotenv::dotenv().ok(); + + Ok(Config { + sftp_host: env::var("SFTP_HOST") + .map_err(|_| anyhow!("SFTP_HOST environment variable not set"))?, + sftp_port: env::var("SFTP_PORT") + .unwrap_or_else(|_| "22".to_string()) + .parse() + .map_err(|_| anyhow!("Invalid SFTP_PORT"))?, + sftp_user: env::var("SFTP_USER") + .map_err(|_| anyhow!("SFTP_USER environment variable not set"))?, + sftp_password: env::var("SFTP_PASSWORD") + .map_err(|_| anyhow!("SFTP_PASSWORD environment variable not set"))?, + sftp_path: env::var("SFTP_PATH") + .unwrap_or_else(|_| "/var/lib/jellyfin/data/jellyfin.db".to_string()), + jellyfin_url: env::var("JELLYFIN_URL") + .map_err(|_| anyhow!("JELLYFIN_URL environment variable not set"))?, + jellyfin_api_key: env::var("JELLYFIN_API_KEY") + .map_err(|_| anyhow!("JELLYFIN_API_KEY environment variable not set"))?, + jellyfin_power_on_command: env::var("JELLYFIN_POWER_ON_COMMAND") + .map_err(|_| anyhow!("JELLYFIN_POWER_ON_COMMAND environment variable not set"))?, + jellyfin_hibernate_command: env::var("JELLYFIN_HIBERNATE_COMMAND") + .map_err(|_| anyhow!("JELLYFIN_HIBERNATE_COMMAND environment variable not set"))?, + }) + } +} diff --git a/src/database.rs b/src/database.rs new file mode 100644 index 0000000..70abc49 --- /dev/null +++ b/src/database.rs @@ -0,0 +1,113 @@ +use anyhow::Result; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub id: String, + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Device { + pub id: i64, + pub user_id: String, + pub access_token: String, + pub app_name: String, + pub app_version: String, + pub device_name: String, + pub device_id: String, + pub is_active: bool, + pub date_created: String, + pub date_modified: String, + pub date_last_activity: String, +} + +pub struct Database { + conn: Connection, +} + +impl Database { + pub fn new>(path: P) -> Result { + let conn = Connection::open(path)?; + Ok(Database { conn }) + } + + pub fn get_user_by_username(&self, username: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT Id, Username, Password FROM Users WHERE Username = ?1 COLLATE NOCASE" + )?; + + let user_result = stmt.query_row([username], |row| { + Ok(User { + id: row.get(0)?, + username: row.get(1)?, + password: row.get(2)?, + }) + }); + + match user_result { + Ok(user) => Ok(Some(user)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn get_device_by_access_token(&self, access_token: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT Id, UserId, AccessToken, AppName, AppVersion, DeviceName, DeviceId, IsActive, DateCreated, DateModified, DateLastActivity FROM Devices WHERE AccessToken = ?1" + )?; + + let device_result = stmt.query_row([access_token], |row| { + Ok(Device { + id: row.get(0)?, + user_id: row.get(1)?, + access_token: row.get(2)?, + app_name: row.get(3)?, + app_version: row.get(4)?, + device_name: row.get(5)?, + device_id: row.get(6)?, + is_active: row.get::<_, i64>(7)? != 0, + date_created: row.get(8)?, + date_modified: row.get(9)?, + date_last_activity: row.get(10)?, + }) + }); + + match device_result { + Ok(device) => Ok(Some(device)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } + + pub fn get_device_by_device_id(&self, device_id: &str) -> Result> { + let mut stmt = self.conn.prepare( + "SELECT Id, UserId, AccessToken, AppName, AppVersion, DeviceName, DeviceId, IsActive, DateCreated, DateModified, DateLastActivity FROM Devices WHERE DeviceId = ?1" + )?; + + let device_result = stmt.query_row([device_id], |row| { + Ok(Device { + id: row.get(0)?, + user_id: row.get(1)?, + access_token: row.get(2)?, + app_name: row.get(3)?, + app_version: row.get(4)?, + device_name: row.get(5)?, + device_id: row.get(6)?, + is_active: row.get::<_, i64>(7)? != 0, + date_created: row.get(8)?, + date_modified: row.get(9)?, + date_last_activity: row.get(10)?, + }) + }); + + match device_result { + Ok(device) => Ok(Some(device)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } + } +} diff --git a/src/jellyfin.rs b/src/jellyfin.rs new file mode 100644 index 0000000..f891318 --- /dev/null +++ b/src/jellyfin.rs @@ -0,0 +1,106 @@ +use anyhow::Result; +use reqwest::Client; +use serde::{Deserialize, Serialize}; +use std::process::Command; +use tracing::{error, info, warn}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SystemInfo { + #[serde(rename = "LocalAddress")] + pub local_address: String, + #[serde(rename = "ServerName")] + pub server_name: String, + #[serde(rename = "Version")] + pub version: String, + #[serde(rename = "ProductName")] + pub product_name: String, + #[serde(rename = "OperatingSystem")] + pub operating_system: String, + #[serde(rename = "Id")] + pub id: String, + #[serde(rename = "StartupWizardCompleted")] + pub startup_wizard_completed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BrandingConfig { + #[serde(rename = "LoginDisclaimer")] + pub login_disclaimer: String, + #[serde(rename = "CustomCss")] + pub custom_css: String, + #[serde(rename = "SplashscreenEnabled")] + pub splashscreen_enabled: bool, +} + +pub struct JellyfinClient { + client: Client, + base_url: String, +} + +impl JellyfinClient { + pub fn new(base_url: String, _api_key: String) -> Self { + Self { + client: Client::new(), + base_url, + } + } + + pub fn get_base_url(&self) -> &str { + &self.base_url + } + + pub async fn is_online(&self) -> bool { + let url = format!("{}/System/Info/Public", self.base_url); + match self.client.get(&url).send().await { + Ok(response) => { + let is_ok = response.status().is_success(); + if is_ok { + info!("Jellyfin server is online"); + } else { + warn!("Jellyfin server responded with status: {}", response.status()); + } + is_ok + } + Err(e) => { + warn!("Failed to connect to Jellyfin server: {}", e); + false + } + } + } + + pub async fn get_system_info(&self) -> Result { + let url = format!("{}/System/Info/Public", self.base_url); + let response = self.client.get(&url).send().await?; + let system_info: SystemInfo = response.json().await?; + Ok(system_info) + } + + pub async fn get_branding_config(&self) -> Result { + let url = format!("{}/Branding/Configuration", self.base_url); + let response = self.client.get(&url).send().await?; + let branding: BrandingConfig = response.json().await?; + Ok(branding) + } +} + +pub async fn power_on_server(command: &str) -> Result<()> { + info!("Powering on server with command: {}", command); + + let parts: Vec<&str> = command.split_whitespace().collect(); + if parts.is_empty() { + return Err(anyhow::anyhow!("Empty power on command")); + } + + let output = Command::new(parts[0]) + .args(&parts[1..]) + .output()?; + + if output.status.success() { + info!("Power on command executed successfully"); + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + error!("Power on command failed: {}", stderr); + Err(anyhow::anyhow!("Power on command failed: {}", stderr)) + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..d59719f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,62 @@ -fn main() { - println!("Hello, world!"); +mod auth; +mod config; +mod database; +mod jellyfin; +mod proxy; +mod server; +mod sftp; +mod websocket; + +use anyhow::Result; +use config::Config; +use server::create_app; +use std::sync::Arc; +use tokio::time::{interval, Duration}; +use tracing::{error, info}; + +#[tokio::main] +async fn main() -> Result<()> { + // Initialize tracing + tracing_subscriber::fmt::init(); + + // Load configuration + let config = Config::load()?; + info!("Loaded configuration"); + + // Initialize shared state + let app_state = server::AppState::new(config).await?; + info!("Initialized application state"); + + // Start background tasks + let state_clone = app_state.clone(); + tokio::spawn(async move { + background_tasks(state_clone).await; + }); + + // Create and start the server + let app = create_app(app_state); + let listener = tokio::net::TcpListener::bind("0.0.0.0:8096").await?; + + info!("Starting server on port 8096"); + axum::serve(listener, app).await?; + + Ok(()) +} + +async fn background_tasks(state: Arc) { + let mut interval = interval(Duration::from_secs(15)); + + loop { + interval.tick().await; + + // Check if Jellyfin server is online + if let Err(e) = state.update_jellyfin_status().await { + error!("Failed to update Jellyfin status: {}", e); + } + + // Check for database updates + if let Err(e) = state.check_database_updates().await { + error!("Failed to check database updates: {}", e); + } + } } diff --git a/src/proxy.rs b/src/proxy.rs new file mode 100644 index 0000000..af2552d --- /dev/null +++ b/src/proxy.rs @@ -0,0 +1,172 @@ +use crate::jellyfin::JellyfinClient; +use anyhow::Result; +use axum::{ + body::Body, + http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + response::Response, +}; +use tokio::time::{sleep, Duration}; +use tracing::{debug, error, info, warn}; + +pub async fn proxy_to_jellyfin_with_retry( + method: Method, + path: &str, + query: Option, + headers: HeaderMap, + body: Vec, + jellyfin_client: &JellyfinClient, + status_updater: F, +) -> Result, StatusCode> +where + F: Fn() -> std::pin::Pin + Send>> + Send + Sync, +{ + let max_retries = 60; // 5 minutes with 5-second intervals + let retry_interval = Duration::from_secs(5); + + for attempt in 0..max_retries { + match proxy_to_jellyfin_once(method.clone(), path, query.clone(), headers.clone(), body.clone(), jellyfin_client).await { + Ok(response) => return Ok(response), + Err(StatusCode::BAD_GATEWAY) => { + // Check if this is a connection error - update server status + let is_online = status_updater().await; + + if !is_online { + if attempt == 0 { + info!("Jellyfin server is offline, waiting for it to come back online..."); + } + + if attempt < max_retries - 1 { + debug!("Attempt {}/{} - server still offline, retrying in {} seconds", + attempt + 1, max_retries, retry_interval.as_secs()); + sleep(retry_interval).await; + continue; + } else { + error!("Server failed to come online after {} attempts", max_retries); + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + } else { + // Server is reported as online but request failed, try once more + warn!("Server reported as online but proxy failed, retrying once more..."); + sleep(Duration::from_secs(1)).await; + return proxy_to_jellyfin_once(method, path, query, headers, body, jellyfin_client).await; + } + } + Err(other_error) => { + // For other errors, don't retry + return Err(other_error); + } + } + } + + Err(StatusCode::SERVICE_UNAVAILABLE) +} + +async fn proxy_to_jellyfin_once( + method: Method, + path: &str, + query: Option, + headers: HeaderMap, + body: Vec, + jellyfin_client: &JellyfinClient, +) -> Result, StatusCode> { + let query_str = query.as_deref(); + + debug!("Proxying {} {} to Jellyfin", method.as_str(), path); + + // Convert axum headers to reqwest headers + let mut reqwest_headers = reqwest::header::HeaderMap::new(); + for (name, value) in &headers { + // Skip certain headers that should be handled by the proxy + if name.as_str().to_lowercase() == "host" { + continue; + } + if let Ok(header_name) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) { + if let Ok(header_value) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) { + reqwest_headers.insert(header_name, header_value); + } + } + } + + // Create a client that doesn't follow redirects automatically + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let mut url = format!("{}{}", jellyfin_client.get_base_url(), path); + if let Some(q) = query_str { + url = format!("{}?{}", url, q); + } + + let mut request = match method.as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + _ => return Err(StatusCode::METHOD_NOT_ALLOWED), + }; + + // Copy headers + for (name, value) in &reqwest_headers { + request = request.header(name, value); + } + + // Add body if present + if !body.is_empty() { + request = request.body(body); + } + + match request.send().await { + Ok(response) => { + let status_code = response.status().as_u16(); + let mut response_headers = HeaderMap::new(); + + // Copy response headers + for (name, value) in response.headers() { + // Skip certain headers that might cause issues + let header_name_str = name.as_str().to_lowercase(); + if header_name_str == "transfer-encoding" || + header_name_str == "connection" || + header_name_str == "upgrade" { + continue; + } + + if let Ok(header_name) = HeaderName::try_from(name.as_str()) { + if let Ok(header_value) = HeaderValue::from_bytes(value.as_bytes()) { + response_headers.insert(header_name, header_value); + } + } + } + + let body_bytes = match response.bytes().await { + Ok(bytes) => bytes, + Err(e) => { + error!("Failed to read response body: {}", e); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + }; + + let mut response_builder = Response::builder() + .status(StatusCode::from_u16(status_code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)); + + // Set headers + if let Some(headers_mut) = response_builder.headers_mut() { + *headers_mut = response_headers; + } + + match response_builder.body(Body::from(body_bytes)) { + Ok(response) => Ok(response), + Err(e) => { + error!("Failed to build response: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + } + Err(e) => { + error!("Failed to proxy request to Jellyfin: {}", e); + Err(StatusCode::BAD_GATEWAY) + } + } +} diff --git a/src/server.rs b/src/server.rs new file mode 100644 index 0000000..c345b7a --- /dev/null +++ b/src/server.rs @@ -0,0 +1,898 @@ +use crate::{ + auth::{parse_authorization_header, verify_password, AuthRequest}, + config::Config, + database::{Database, Device}, + jellyfin::{power_on_server, BrandingConfig, JellyfinClient, SystemInfo}, + proxy::proxy_to_jellyfin_with_retry, + sftp::{calculate_local_file_hash, SftpClient}, + websocket::proxy_websocket, +}; +use anyhow::{anyhow, Result}; +use axum::{ + body::Body, + extract::{ws::WebSocketUpgrade, State}, + http::{HeaderMap, Method, StatusCode, Uri}, + response::{IntoResponse, Response}, + routing::any, + Router, +}; + +use std::{ + collections::HashMap, + path::Path as StdPath, + sync::{Arc, RwLock}, + time::{Duration, Instant}, +}; +use tokio::time::sleep; +use tracing::{debug, error, info, warn}; + +const LOCAL_DB_PATH: &str = "./jellyfin.db"; +const SYSTEM_INFO_PATH: &str = "./system_info.json"; + +// Embedded login HTML content at build time +const LOGIN_HTML: &str = include_str!("../login.html"); + +pub struct AppState { + config: Config, + jellyfin_client: JellyfinClient, + sftp_client: SftpClient, + cached_system_info: RwLock>, + is_jellyfin_online: RwLock, + last_db_hash: RwLock>, + last_activity: RwLock>, + is_powering_on: RwLock, + power_on_start_time: RwLock>, +} + +impl AppState { + pub async fn new(config: Config) -> Result> { + let jellyfin_client = JellyfinClient::new(config.jellyfin_url.clone(), config.jellyfin_api_key.clone()); + let sftp_client = SftpClient::new( + config.sftp_host.clone(), + config.sftp_port, + config.sftp_user.clone(), + config.sftp_password.clone(), + ); + + // Try to load cached system info + let cached_system_info = if StdPath::new(SYSTEM_INFO_PATH).exists() { + match std::fs::read_to_string(SYSTEM_INFO_PATH) { + Ok(content) => serde_json::from_str(&content).ok(), + Err(_) => None, + } + } else { + None + }; + + // Initial database download + if let Err(e) = sftp_client.download_file(&config.sftp_path, LOCAL_DB_PATH).await { + warn!("Failed to download initial database: {}", e); + } + + let app_state = Self { + config, + jellyfin_client, + sftp_client, + cached_system_info: RwLock::new(cached_system_info), + is_jellyfin_online: RwLock::new(false), + last_db_hash: RwLock::new(None), + last_activity: RwLock::new(None), + is_powering_on: RwLock::new(false), + power_on_start_time: RwLock::new(None), + }; + + // Initial status check + app_state.update_jellyfin_status().await?; + + // Start background database update checker + let state_clone = Arc::new(app_state); + let checker_state = state_clone.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30)); // Check every 30 seconds + loop { + interval.tick().await; + if let Err(e) = checker_state.check_database_updates().await { + warn!("Failed to check database updates: {}", e); + } + } + }); + + Ok(state_clone) + } + + pub async fn update_jellyfin_status(&self) -> Result<()> { + let is_online = self.jellyfin_client.is_online().await; + *self.is_jellyfin_online.write().unwrap() = is_online; + + if is_online { + // Update system info cache + if let Ok(system_info) = self.jellyfin_client.get_system_info().await { + *self.cached_system_info.write().unwrap() = Some(system_info.clone()); + + // Save to file + if let Ok(json_str) = serde_json::to_string_pretty(&system_info) { + let _ = std::fs::write(SYSTEM_INFO_PATH, json_str); + } + } + } + + Ok(()) + } + + pub async fn check_database_updates(&self) -> Result<()> { + match self.sftp_client.get_file_hash(&self.config.sftp_path).await { + Ok(remote_hash) => { + let current_hash = if StdPath::new(LOCAL_DB_PATH).exists() { + calculate_local_file_hash(LOCAL_DB_PATH).ok() + } else { + None + }; + + let last_hash = self.last_db_hash.read().unwrap().clone(); + + if Some(&remote_hash) != last_hash.as_ref() || current_hash.as_ref() != Some(&remote_hash) { + info!("Database hash changed, downloading new version"); + + if let Err(e) = self.sftp_client.download_file(&self.config.sftp_path, LOCAL_DB_PATH).await { + error!("Failed to download updated database: {}", e); + } else { + *self.last_db_hash.write().unwrap() = Some(remote_hash); + info!("Database updated successfully"); + } + } + } + Err(e) => { + warn!("Failed to check remote database hash: {}", e); + } + } + + Ok(()) + } + + pub fn is_online(&self) -> bool { + *self.is_jellyfin_online.read().unwrap() + } + + pub fn is_powering_on(&self) -> bool { + *self.is_powering_on.read().unwrap() + } + + pub fn is_power_on_timeout(&self) -> bool { + if let Some(start_time) = *self.power_on_start_time.read().unwrap() { + start_time.elapsed() > Duration::from_secs(300) // 5 minutes + } else { + false + } + } + + pub async fn update_and_check_status(&self) -> bool { + let is_online = self.jellyfin_client.is_online().await; + *self.is_jellyfin_online.write().unwrap() = is_online; + + if is_online { + // Reset power-on state if server came online + *self.is_powering_on.write().unwrap() = false; + *self.power_on_start_time.write().unwrap() = None; + } + + is_online + } + + pub fn update_activity(&self) { + *self.last_activity.write().unwrap() = Some(Instant::now()); + } + + // Check for database updates on every authentication-related operation + async fn get_database_with_update_check(&self) -> Result { + // Check for updates first + if let Err(e) = self.check_database_updates().await { + warn!("Failed to check database updates during access: {}", e); + } + + if !StdPath::new(LOCAL_DB_PATH).exists() { + return Err(anyhow!("Local database not found")); + } + Database::new(LOCAL_DB_PATH) + } + + async fn authenticate_user(&self, username: &str, password: &str) -> Result> { + let db = self.get_database_with_update_check().await?; + + if let Some(user) = db.get_user_by_username(username)? { + if verify_password(password, &user.password)? { + return Ok(Some(user)); + } + } + + Ok(None) + } + + async fn validate_token(&self, token: &str) -> Result> { + let db = self.get_database_with_update_check().await?; + db.get_device_by_access_token(token) + } + + async fn validate_device_id(&self, device_id: &str) -> Result> { + let db = self.get_database_with_update_check().await?; + db.get_device_by_device_id(device_id) + } +} + +pub fn create_app(state: Arc) -> Router { + Router::new() + .route("/", any(handle_root_request)) + .route("/web", any(handle_web_request)) + .route("/web/", any(handle_web_request)) + .route("/web/*path", any(handle_web_request)) + .route("/Users/AuthenticateByName", any(handle_auth_request)) + .route("/System/Info/Public", any(handle_system_info_request)) + .route("/Branding/Configuration", any(handle_branding_request)) + .fallback(handle_fallback_request) + .with_state(state) +} + +async fn handle_root_request( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + // Check authentication first + let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; + + // If server is offline but user is authenticated, power it on and wait + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // If server is online (either was online or just came online), proxy the request to Jellyfin + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } + + // If we reach here, server is offline and user is not authenticated + // Serve login page only for GET requests + if method == Method::GET { + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .body(Body::from(LOGIN_HTML)) + .unwrap()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +async fn handle_web_request( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + // Check authentication first + let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; + + // If server is offline but user is authenticated, power it on and wait + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // If server is online (either was online or just came online), proxy the request to Jellyfin + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } + + // If we reach here, server is offline and user is not authenticated + // Serve login page only for GET requests + if method == Method::GET { + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/html") + .body(Body::from(LOGIN_HTML)) + .unwrap()) + } else { + Err(StatusCode::UNAUTHORIZED) + } +} + +async fn handle_auth_request( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + // Check authentication first (for existing session tokens) + let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; + + // If server is offline but user is authenticated, power it on and wait + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // If server is online, proxy the request to Jellyfin + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } + + // If server is offline, handle authentication locally + if method == Method::POST { + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + let auth_request: AuthRequest = match serde_json::from_slice(&body_bytes) { + Ok(req) => req, + Err(e) => { + error!("Failed to parse auth request: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + // Validate credentials locally first - don't start server if invalid + match state.authenticate_user(&auth_request.username, &auth_request.pw).await { + Ok(Some(_user)) => { + info!("Credentials validated locally, starting server and proxying to Jellyfin"); + // User is valid, power on server and wait for it to come online + ensure_server_online_for_authenticated_request(&state).await?; + + // Once server is online, proxy the original request to Jellyfin for real auth response + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } else { + error!("Server failed to come online after authentication"); + Err(StatusCode::SERVICE_UNAVAILABLE) + } + } + Ok(None) => { + warn!("Authentication failed for user: {} - not starting server", auth_request.username); + Err(StatusCode::UNAUTHORIZED) + } + Err(e) => { + error!("Database error during authentication: {}", e); + Err(StatusCode::INTERNAL_SERVER_ERROR) + } + } + } else { + Err(StatusCode::METHOD_NOT_ALLOWED) + } +} + +async fn handle_system_info_request( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + // Check authentication first + let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; + + // If server is offline but user is authenticated, power it on + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // If server is online, proxy the request to Jellyfin + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } + + // If server is offline, return cached system info (this endpoint is usually public) + let system_info = get_system_info_impl(state).await; + let json_body = serde_json::to_string(&system_info).unwrap(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(json_body)) + .unwrap()) +} + +async fn handle_branding_request( + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + // Check authentication first + let is_authenticated = check_authentication(&state, &headers, &uri.query().map(|q| q.to_string())).await; + + // If server is offline but user is authenticated, power it on + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // If server is online, proxy the request to Jellyfin + if state.is_online() { + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + return proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await; + } + + // If server is offline, return offline branding config (this endpoint is usually public) + let branding_config = get_branding_config_impl(state).await; + let json_body = serde_json::to_string(&branding_config).unwrap(); + Ok(Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(json_body)) + .unwrap()) +} + +async fn get_system_info_impl(state: Arc) -> SystemInfo { + if state.is_online() { + // Try to get fresh info from Jellyfin + if let Ok(info) = state.jellyfin_client.get_system_info().await { + return info; + } + } + + // Return cached info or default + let cached_info = state.cached_system_info.read().unwrap(); + if let Some(mut info) = cached_info.clone() { + if !state.is_online() { + info.server_name = format!("{} (Offline)", info.server_name); + } + info + } else { + SystemInfo { + local_address: "http://localhost:8096".to_string(), + server_name: "Jellyfin Server (Offline)".to_string(), + version: "10.10.6".to_string(), + product_name: "Jellyfin Server".to_string(), + operating_system: "".to_string(), + id: "unknown".to_string(), + startup_wizard_completed: true, + } + } +} + +async fn get_branding_config_impl(state: Arc) -> BrandingConfig { + if state.is_online() { + if let Ok(config) = state.jellyfin_client.get_branding_config().await { + return config; + } + } + + BrandingConfig { + login_disclaimer: "This server is currently offline. Log-in to start the server.".to_string(), + custom_css: "".to_string(), + splashscreen_enabled: true, + } +} + +async fn handle_fallback_request( + ws: Option, + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + // Check if this is a WebSocket upgrade request + if let Some(ws_upgrade) = ws { + return handle_websocket_request(ws_upgrade, state, uri, headers).await; + } + + // Handle as regular HTTP request + handle_proxy_request(None, State(state), method, uri, headers, body).await +} + +async fn handle_websocket_request( + ws_upgrade: WebSocketUpgrade, + state: Arc, + uri: Uri, + headers: HeaderMap, +) -> Result, StatusCode> { + state.update_activity(); + + let path = uri.path().to_string(); + let query = uri.query().map(|q| q.to_string()); + + // Check authentication for WebSocket connections + let is_authenticated = check_authentication(&state, &headers, &query).await; + + // If server is offline but user is authenticated, power it on + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // Check if server is online for WebSocket connections + if !state.is_online() { + // For WebSocket connections when offline, we need authentication + if !is_authenticated { + return Err(StatusCode::UNAUTHORIZED); + } + } + + // Handle WebSocket upgrade + let jellyfin_url = state.jellyfin_client.get_base_url().to_string(); + let query_str = query.clone(); + let headers_clone = headers.clone(); + + Ok(ws_upgrade.on_upgrade(move |socket| async move { + proxy_websocket(socket, &jellyfin_url, &path, query_str.as_deref(), &headers_clone).await; + }).into_response()) +} + +async fn handle_proxy_request( + _ws: Option, + State(state): State>, + method: Method, + uri: Uri, + headers: HeaderMap, + body: Body, +) -> Result, StatusCode> { + state.update_activity(); + + let path = uri.path(); + let query = uri.query().map(|q| q.to_string()); + + // Check authentication for all requests + let is_authenticated = check_authentication(&state, &headers, &query).await; + + // If server is offline but user is authenticated, power it on + if !state.is_online() && is_authenticated { + ensure_server_online_for_authenticated_request(&state).await?; + } + + // Handle regular HTTP requests + let body_bytes = match axum::body::to_bytes(body, usize::MAX).await { + Ok(bytes) => bytes.to_vec(), + Err(e) => { + error!("Failed to read request body: {}", e); + return Err(StatusCode::BAD_REQUEST); + } + }; + + if !state.is_online() && !is_authenticated { + return Err(StatusCode::UNAUTHORIZED); + } + + // Proxy to Jellyfin + proxy_to_jellyfin_with_retry( + method, + path, + query, + headers, + body_bytes, + &state.jellyfin_client, + { + let state_clone = state.clone(); + move || { + let state_clone = state_clone.clone(); + Box::pin(async move { + state_clone.update_and_check_status().await + }) + } + } + ).await +} + +async fn check_authentication( + state: &Arc, + headers: &HeaderMap, + query: &Option, +) -> bool { + // Check for API key in query parameters + if let Some(query_str) = query { + let params: HashMap<_, _> = url::form_urlencoded::parse(query_str.as_bytes()).collect(); + if let Some(api_key) = params.get("api_key") { + if let Ok(Some(_)) = state.validate_token(api_key).await { + debug!("Valid API key found in query parameters"); + return true; + } + } + + if let Some(device_id) = params.get("deviceId").or_else(|| params.get("DeviceId")) { + if let Ok(Some(_)) = state.validate_device_id(device_id).await { + debug!("Valid device ID found in query parameters"); + return true; + } + } + } + + // Check authorization header + if let Some(auth_header) = headers.get("authorization") { + if let Ok(header_str) = auth_header.to_str() { + debug!("Checking authorization header: {}", header_str); + + // First, try to extract token from MediaBrowser header + if header_str.starts_with("MediaBrowser ") && header_str.contains("Token=") { + if let Some(token_start) = header_str.find("Token=\"") { + let token_start = token_start + 7; // Skip 'Token="' + if let Some(token_end) = header_str[token_start..].find('"') { + let token = &header_str[token_start..token_start + token_end]; + debug!("Extracted token from header: {}", token); + if let Ok(Some(_)) = state.validate_token(token).await { + debug!("Valid token found in authorization header"); + return true; + } else { + debug!("Token validation failed for: {}", token); + } + } + } else if let Some(token_start) = header_str.find("Token=") { + // Handle case without quotes around token value + let token_start = token_start + 6; // Skip 'Token=' + let token_end = header_str[token_start..].find(',').or_else(|| + header_str[token_start..].find(' ')).unwrap_or(header_str.len() - token_start); + let token = &header_str[token_start..token_start + token_end].trim_matches('"'); + debug!("Extracted token from header (no quotes): {}", token); + if let Ok(Some(_)) = state.validate_token(token).await { + debug!("Valid token found in authorization header (no quotes)"); + return true; + } else { + debug!("Token validation failed for: {}", token); + } + } + } + + // Also try to parse device ID from header and validate it + if let Some((_, _, device_id, _)) = parse_authorization_header(header_str) { + // URL decode the device ID since it might be encoded + if let Ok(decoded_device_id) = urlencoding::decode(&device_id) { + debug!("Checking device ID: {}", decoded_device_id); + if let Ok(Some(_)) = state.validate_device_id(&decoded_device_id).await { + debug!("Valid device ID found in authorization header"); + return true; + } + } + // Also try the non-decoded version + if let Ok(Some(_)) = state.validate_device_id(&device_id).await { + debug!("Valid device ID found in authorization header (non-decoded)"); + return true; + } + } + } + } + + debug!("No valid authentication found"); + false +} + +async fn ensure_server_online_for_authenticated_request(state: &Arc) -> Result<(), StatusCode> { + // If server is already online, nothing to do + if state.is_online() { + return Ok(()); + } + + // Check if we're already powering on + if state.is_powering_on() { + // Check if power-on has timed out + if state.is_power_on_timeout() { + error!("Server power-on timed out, resetting power-on state"); + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + // Wait for server to come online or timeout + let max_wait = Duration::from_secs(300); // 5 minutes + let start_check = Instant::now(); + + info!("Server is being powered on, waiting for it to come online..."); + while start_check.elapsed() < max_wait { + // Check if server came online + if state.jellyfin_client.is_online().await { + *state.is_jellyfin_online.write().unwrap() = true; + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + info!("Server came online successfully"); + return Ok(()); + } + + // Check if power-on process timed out + if state.is_power_on_timeout() { + error!("Server power-on timed out while waiting"); + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + sleep(tokio::time::Duration::from_secs(5)).await; + } + + error!("Timed out waiting for server to come online"); + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + // Start power-on process + info!("Server is offline but user is authenticated, powering on"); + *state.is_powering_on.write().unwrap() = true; + *state.power_on_start_time.write().unwrap() = Some(Instant::now()); + + if let Err(e) = power_on_server(&state.config.jellyfin_power_on_command).await { + error!("Failed to power on server: {}", e); + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + return Err(StatusCode::SERVICE_UNAVAILABLE); + } + + // Wait for server to come online + let max_wait = Duration::from_secs(300); // 5 minutes + let start_time = Instant::now(); + + while start_time.elapsed() < max_wait { + if state.jellyfin_client.is_online().await { + *state.is_jellyfin_online.write().unwrap() = true; + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + info!("Server came online successfully"); + return Ok(()); + } + sleep(tokio::time::Duration::from_secs(5)).await; + } + + error!("Server failed to come online within timeout"); + *state.is_powering_on.write().unwrap() = false; + *state.power_on_start_time.write().unwrap() = None; + Err(StatusCode::SERVICE_UNAVAILABLE) +} diff --git a/src/sftp.rs b/src/sftp.rs new file mode 100644 index 0000000..02ee4f1 --- /dev/null +++ b/src/sftp.rs @@ -0,0 +1,73 @@ +use anyhow::Result; +use sha2::{Digest, Sha256}; +use ssh2::Session; +use std::io::Read; +use std::net::TcpStream; +use std::path::Path; +use tracing::{debug, info}; + +pub struct SftpClient { + host: String, + port: u16, + username: String, + password: String, +} + +impl SftpClient { + pub fn new(host: String, port: u16, username: String, password: String) -> Self { + Self { + host, + port, + username, + password, + } + } + + pub async fn download_file(&self, remote_path: &str, local_path: &str) -> Result<()> { + let host_port = format!("{}:{}", self.host, self.port); + let tcp = TcpStream::connect(&host_port)?; + let mut sess = Session::new()?; + sess.set_tcp_stream(tcp); + sess.handshake()?; + sess.userauth_password(&self.username, &self.password)?; + + let sftp = sess.sftp()?; + let mut remote_file = sftp.open(Path::new(remote_path))?; + let mut contents = Vec::new(); + remote_file.read_to_end(&mut contents)?; + + std::fs::write(local_path, contents)?; + info!("Downloaded {} to {}", remote_path, local_path); + + Ok(()) + } + + pub async fn get_file_hash(&self, remote_path: &str) -> Result { + let host_port = format!("{}:{}", self.host, self.port); + let tcp = TcpStream::connect(&host_port)?; + let mut sess = Session::new()?; + sess.set_tcp_stream(tcp); + sess.handshake()?; + sess.userauth_password(&self.username, &self.password)?; + + let sftp = sess.sftp()?; + let mut remote_file = sftp.open(Path::new(remote_path))?; + let mut contents = Vec::new(); + remote_file.read_to_end(&mut contents)?; + + let mut hasher = Sha256::new(); + hasher.update(&contents); + let hash = format!("{:x}", hasher.finalize()); + + debug!("File {} hash: {}", remote_path, hash); + Ok(hash) + } +} + +pub fn calculate_local_file_hash(file_path: &str) -> Result { + let contents = std::fs::read(file_path)?; + let mut hasher = Sha256::new(); + hasher.update(&contents); + let hash = format!("{:x}", hasher.finalize()); + Ok(hash) +} diff --git a/src/websocket.rs b/src/websocket.rs new file mode 100644 index 0000000..e828290 --- /dev/null +++ b/src/websocket.rs @@ -0,0 +1,186 @@ +use axum::{ + extract::ws::{Message, WebSocket}, + http::HeaderMap, +}; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::{ + connect_async, + tungstenite::{Message as TungsteniteMessage, client::IntoClientRequest} +}; +use tracing::{debug, error, warn}; +use url::Url; + +pub async fn proxy_websocket( + socket: WebSocket, + target_url: &str, + path: &str, + query: Option<&str>, + headers: &HeaderMap, +) { + // Build the target WebSocket URL + let ws_url = if target_url.starts_with("http://") { + target_url.replacen("http://", "ws://", 1) + } else if target_url.starts_with("https://") { + target_url.replacen("https://", "wss://", 1) + } else { + format!("ws://{}", target_url) + }; + + let mut full_url = format!("{}{}", ws_url, path); + if let Some(q) = query { + full_url = format!("{}?{}", full_url, q); + } + + debug!("Proxying WebSocket connection to: {}", full_url); + + // Parse the URL + let url = match Url::parse(&full_url) { + Ok(url) => url, + Err(e) => { + error!("Invalid WebSocket URL {}: {}", full_url, e); + let _ = socket.close().await; + return; + } + }; + + // Create request with headers + let mut request = url.into_client_request().unwrap(); + + // Copy relevant headers from the original request + for (name, value) in headers { + // Skip headers that shouldn't be forwarded + let header_name = name.as_str().to_lowercase(); + if header_name == "host" + || header_name == "connection" + || header_name == "upgrade" + || header_name == "sec-websocket-key" + || header_name == "sec-websocket-version" + || header_name == "sec-websocket-protocol" + || header_name == "sec-websocket-extensions" { + continue; + } + + if let Ok(_header_value) = value.to_str() { + request.headers_mut().insert(name, value.clone()); + } + } + + // Connect to the target WebSocket + let (target_ws, _) = match connect_async(request).await { + Ok(result) => result, + Err(e) => { + error!("Failed to connect to target WebSocket: {}", e); + let _ = socket.close().await; + return; + } + }; + + debug!("Connected to target WebSocket"); + + let (target_sink, target_stream) = target_ws.split(); + + // Spawn task to forward messages from client to target + let (client_sink, client_stream) = socket.split(); + + let client_to_target = async move { + let mut target_sink = target_sink; + let mut client_stream = client_stream; + + while let Some(msg) = client_stream.next().await { + match msg { + Ok(Message::Text(text)) => { + if let Err(e) = target_sink.send(TungsteniteMessage::Text(text)).await { + error!("Failed to send text message to target: {}", e); + break; + } + } + Ok(Message::Binary(data)) => { + if let Err(e) = target_sink.send(TungsteniteMessage::Binary(data)).await { + error!("Failed to send binary message to target: {}", e); + break; + } + } + Ok(Message::Ping(data)) => { + if let Err(e) = target_sink.send(TungsteniteMessage::Ping(data)).await { + error!("Failed to send ping to target: {}", e); + break; + } + } + Ok(Message::Pong(data)) => { + if let Err(e) = target_sink.send(TungsteniteMessage::Pong(data)).await { + error!("Failed to send pong to target: {}", e); + break; + } + } + Ok(Message::Close(_)) => { + debug!("Client closed WebSocket connection"); + let _ = target_sink.send(TungsteniteMessage::Close(None)).await; + break; + } + Err(e) => { + warn!("WebSocket error from client: {}", e); + break; + } + } + } + }; + + let target_to_client = async move { + let mut client_sink = client_sink; + let mut target_stream = target_stream; + + while let Some(msg) = target_stream.next().await { + match msg { + Ok(TungsteniteMessage::Text(text)) => { + if let Err(e) = client_sink.send(Message::Text(text)).await { + error!("Failed to send text message to client: {}", e); + break; + } + } + Ok(TungsteniteMessage::Binary(data)) => { + if let Err(e) = client_sink.send(Message::Binary(data)).await { + error!("Failed to send binary message to client: {}", e); + break; + } + } + Ok(TungsteniteMessage::Ping(data)) => { + if let Err(e) = client_sink.send(Message::Ping(data)).await { + error!("Failed to send ping to client: {}", e); + break; + } + } + Ok(TungsteniteMessage::Pong(data)) => { + if let Err(e) = client_sink.send(Message::Pong(data)).await { + error!("Failed to send pong to client: {}", e); + break; + } + } + Ok(TungsteniteMessage::Close(_)) => { + debug!("Target closed WebSocket connection"); + let _ = client_sink.send(Message::Close(None)).await; + break; + } + Ok(TungsteniteMessage::Frame(_)) => { + // Frame messages are low-level and should be handled automatically + debug!("Received frame message, ignoring"); + } + Err(e) => { + warn!("WebSocket error from target: {}", e); + break; + } + } + } + }; + + // Run both forwarding tasks concurrently + tokio::select! { + _ = client_to_target => { + debug!("Client to target forwarding finished"); + } + _ = target_to_client => { + debug!("Target to client forwarding finished"); + } + } + + debug!("WebSocket proxy connection closed"); +}