diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c86ceda --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target +node_modules/ +dist/ +build/ +.svelte-kit/ +*.log +.env +/data +/panel/server/data +launcher/src-tauri/gen/schemas +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cd42703 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2742 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "async-compression" +version = "0.4.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "compression-codecs" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-queue" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "fastnbt" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9dcc36fee27b17c9c183294d6e739cf218b01ce0fc545c31d8be3913d961731" +dependencies = [ + "byteorder", + "cesu8", + "serde", + "serde_bytes", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "scopenet-core" +version = "0.1.0" +dependencies = [ + "anyhow", + "fastnbt", + "futures", + "hex", + "reqwest", + "scopenet-shared", + "serde", + "serde_json", + "sha1", + "tempfile", + "tokio", + "tracing", + "windows-sys 0.59.0", + "zip", +] + +[[package]] +name = "scopenet-panel" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "chrono", + "futures", + "hex", + "jsonwebtoken", + "percent-encoding", + "rand 0.8.8", + "reqwest", + "scopenet-core", + "scopenet-shared", + "serde", + "serde_json", + "sha1", + "sqlx", + "tempfile", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", + "zip", +] + +[[package]] +name = "scopenet-shared" +version = "0.1.0" +dependencies = [ + "md-5", + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "serde", + "sha2", + "smallvec", + "thiserror", + "tokio", + "tokio-stream", + "tracing", + "url", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.119", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-sqlite", + "syn 2.0.119", + "tokio", + "url", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror", + "tracing", + "url", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower", + "tower-layer", + "tower-service", + "tracing", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zip" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50" +dependencies = [ + "arbitrary", + "crc32fast", + "crossbeam-utils", + "displaydoc", + "flate2", + "indexmap", + "memchr", + "thiserror", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..5678c7b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[workspace] +resolver = "2" +members = ["crates/shared", "crates/core", "panel/server"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +license = "MIT" +repository = "https://github.com/scopeddlol/SCOPENET-MC" + +[workspace.dependencies] +scopenet-shared = { path = "crates/shared" } +scopenet-core = { path = "crates/core" } +anyhow = "1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "process", "io-util", "net", "time", "sync", "signal"] } +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "http2", "gzip"] } +futures = "0.3" +sha1 = "0.10" +md-5 = "0.10" +hex = "0.4" +uuid = { version = "1", features = ["v4", "serde"] } +zip = { version = "2", default-features = false, features = ["deflate"] } +tracing = "0.1" +chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] } +base64 = "0.22" +rand = "0.8" + +# Small, fast binaries: the launcher and the panel both ship lean. +[profile.release] +opt-level = 3 +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true + +[profile.dev.package."*"] +opt-level = 1 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml new file mode 100644 index 0000000..ff5a7cc --- /dev/null +++ b/crates/core/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "scopenet-core" +description = "Minecraft install / launch engine used by the ScopeNet launcher (and meta lookups in the panel)" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +scopenet-shared.workspace = true +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +reqwest.workspace = true +futures.workspace = true +sha1.workspace = true +hex.workspace = true +zip.workspace = true +tracing.workspace = true +fastnbt = "2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_System_SystemInformation"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/core/src/assets.rs b/crates/core/src/assets.rs new file mode 100644 index 0000000..eda7a66 --- /dev/null +++ b/crates/core/src/assets.rs @@ -0,0 +1,83 @@ +//! Game assets (sounds, languages, textures) — content-addressed objects. + +use crate::http::{self, Download}; +use crate::paths::Layout; +use crate::version::AssetIndexRef; +use anyhow::Result; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub const RESOURCES: &str = "https://resources.download.minecraft.net"; + +#[derive(Debug, Deserialize)] +pub struct AssetIndex { + pub objects: HashMap, + #[serde(default)] + pub map_to_resources: bool, + #[serde(default, rename = "virtual")] + pub is_virtual: bool, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct AssetObject { + pub hash: String, + pub size: u64, +} + +pub fn object_path(layout: &Layout, hash: &str) -> PathBuf { + layout.assets().join("objects").join(&hash[..2]).join(hash) +} + +/// Load (downloading if needed) the asset index. +pub async fn load_index(client: &reqwest::Client, layout: &Layout, index: &AssetIndexRef) -> Result { + let path = layout.assets().join("indexes").join(format!("{}.json", index.id)); + if !http::file_ok(&path, index.sha1.as_deref(), index.size, true) { + http::download_one(client, &Download::new(index.url.clone(), path.clone(), index.sha1.clone(), index.size), &|_| {}) + .await?; + } + Ok(serde_json::from_slice(&std::fs::read(&path)?)?) +} + +/// Downloads needed for missing/corrupt objects. +pub fn missing_objects(layout: &Layout, index: &AssetIndex, deep: bool) -> Vec { + let mut seen = std::collections::HashSet::new(); + index + .objects + .values() + .filter(|o| o.hash.len() > 2 && seen.insert(o.hash.clone())) + .filter_map(|o| { + let path = object_path(layout, &o.hash); + if http::file_ok(&path, Some(&o.hash), Some(o.size), deep) { + None + } else { + Some(Download::new(format!("{RESOURCES}/{}/{}", &o.hash[..2], o.hash), path, Some(o.hash.clone()), Some(o.size))) + } + }) + .collect() +} + +/// Pre-1.7 versions read assets by name, not hash. Copy them into the +/// virtual (or `resources/`) directory. Returns the directory to pass as +/// `game_assets`. +pub fn materialize_legacy(layout: &Layout, index_id: &str, index: &AssetIndex, game_dir: &Path) -> Result> { + if !index.is_virtual && !index.map_to_resources { + return Ok(None); + } + let target = if index.map_to_resources { + game_dir.join("resources") + } else { + layout.assets().join("virtual").join(index_id) + }; + for (name, obj) in &index.objects { + let Some(dest) = crate::paths::safe_join(&target, name) else { continue }; + if dest.exists() { + continue; + } + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(object_path(layout, &obj.hash), &dest).ok(); + } + Ok(Some(target)) +} diff --git a/crates/core/src/http.rs b/crates/core/src/http.rs new file mode 100644 index 0000000..9d501b2 --- /dev/null +++ b/crates/core/src/http.rs @@ -0,0 +1,223 @@ +//! HTTP helpers: a shared client, JSON fetches and verified, resumable-safe +//! parallel downloads. + +use crate::progress::{Event, Reporter, Stage}; +use anyhow::{bail, Context, Result}; +use futures::StreamExt; +use serde::de::DeserializeOwned; +use sha1::{Digest, Sha1}; +use std::io::Read; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use tokio::io::AsyncWriteExt; + +pub const USER_AGENT: &str = concat!("ScopeNet-Launcher/", env!("CARGO_PKG_VERSION"), " (+https://github.com/scopeddlol/SCOPENET-MC)"); + +pub fn client() -> reqwest::Client { + reqwest::Client::builder() + .user_agent(USER_AGENT) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(60)) + .pool_max_idle_per_host(16) + .build() + .expect("http client") +} + +pub async fn get_json(client: &reqwest::Client, url: &str) -> Result { + let resp = client.get(url).send().await.with_context(|| format!("GET {url}"))?; + let status = resp.status(); + if !status.is_success() { + bail!("GET {url} returned {status}"); + } + let bytes = resp.bytes().await?; + serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}")) +} + +/// Fetch JSON, caching it at `cache`. Falls back to the cached copy when +/// offline so the game can still launch without internet. +pub async fn get_json_cached(client: &reqwest::Client, url: &str, cache: &Path) -> Result { + match client.get(url).send().await.and_then(|r| r.error_for_status()) { + Ok(resp) => { + let bytes = resp.bytes().await?; + let parsed = serde_json::from_slice(&bytes).with_context(|| format!("parsing JSON from {url}"))?; + if let Some(dir) = cache.parent() { + tokio::fs::create_dir_all(dir).await.ok(); + } + tokio::fs::write(cache, &bytes).await.ok(); + Ok(parsed) + } + Err(err) => { + let bytes = tokio::fs::read(cache).await.map_err(|_| anyhow::anyhow!("{err} (and no cached copy)"))?; + tracing::warn!("{url} unreachable, using cached copy: {err}"); + Ok(serde_json::from_slice(&bytes)?) + } + } +} + +pub fn sha1_file(path: &Path) -> Result { + let mut file = std::fs::File::open(path)?; + let mut hasher = Sha1::new(); + let mut buf = vec![0u8; 64 * 1024]; + loop { + let n = file.read(&mut buf)?; + if n == 0 { + break; + } + hasher.update(&buf[..n]); + } + Ok(hex::encode(hasher.finalize())) +} + +pub fn sha1_bytes(bytes: &[u8]) -> String { + hex::encode(Sha1::digest(bytes)) +} + +/// Cheap check used before every launch: size match (and hash match when +/// `deep` is set). +pub fn file_ok(path: &Path, sha1: Option<&str>, size: Option, deep: bool) -> bool { + let Ok(meta) = std::fs::metadata(path) else { return false }; + if let Some(size) = size { + if size > 0 && meta.len() != size { + return false; + } + } + if deep { + if let Some(expected) = sha1.filter(|s| !s.is_empty()) { + return sha1_file(path).map(|h| h.eq_ignore_ascii_case(expected)).unwrap_or(false); + } + } + true +} + +#[derive(Debug, Clone)] +pub struct Download { + pub url: String, + pub dest: PathBuf, + pub sha1: Option, + pub size: u64, +} + +impl Download { + pub fn new(url: impl Into, dest: PathBuf, sha1: Option, size: Option) -> Self { + Self { url: url.into(), dest, sha1: sha1.filter(|s| !s.is_empty()), size: size.unwrap_or(0) } + } +} + +/// Download a single file to `dest` atomically (via a `.part` file) and +/// verify its SHA-1. Retries transient failures. +pub async fn download_one(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> { + let mut last_err = None; + for attempt in 0..4u32 { + if attempt > 0 { + tokio::time::sleep(Duration::from_millis(400 * 2u64.pow(attempt))).await; + } + match try_download(client, dl, on_bytes).await { + Ok(()) => return Ok(()), + Err(e) => { + tracing::warn!("download {} failed (attempt {}): {e:#}", dl.url, attempt + 1); + last_err = Some(e); + } + } + } + Err(last_err.unwrap()).with_context(|| format!("downloading {}", dl.url)) +} + +async fn try_download(client: &reqwest::Client, dl: &Download, on_bytes: &(dyn Fn(u64) + Sync)) -> Result<()> { + if let Some(dir) = dl.dest.parent() { + tokio::fs::create_dir_all(dir).await?; + } + let resp = client.get(&dl.url).send().await?.error_for_status()?; + let part = dl.dest.with_extension(format!( + "{}part", + dl.dest.extension().map(|e| format!("{}.", e.to_string_lossy())).unwrap_or_default() + )); + let mut file = tokio::fs::File::create(&part).await?; + let mut hasher = Sha1::new(); + let mut stream = resp.bytes_stream(); + let mut written = 0u64; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + hasher.update(&chunk); + file.write_all(&chunk).await?; + written += chunk.len() as u64; + on_bytes(chunk.len() as u64); + } + file.flush().await?; + drop(file); + if let Some(expected) = &dl.sha1 { + let got = hex::encode(hasher.finalize()); + if !got.eq_ignore_ascii_case(expected) { + tokio::fs::remove_file(&part).await.ok(); + bail!("checksum mismatch for {} (expected {expected}, got {got}, {written} bytes)", dl.url); + } + } + tokio::fs::rename(&part, &dl.dest).await?; + Ok(()) +} + +/// Download many files in parallel, reporting aggregate byte progress. +pub async fn download_all( + client: &reqwest::Client, + downloads: Vec, + concurrency: usize, + stage: Stage, + reporter: &Reporter, +) -> Result<()> { + if downloads.is_empty() { + return Ok(()); + } + let total: u64 = downloads.iter().map(|d| d.size).sum(); + let files_total = downloads.len() as u32; + let done = Arc::new(AtomicU64::new(0)); + let files_done = Arc::new(AtomicU32::new(0)); + let last_emit = Arc::new(std::sync::Mutex::new(Instant::now() - Duration::from_secs(1))); + + let emit = { + let done = done.clone(); + let files_done = files_done.clone(); + let reporter = reporter.clone(); + move |force: bool| { + let mut last = last_emit.lock().unwrap(); + if force || last.elapsed() >= Duration::from_millis(100) { + *last = Instant::now(); + let d = done.load(Ordering::Relaxed); + reporter(Event::Progress { + stage, + done: d, + total: total.max(d), + files_done: files_done.load(Ordering::Relaxed), + files_total, + }); + } + } + }; + emit(true); + + let results: Vec> = futures::stream::iter(downloads.into_iter().map(|dl| { + let done = done.clone(); + let files_done = files_done.clone(); + let emit = &emit; + async move { + let on_bytes = |n: u64| { + done.fetch_add(n, Ordering::Relaxed); + emit(false); + }; + let r = download_one(client, &dl, &on_bytes).await; + files_done.fetch_add(1, Ordering::Relaxed); + emit(false); + r + } + })) + .buffer_unordered(concurrency.clamp(1, 64)) + .collect() + .await; + emit(true); + + let errors: Vec<_> = results.into_iter().filter_map(|r| r.err()).collect(); + if let Some(first) = errors.into_iter().next() { + return Err(first); + } + Ok(()) +} diff --git a/crates/core/src/install.rs b/crates/core/src/install.rs new file mode 100644 index 0000000..09a4fda --- /dev/null +++ b/crates/core/src/install.rs @@ -0,0 +1,222 @@ +//! Installs everything needed to launch a version: vanilla game, loader, +//! libraries, assets and Java. + +use crate::assets; +use crate::http::{self, Download}; +use crate::java; +use crate::libraries::{self, ResolvedLib}; +use crate::loaders; +use crate::meta; +use crate::paths::Layout; +use crate::progress::{self, Reporter, Stage}; +use crate::rules::Env; +use crate::version::{self, VersionJson}; +use anyhow::{anyhow, bail, Context, Result}; +use scopenet_shared::Loader; +use std::path::PathBuf; + +#[derive(Debug, Clone)] +pub struct InstallSpec { + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + /// User-selected Java executable (skips the automatic runtime). + pub java_override: Option, + pub game_dir: PathBuf, + pub concurrency: usize, + /// Re-hash every file instead of trusting sizes (Repair). + pub deep_verify: bool, +} + +#[derive(Debug, Clone)] +pub struct Installed { + pub version: VersionJson, + /// Windowed Java (javaw on Windows). + pub java: PathBuf, + pub java_major: u32, + pub client_jar: PathBuf, + pub natives_dir: PathBuf, + pub libraries: Vec, + pub assets_root: PathBuf, + pub game_assets: PathBuf, + pub assets_index: String, + pub logging_arg: Option, +} + +impl Installed { + pub fn classpath(&self, layout: &Layout) -> Vec { + let libs_dir = layout.libraries(); + let mut seen = std::collections::HashSet::new(); + let mut cp: Vec = self + .libraries + .iter() + .filter(|l| l.classpath) + .map(|l| l.file(&libs_dir)) + .filter(|p| seen.insert(p.clone())) + .collect(); + cp.push(self.client_jar.clone()); + cp + } +} + +/// Vanilla version JSON: from Mojang (cached), falling back to disk. +async fn vanilla_json(client: &reqwest::Client, layout: &Layout, mc: &str) -> Result { + let path = layout.version_json(mc); + let manifest = match meta::mojang_manifest(client).await { + Ok(m) => m, + Err(e) => { + let bytes = std::fs::read(&path).map_err(|_| anyhow!("can't reach Mojang ({e}) and {mc} isn't installed yet"))?; + return Ok(serde_json::from_slice(&bytes)?); + } + }; + let entry = manifest + .versions + .iter() + .find(|v| v.id == mc) + .ok_or_else(|| anyhow!("Minecraft {mc} doesn't exist in Mojang's version list"))?; + if !http::file_ok(&path, entry.sha1.as_deref(), None, true) { + http::download_one(client, &Download::new(entry.url.clone(), path.clone(), entry.sha1.clone(), None), &|_| {}).await?; + } + Ok(serde_json::from_slice(&std::fs::read(&path)?)?) +} + +fn library_downloads(libs: &[ResolvedLib], layout: &Layout, deep: bool) -> Vec { + let dir = layout.libraries(); + let mut out: Vec = libs + .iter() + .filter(|l| !http::file_ok(&l.file(&dir), l.sha1.as_deref(), l.size, deep)) + .filter_map(|l| l.download(&dir)) + .collect(); + out.sort_by(|a, b| a.dest.cmp(&b.dest)); + out.dedup_by(|a, b| a.dest == b.dest); + out +} + +pub async fn install(client: &reqwest::Client, layout: &Layout, spec: &InstallSpec, reporter: &Reporter) -> Result { + let env = Env::current(); + let mc = spec.mc_version.as_str(); + progress::stage(reporter, Stage::Preparing, format!("Preparing Minecraft {mc}")); + + // 1. Vanilla metadata ---------------------------------------------------- + let vanilla = vanilla_json(client, layout, mc).await?; + + // 2. Java ---------------------------------------------------------------- + let (java_dir, java_major) = match &spec.java_override { + Some(p) if p.exists() => (None, java::detect_major(p).await.unwrap_or(vanilla.java_major())), + Some(p) => bail!("the Java path in your settings doesn't exist: {}", p.display()), + None => { + let component = vanilla.java_component(); + progress::stage(reporter, Stage::Java, "Checking Java"); + (Some(java::ensure_runtime(client, layout, &component, spec.concurrency, reporter).await?), vanilla.java_major()) + } + }; + let (java_windowed, java_console) = match (&java_dir, &spec.java_override) { + (Some(dir), _) => (java::java_exe(dir, true), java::java_exe(dir, false)), + (None, Some(p)) => (java::sibling_exe(p, true), java::sibling_exe(p, false)), + _ => unreachable!(), + }; + + // 3. Vanilla files (client jar, libraries, assets, log config) --------- + progress::stage(reporter, Stage::Game, format!("Downloading Minecraft {mc}")); + let vanilla_client = layout.version_jar(mc); + let mut downloads = Vec::new(); + if let Some(client_dl) = vanilla.downloads.as_ref().and_then(|d| d.client.as_ref()) { + if !http::file_ok(&vanilla_client, client_dl.sha1.as_deref(), client_dl.size, spec.deep_verify) { + downloads.push(Download::new(client_dl.url.clone(), vanilla_client.clone(), client_dl.sha1.clone(), client_dl.size)); + } + } + downloads.extend(library_downloads(&libraries::resolve(&vanilla.libraries, &env), layout, spec.deep_verify)); + + let asset_ref = vanilla.asset_index.clone().ok_or_else(|| anyhow!("version {mc} has no asset index"))?; + let asset_index = assets::load_index(client, layout, &asset_ref).await?; + downloads.extend(assets::missing_objects(layout, &asset_index, spec.deep_verify)); + + let mut logging_arg = None; + if let Some(log) = vanilla.logging.as_ref().and_then(|l| l.client.as_ref()) { + let path = layout.assets().join("log_configs").join(&log.file.id); + if !http::file_ok(&path, log.file.sha1.as_deref(), log.file.size, spec.deep_verify) { + downloads.push(Download::new(log.file.url.clone(), path.clone(), log.file.sha1.clone(), log.file.size)); + } + logging_arg = Some(log.argument.replace("${path}", &path.to_string_lossy())); + } + http::download_all(client, downloads, spec.concurrency, Stage::Game, reporter).await?; + + // 4. Loader -------------------------------------------------------------- + let child: Option = match spec.loader { + Loader::Vanilla => None, + Loader::Fabric | Loader::Quilt => { + let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap(); + progress::stage(reporter, Stage::Loader, format!("Installing {} {lv}", if spec.loader == Loader::Fabric { "Fabric" } else { "Quilt" })); + Some(loaders::fabric::profile(client, layout, spec.loader, mc, &lv).await?) + } + Loader::Forge | Loader::NeoForge => { + let lv = meta::resolve_loader_version(client, spec.loader, mc, spec.loader_version.as_deref()).await?.unwrap(); + Some( + loaders::forge::install(loaders::forge::ForgeInstall { + client, + layout, + loader: spec.loader, + mc, + version: &lv, + java: &java_console, + client_jar: &vanilla_client, + concurrency: spec.concurrency, + reporter, + }) + .await + .with_context(|| format!("installing {} {lv}", spec.loader.as_str()))?, + ) + } + }; + let version = match child { + Some(child) => version::merge(vanilla, child), + None => vanilla, + }; + + // 5. Remaining (loader) libraries ----------------------------------------- + let libs = libraries::resolve(&version.libraries, &env); + let lib_downloads = library_downloads(&libs, layout, false); + if !lib_downloads.is_empty() { + progress::stage(reporter, Stage::Loader, "Downloading loader libraries"); + http::download_all(client, lib_downloads, spec.concurrency, Stage::Loader, reporter).await?; + } + let libs_dir = layout.libraries(); + if let Some(missing) = libs.iter().find(|l| l.classpath && !l.file(&libs_dir).exists()) { + bail!("library {} is missing and has no download URL", missing.name); + } + + // 6. Natives, client jar, legacy assets --------------------------------- + let natives_dir = layout.natives(&version.id); + libraries::extract_natives(&libs, &libs_dir, &natives_dir)?; + + // Forge's module system ignores the game jar by *file name* + // (`${version_name}.jar`), so the jar must be named after the final id. + let client_jar = if version.id != mc { + let jar = layout.version_jar(&version.id); + let src_len = std::fs::metadata(&vanilla_client).map(|m| m.len()).unwrap_or(0); + if std::fs::metadata(&jar).map(|m| m.len()).unwrap_or(u64::MAX) != src_len { + std::fs::create_dir_all(jar.parent().unwrap())?; + std::fs::copy(&vanilla_client, &jar)?; + } + jar + } else { + vanilla_client + }; + + let assets_root = layout.assets(); + let game_assets = assets::materialize_legacy(layout, &asset_ref.id, &asset_index, &spec.game_dir)? + .unwrap_or_else(|| assets_root.clone()); + + Ok(Installed { + java: java_windowed, + java_major, + client_jar, + natives_dir, + libraries: libs, + assets_root, + game_assets, + assets_index: asset_ref.id.clone(), + logging_arg, + version, + }) +} diff --git a/crates/core/src/java.rs b/crates/core/src/java.rs new file mode 100644 index 0000000..e917de1 --- /dev/null +++ b/crates/core/src/java.rs @@ -0,0 +1,204 @@ +//! Automatic Java: Mojang publishes the exact runtimes its launcher uses. +//! We download the one each Minecraft version asks for, so players never +//! have to install Java themselves. + +use crate::http::{self, Download}; +use crate::paths::Layout; +use crate::progress::{self, Reporter, Stage}; +use anyhow::{anyhow, Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +pub const RUNTIME_INDEX: &str = + "https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json"; + +#[derive(Deserialize)] +struct RuntimeEntry { + manifest: ManifestRef, + version: RuntimeVersion, +} +#[derive(Deserialize)] +struct ManifestRef { + sha1: String, + url: String, +} +#[derive(Deserialize)] +struct RuntimeVersion { + name: String, +} + +#[derive(Deserialize)] +struct RuntimeManifest { + files: HashMap, +} + +#[derive(Deserialize)] +struct RuntimeFile { + #[serde(rename = "type")] + kind: String, + #[serde(default)] + executable: bool, + #[serde(default)] + downloads: Option, + #[serde(default)] + target: Option, +} +#[derive(Deserialize)] +struct RuntimeDownloads { + raw: RawDownload, +} +#[derive(Deserialize)] +struct RawDownload { + sha1: String, + size: u64, + url: String, +} + +pub fn platform_key() -> &'static str { + match (std::env::consts::OS, std::env::consts::ARCH) { + ("windows", "x86") => "windows-x86", + ("windows", "aarch64") => "windows-arm64", + ("windows", _) => "windows-x64", + ("macos", "aarch64") => "mac-os-arm64", + ("macos", _) => "mac-os", + ("linux", "x86") => "linux-i386", + _ => "linux", + } +} + +/// Path to the Java executable inside a runtime directory. +/// `windowed` picks `javaw.exe` on Windows so no console window appears. +pub fn java_exe(dir: &Path, windowed: bool) -> PathBuf { + if cfg!(windows) { + dir.join("bin").join(if windowed { "javaw.exe" } else { "java.exe" }) + } else if cfg!(target_os = "macos") { + dir.join("jre.bundle/Contents/Home/bin/java") + } else { + dir.join("bin/java") + } +} + +/// For a user-supplied path, derive the console/windowed sibling. +pub fn sibling_exe(java: &Path, windowed: bool) -> PathBuf { + if !cfg!(windows) { + return java.to_path_buf(); + } + let name = if windowed { "javaw.exe" } else { "java.exe" }; + let candidate = java.with_file_name(name); + if candidate.exists() { candidate } else { java.to_path_buf() } +} + +/// Make sure the Mojang runtime `component` (e.g. `java-runtime-delta`) is +/// installed. Returns its directory. +pub async fn ensure_runtime( + client: &reqwest::Client, + layout: &Layout, + component: &str, + concurrency: usize, + reporter: &Reporter, +) -> Result { + let dir = layout.runtimes().join(component); + let marker = dir.join(".scopenet-runtime"); + let installed = std::fs::read_to_string(&marker).ok(); + + let index: HashMap>> = + match http::get_json(client, RUNTIME_INDEX).await { + Ok(i) => i, + Err(e) if installed.is_some() && java_exe(&dir, false).exists() => { + tracing::warn!("java index unreachable ({e}); using installed {component}"); + return Ok(dir); + } + Err(e) => return Err(e).context("fetching Java runtime index"), + }; + let entry = index + .get(platform_key()) + .and_then(|p| p.get(component)) + .and_then(|v| v.first()) + .ok_or_else(|| anyhow!("Mojang has no '{component}' Java runtime for {}", platform_key()))?; + + if installed.as_deref() == Some(entry.manifest.sha1.as_str()) && java_exe(&dir, false).exists() { + return Ok(dir); + } + + progress::stage(reporter, Stage::Java, format!("Installing Java {}", entry.version.name)); + let manifest: RuntimeManifest = http::get_json(client, &entry.manifest.url).await?; + let mut downloads = Vec::new(); + let mut executables = Vec::new(); + let mut links = Vec::new(); + for (rel, file) in &manifest.files { + let Some(path) = crate::paths::safe_join(&dir, rel) else { continue }; + match file.kind.as_str() { + "directory" => std::fs::create_dir_all(&path)?, + "file" => { + let Some(dl) = &file.downloads else { continue }; + if file.executable { + executables.push(path.clone()); + } + if !http::file_ok(&path, Some(&dl.raw.sha1), Some(dl.raw.size), true) { + downloads.push(Download::new(dl.raw.url.clone(), path, Some(dl.raw.sha1.clone()), Some(dl.raw.size))); + } + } + "link" => { + if let Some(target) = &file.target { + links.push((path, target.clone())); + } + } + _ => {} + } + } + http::download_all(client, downloads, concurrency, Stage::Java, reporter).await?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + for exe in executables { + if let Ok(meta) = std::fs::metadata(&exe) { + let mut perms = meta.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&exe, perms).ok(); + } + } + for (path, target) in links { + std::fs::remove_file(&path).ok(); + std::os::unix::fs::symlink(target, &path).ok(); + } + } + #[cfg(not(unix))] + let _ = (executables, links); + + std::fs::write(&marker, &entry.manifest.sha1)?; + Ok(dir) +} + +/// Best-effort `java -version` parse for a custom Java path. +pub async fn detect_major(java: &Path) -> Option { + let mut cmd = tokio::process::Command::new(sibling_exe(java, false)); + cmd.arg("-version"); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); + let out = cmd.output().await.ok()?; + let text = String::from_utf8_lossy(&out.stderr).to_string() + &String::from_utf8_lossy(&out.stdout); + parse_java_version(&text) +} + +pub fn parse_java_version(text: &str) -> Option { + let start = text.find('"')? + 1; + let rest = &text[start..]; + let ver = &rest[..rest.find('"')?]; + let mut parts = ver.split(['.', '_', '-', '+']); + let first: u32 = parts.next()?.parse().ok()?; + if first == 1 { parts.next()?.parse().ok() } else { Some(first) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_versions() { + assert_eq!(parse_java_version("java version \"1.8.0_381\""), Some(8)); + assert_eq!(parse_java_version("openjdk version \"21.0.2\" 2024-01-16"), Some(21)); + assert_eq!(parse_java_version("openjdk version \"17\" 2021-09-14"), Some(17)); + } +} diff --git a/crates/core/src/launch.rs b/crates/core/src/launch.rs new file mode 100644 index 0000000..a714f9a --- /dev/null +++ b/crates/core/src/launch.rs @@ -0,0 +1,387 @@ +//! Building the `java ...` command line and starting the game. + +use crate::install::Installed; +use crate::paths::Layout; +use crate::rules::{self, Env}; +use crate::version::Arg; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum GcPreset { + /// Let the JVM decide. + Default, + /// Tuned G1 (Aikar-style) — smooth frame times on most machines. + #[default] + G1, + /// ZGC — lowest pauses, needs Java 17+ and a bit more RAM. + Zgc, +} + +#[derive(Debug, Clone)] +pub struct Auth { + pub username: String, + /// Dashed or undashed UUID. + pub uuid: String, + pub access_token: String, + /// "msa" for Microsoft accounts, "legacy" for offline. + pub user_type: String, + pub xuid: Option, +} + +#[derive(Debug, Clone)] +pub struct LaunchOptions { + pub auth: Auth, + pub game_dir: PathBuf, + pub memory_min_mb: u32, + pub memory_max_mb: u32, + pub gc: GcPreset, + pub extra_jvm_args: Vec, + pub resolution: Option<(u32, u32)>, + pub fullscreen: bool, + /// Join this server right after the game starts. + pub join_server: Option<(String, u16)>, + pub version_label: String, + pub launcher_name: String, + pub launcher_version: String, +} + +/// Split a user-entered argument string, honouring quotes. +pub fn split_args(s: &str) -> Vec { + let mut out = Vec::new(); + let mut cur = String::new(); + let mut quote: Option = None; + let mut has_token = false; + for c in s.chars() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (Some(_), c) => cur.push(c), + (None, '"') | (None, '\'') => { + quote = Some(c); + has_token = true; + } + (None, c) if c.is_whitespace() => { + if has_token || !cur.is_empty() { + out.push(std::mem::take(&mut cur)); + has_token = false; + } + } + (None, c) => cur.push(c), + } + } + if has_token || !cur.is_empty() { + out.push(cur); + } + out +} + +pub fn gc_args(preset: GcPreset, java_major: u32) -> Vec { + let g1 = [ + "-XX:+UseG1GC", + "-XX:+ParallelRefProcEnabled", + "-XX:MaxGCPauseMillis=200", + "-XX:+UnlockExperimentalVMOptions", + "-XX:+DisableExplicitGC", + "-XX:+AlwaysPreTouch", + "-XX:G1NewSizePercent=30", + "-XX:G1MaxNewSizePercent=40", + "-XX:G1HeapRegionSize=8M", + "-XX:G1ReservePercent=20", + "-XX:G1HeapWastePercent=5", + "-XX:G1MixedGCCountTarget=4", + "-XX:InitiatingHeapOccupancyPercent=15", + "-XX:G1MixedGCLiveThresholdPercent=90", + "-XX:G1RSetUpdatingPauseTimePercent=5", + "-XX:SurvivorRatio=32", + "-XX:+PerfDisableSharedMem", + "-XX:MaxTenuringThreshold=1", + ]; + match preset { + GcPreset::Default => vec![], + GcPreset::Zgc if java_major >= 17 => { + let mut v = vec!["-XX:+UseZGC".to_string()]; + // Generational ZGC: opt-in on 21-22, default (flag obsolete) from 23. + if (21..23).contains(&java_major) { + v.push("-XX:+ZGenerational".into()); + } + v + } + _ => g1.iter().map(|s| s.to_string()).collect(), + } +} + +fn substitute(arg: &str, vars: &HashMap<&str, String>) -> String { + let mut out = String::with_capacity(arg.len()); + let mut rest = arg; + while let Some(start) = rest.find("${") { + out.push_str(&rest[..start]); + let after = &rest[start + 2..]; + match after.find('}') { + Some(end) => { + let key = &after[..end]; + match vars.get(key) { + Some(v) => out.push_str(v), + None => { + out.push_str("${"); + out.push_str(key); + out.push('}'); + } + } + rest = &after[end + 1..]; + } + None => { + out.push_str(&rest[start..]); + rest = ""; + } + } + } + out.push_str(rest); + out +} + +fn expand(args: &[Arg], env: &Env, vars: &HashMap<&str, String>) -> Vec { + let mut out = Vec::new(); + for arg in args { + match arg { + Arg::Plain(s) => out.push(substitute(s, vars)), + Arg::Ruled { rules: r, value } => { + if rules::allowed(r, env) { + out.extend(value.values().iter().map(|s| substitute(s, vars))); + } + } + } + } + out +} + +#[derive(Debug, Clone)] +pub struct Command { + pub java: PathBuf, + pub args: Vec, + pub cwd: PathBuf, +} + +pub fn build(installed: &Installed, layout: &Layout, opts: &LaunchOptions) -> Command { + let v = &installed.version; + let sep = if cfg!(windows) { ";" } else { ":" }; + let classpath = installed + .classpath(layout) + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect::>() + .join(sep); + + let quick_play = v.supports_quick_play(); + let env = Env::current() + .with_feature("has_custom_resolution", opts.resolution.is_some()) + .with_feature("is_quick_play_multiplayer", quick_play && opts.join_server.is_some()); + + let uuid = opts.auth.uuid.replace('-', ""); + let (w, h) = opts.resolution.unwrap_or((854, 480)); + let mut vars: HashMap<&str, String> = HashMap::new(); + vars.insert("auth_player_name", opts.auth.username.clone()); + vars.insert("version_name", v.id.clone()); + vars.insert("game_directory", opts.game_dir.to_string_lossy().into_owned()); + vars.insert("assets_root", installed.assets_root.to_string_lossy().into_owned()); + vars.insert("game_assets", installed.game_assets.to_string_lossy().into_owned()); + vars.insert("assets_index_name", installed.assets_index.clone()); + vars.insert("auth_uuid", uuid.clone()); + vars.insert("auth_access_token", opts.auth.access_token.clone()); + vars.insert("auth_session", format!("token:{}:{}", opts.auth.access_token, uuid)); + vars.insert("clientid", String::new()); + vars.insert("auth_xuid", opts.auth.xuid.clone().unwrap_or_default()); + vars.insert("user_type", opts.auth.user_type.clone()); + vars.insert("user_properties", "{}".into()); + vars.insert("version_type", opts.version_label.clone()); + vars.insert("resolution_width", w.to_string()); + vars.insert("resolution_height", h.to_string()); + vars.insert("natives_directory", installed.natives_dir.to_string_lossy().into_owned()); + vars.insert("launcher_name", opts.launcher_name.clone()); + vars.insert("launcher_version", opts.launcher_version.clone()); + vars.insert("classpath", classpath); + vars.insert("classpath_separator", sep.into()); + vars.insert("library_directory", layout.libraries().to_string_lossy().into_owned()); + if let Some((host, port)) = &opts.join_server { + vars.insert("quickPlayMultiplayer", format!("{host}:{port}")); + } + + let mut args = Vec::new(); + args.push(format!("-Xms{}M", opts.memory_min_mb.min(opts.memory_max_mb))); + args.push(format!("-Xmx{}M", opts.memory_max_mb)); + args.extend(gc_args(opts.gc, installed.java_major)); + args.push("-Dlog4j2.formatMsgNoLookups=true".into()); + if let Some(log) = &installed.logging_arg { + args.push(log.clone()); + } + args.extend(opts.extra_jvm_args.iter().cloned()); + + match &v.arguments { + Some(a) if !a.jvm.is_empty() => args.extend(expand(&a.jvm, &env, &vars)), + _ => { + args.push(format!("-Djava.library.path={}", vars["natives_directory"])); + args.push("-cp".into()); + args.push(vars["classpath"].clone()); + } + } + + args.push(v.main_class.clone().unwrap_or_else(|| "net.minecraft.client.main.Main".into())); + + let mut game_args = match (&v.arguments, &v.minecraft_arguments) { + (_, Some(legacy)) if v.arguments.as_ref().map(|a| a.game.is_empty()).unwrap_or(true) => { + legacy.split_whitespace().map(|s| substitute(s, &vars)).collect() + } + (Some(a), _) => expand(&a.game, &env, &vars), + _ => vec![], + }; + + let has = |args: &[String], flag: &str| args.iter().any(|a| a == flag); + if let Some((w, h)) = opts.resolution { + if !has(&game_args, "--width") { + game_args.extend(["--width".into(), w.to_string(), "--height".into(), h.to_string()]); + } + } + if opts.fullscreen && !has(&game_args, "--fullscreen") { + game_args.push("--fullscreen".into()); + } + if let Some((host, port)) = &opts.join_server { + if !quick_play { + game_args.extend(["--server".into(), host.clone(), "--port".into(), port.to_string()]); + } + } + args.extend(game_args); + + Command { java: installed.java.clone(), args, cwd: opts.game_dir.clone() } +} + +/// Java 9+ can read arguments from a file, which sidesteps Windows' +/// 32k command-line limit on huge modpacks. +fn write_argfile(path: &Path, args: &[String]) -> Result<()> { + let body = args + .iter() + .map(|a| format!("\"{}\"", a.replace('\\', "\\\\").replace('"', "\\\""))) + .collect::>() + .join("\n"); + std::fs::write(path, body)?; + Ok(()) +} + +pub fn spawn(cmd: &Command, java_major: u32) -> Result { + std::fs::create_dir_all(&cmd.cwd)?; + let mut proc = tokio::process::Command::new(&cmd.java); + let total_len: usize = cmd.args.iter().map(|a| a.len() + 3).sum(); + if java_major >= 9 && total_len > 8000 { + let argfile = cmd.cwd.join(".scopenet").join("launch.args"); + std::fs::create_dir_all(argfile.parent().unwrap())?; + write_argfile(&argfile, &cmd.args)?; + proc.arg(format!("@{}", argfile.display())); + } else { + proc.args(&cmd.args); + } + proc.current_dir(&cmd.cwd) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + #[cfg(windows)] + proc.creation_flags(0x0800_0000); + proc.spawn().with_context(|| format!("starting {}", cmd.java.display())) +} + +/// Hide the access token when showing the command to the user. +pub fn redact(args: &[String], token: &str) -> Vec { + if token.len() < 8 { + return args.to_vec(); + } + args.iter().map(|a| a.replace(token, "********")).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::version::VersionJson; + + #[test] + fn splits_quoted_args() { + assert_eq!(split_args(r#"-Xss2M -Dfoo="a b" '' -Dx=1"#), vec!["-Xss2M", "-Dfoo=a b", "", "-Dx=1"]); + assert!(split_args(" ").is_empty()); + } + + #[test] + fn substitutes_vars() { + let mut vars = HashMap::new(); + vars.insert("a", "1".to_string()); + assert_eq!(substitute("x${a}y${b}z", &vars), "x1y${b}z"); + } + + #[test] + fn zgc_falls_back_on_java8() { + assert!(gc_args(GcPreset::Zgc, 8).contains(&"-XX:+UseG1GC".to_string())); + assert_eq!(gc_args(GcPreset::Zgc, 21), vec!["-XX:+UseZGC", "-XX:+ZGenerational"]); + assert_eq!(gc_args(GcPreset::Zgc, 25), vec!["-XX:+UseZGC"]); + } + + fn installed(json: &str) -> Installed { + Installed { + version: serde_json::from_str::(json).unwrap(), + java: "java".into(), + java_major: 17, + client_jar: "/v/c.jar".into(), + natives_dir: "/n".into(), + libraries: vec![], + assets_root: "/a".into(), + game_assets: "/a".into(), + assets_index: "5".into(), + logging_arg: None, + } + } + + fn opts(join: bool) -> LaunchOptions { + LaunchOptions { + auth: Auth { username: "Steve".into(), uuid: "b50ad385-829d-3141-a216-7e7d7539ba7f".into(), access_token: "0".into(), user_type: "legacy".into(), xuid: None }, + game_dir: "/g".into(), + memory_min_mb: 1024, + memory_max_mb: 4096, + gc: GcPreset::Default, + extra_jvm_args: vec!["-Dcustom=1".into()], + resolution: Some((1280, 720)), + fullscreen: false, + join_server: join.then(|| ("play.example.net".to_string(), 25565)), + version_label: "ScopeNet".into(), + launcher_name: "scopenet".into(), + launcher_version: "0.1.0".into(), + } + } + + #[test] + fn builds_modern_command_with_quick_play() { + let json = r#"{"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","arguments":{ + "game":["--username","${auth_player_name}","--uuid","${auth_uuid}","--versionType","${version_type}", + {"rules":[{"action":"allow","features":{"has_custom_resolution":true}}],"value":["--width","${resolution_width}","--height","${resolution_height}"]}, + {"rules":[{"action":"allow","features":{"is_quick_play_multiplayer":true}}],"value":["--quickPlayMultiplayer","${quickPlayMultiplayer}"]}], + "jvm":["-Djava.library.path=${natives_directory}","-cp","${classpath}"]}}"#; + let layout = Layout::new("/root"); + let cmd = build(&installed(json), &layout, &opts(true)); + let a = cmd.args.join(" "); + assert!(a.starts_with("-Xms1024M -Xmx4096M")); + assert!(a.contains("-Dcustom=1")); + assert!(a.contains("--uuid b50ad385829d3141a2167e7d7539ba7f")); + assert!(a.contains("--width 1280 --height 720")); + assert!(a.contains("--quickPlayMultiplayer play.example.net:25565")); + assert!(!a.contains("--server")); + assert!(a.contains("--versionType ScopeNet")); + assert!(a.contains("-Djava.library.path=/n")); + } + + #[test] + fn builds_legacy_command_with_server_flag() { + let json = r#"{"id":"1.12.2","mainClass":"net.minecraft.launchwrapper.Launch", + "minecraftArguments":"--username ${auth_player_name} --tweakClass net.minecraftforge.fml.common.launcher.FMLTweaker"}"#; + let cmd = build(&installed(json), &Layout::new("/root"), &opts(true)); + let a = cmd.args.join(" "); + assert!(a.contains("-Djava.library.path=/n -cp /v/c.jar net.minecraft.launchwrapper.Launch --username Steve")); + assert!(a.contains("--width 1280 --height 720")); + assert!(a.ends_with("--server play.example.net --port 25565")); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs new file mode 100644 index 0000000..dd2b42a --- /dev/null +++ b/crates/core/src/lib.rs @@ -0,0 +1,28 @@ +//! ScopeNet launcher engine. +//! +//! The Tauri app is a thin UI shell around this crate: everything that +//! downloads, installs or launches Minecraft lives here so it can be unit +//! tested without a window. + +pub mod assets; +pub mod http; +pub mod install; +pub mod java; +pub mod launch; +pub mod libraries; +pub mod loaders; +pub mod maven; +pub mod meta; +pub mod msa; +pub mod options; +pub mod paths; +pub mod ping; +pub mod progress; +pub mod rules; +pub mod servers_dat; +pub mod sync; +pub mod sys; +pub mod version; + +pub use paths::Layout; +pub use progress::{Event, Reporter, Stage}; diff --git a/crates/core/src/libraries.rs b/crates/core/src/libraries.rs new file mode 100644 index 0000000..5a86ecc --- /dev/null +++ b/crates/core/src/libraries.rs @@ -0,0 +1,216 @@ +//! Turning version-JSON libraries into files on disk + a classpath. + +use crate::http::Download; +use crate::maven::Coord; +use crate::rules::{self, Env}; +use crate::version::Library; +use anyhow::Result; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub const MOJANG_LIBRARIES: &str = "https://libraries.minecraft.net/"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ResolvedLib { + pub name: String, + /// Path relative to the libraries dir (`/`-separated). + pub path: String, + /// `None` when the file must come from somewhere else (e.g. a Forge installer). + pub url: Option, + pub sha1: Option, + pub size: Option, + /// Goes on the classpath. + pub classpath: bool, + /// Contains native libraries that must be extracted. + pub native: bool, + pub exclude: Vec, +} + +impl ResolvedLib { + pub fn file(&self, libraries_dir: &Path) -> PathBuf { + libraries_dir.join(&self.path) + } + + pub fn download(&self, libraries_dir: &Path) -> Option { + self.url.as_ref().map(|url| Download::new(url.clone(), self.file(libraries_dir), self.sha1.clone(), self.size)) + } +} + +fn join_url(base: &str, path: &str) -> String { + if base.ends_with('/') { format!("{base}{path}") } else { format!("{base}/{path}") } +} + +pub fn resolve(libs: &[Library], env: &Env) -> Vec { + let mut out = Vec::new(); + for lib in libs { + if let Some(rules) = &lib.rules { + if !rules::allowed(rules, env) { + continue; + } + } + let Some(coord) = Coord::parse(&lib.name) else { continue }; + let exclude = lib.extract.as_ref().map(|e| e.exclude.clone()).unwrap_or_default(); + + // Legacy natives (LWJGL 2 / pre-1.19): a separate classifier jar + // that is extracted but never put on the classpath. + if let Some(natives) = &lib.natives { + if let Some(classifier) = natives.get(env.os) { + let classifier = classifier.replace("${arch}", env.bits()); + let from_downloads = lib + .downloads + .as_ref() + .and_then(|d| d.classifiers.as_ref()) + .and_then(|c| c.get(&classifier)); + let native_coord = coord.with_classifier(&classifier); + let (path, url, sha1, size) = match from_downloads { + Some(a) => ( + a.path.clone().unwrap_or_else(|| native_coord.path()), + Some(a.url.clone()).filter(|u| !u.is_empty()), + a.sha1.clone(), + a.size, + ), + None => { + let p = native_coord.path(); + let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES); + (p.clone(), Some(join_url(base, &p)), None, None) + } + }; + out.push(ResolvedLib { + name: format!("{}:{}", lib.name, classifier), + path, + url, + sha1, + size, + classpath: false, + native: true, + exclude: exclude.clone(), + }); + } + // A natives-map library only has a main artifact if downloads.artifact exists. + let Some(artifact) = lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) else { continue }; + out.push(ResolvedLib { + name: lib.name.clone(), + path: artifact.path.clone().unwrap_or_else(|| coord.path()), + url: Some(artifact.url.clone()).filter(|u| !u.is_empty()), + sha1: artifact.sha1.clone(), + size: artifact.size, + classpath: true, + native: false, + exclude, + }); + continue; + } + + let is_native_jar = coord.classifier.as_deref().is_some_and(|c| c.starts_with("natives-")); + let resolved = match lib.downloads.as_ref().and_then(|d| d.artifact.as_ref()) { + Some(a) => ResolvedLib { + name: lib.name.clone(), + path: a.path.clone().filter(|p| !p.is_empty()).unwrap_or_else(|| coord.path()), + url: Some(a.url.clone()).filter(|u| !u.is_empty()), + sha1: a.sha1.clone(), + size: a.size, + classpath: true, + native: is_native_jar, + exclude, + }, + None => { + if lib.downloads.is_some() { + // `downloads` present but no artifact: classifier-only entry we don't need. + continue; + } + let p = coord.path(); + let base = lib.url.as_deref().unwrap_or(MOJANG_LIBRARIES); + ResolvedLib { + name: lib.name.clone(), + path: p.clone(), + url: Some(join_url(base, &p)), + sha1: lib.sha1.clone(), + size: lib.size, + classpath: true, + native: is_native_jar, + exclude, + } + } + }; + out.push(resolved); + } + out +} + +fn is_native_file(name: &str) -> bool { + let n = name.to_ascii_lowercase(); + n.ends_with(".dll") || n.ends_with(".so") || n.ends_with(".dylib") || n.ends_with(".jnilib") +} + +/// Extract native libraries (flattened) into `dest`. +pub fn extract_natives(libs: &[ResolvedLib], libraries_dir: &Path, dest: &Path) -> Result<()> { + std::fs::create_dir_all(dest)?; + for lib in libs.iter().filter(|l| l.native) { + let path = lib.file(libraries_dir); + let Ok(file) = std::fs::File::open(&path) else { + tracing::warn!("missing native jar {}", path.display()); + continue; + }; + let mut zip = zip::ZipArchive::new(file)?; + for i in 0..zip.len() { + let mut entry = zip.by_index(i)?; + if entry.is_dir() { + continue; + } + let name = entry.name().to_string(); + if name.starts_with("META-INF") || lib.exclude.iter().any(|e| name.starts_with(e.as_str())) { + continue; + } + if !is_native_file(&name) { + continue; + } + let file_name = name.rsplit('/').next().unwrap_or(&name); + // Skip natives for other architectures (LWJGL 3.3 ships several). + let lower = name.to_ascii_lowercase(); + if cfg!(target_arch = "x86_64") && (lower.contains("arm64") || lower.contains("aarch64") || lower.contains("/x86/")) { + continue; + } + let target = dest.join(file_name); + if target.exists() && std::fs::metadata(&target).map(|m| m.len() == entry.size()).unwrap_or(false) { + continue; + } + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + std::fs::write(&target, buf)?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::version::VersionJson; + use std::collections::HashMap; + + #[test] + fn resolves_legacy_natives_and_maven_libs() { + let v: VersionJson = serde_json::from_str( + r#"{"id":"x","libraries":[ + {"name":"org.lwjgl.lwjgl:lwjgl-platform:2.9.4-nightly-20150209", + "natives":{"linux":"natives-linux","windows":"natives-windows-${arch}"}, + "extract":{"exclude":["META-INF/"]}, + "downloads":{"classifiers":{"natives-windows-64":{"path":"p/w64.jar","sha1":"s","size":5,"url":"https://l/w64.jar"}}}}, + {"name":"net.fabricmc:intermediary:1.20.1","url":"https://maven.fabricmc.net/"}, + {"name":"com.mojang:only-mac:1","rules":[{"action":"allow","os":{"name":"osx"}}]}, + {"name":"net.minecraftforge:forge:1.20.1-47.2.0:universal","downloads":{"artifact":{"path":"net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-universal.jar","url":"","sha1":"u","size":9}}} + ]}"#, + ) + .unwrap(); + let env = Env { os: "windows", arch: "x86_64", features: HashMap::new() }; + let libs = resolve(&v.libraries, &env); + assert_eq!(libs.len(), 3); + assert!(libs[0].native && !libs[0].classpath); + assert_eq!(libs[0].path, "p/w64.jar"); + assert_eq!( + libs[1].url.as_deref(), + Some("https://maven.fabricmc.net/net/fabricmc/intermediary/1.20.1/intermediary-1.20.1.jar") + ); + assert!(libs[2].url.is_none(), "empty url = bundled in installer"); + } +} diff --git a/crates/core/src/loaders/fabric.rs b/crates/core/src/loaders/fabric.rs new file mode 100644 index 0000000..e4a2f37 --- /dev/null +++ b/crates/core/src/loaders/fabric.rs @@ -0,0 +1,20 @@ +//! Fabric and Quilt both publish a ready-made launcher profile. + +use crate::http; +use crate::meta::{FABRIC_META, QUILT_META}; +use crate::paths::Layout; +use crate::version::VersionJson; +use anyhow::{Context, Result}; +use scopenet_shared::Loader; + +pub async fn profile(client: &reqwest::Client, layout: &Layout, loader: Loader, mc: &str, loader_version: &str) -> Result { + let url = match loader { + Loader::Quilt => format!("{QUILT_META}/versions/loader/{mc}/{loader_version}/profile/json"), + _ => format!("{FABRIC_META}/versions/loader/{mc}/{loader_version}/profile/json"), + }; + let cache = layout.cache().join("profiles").join(format!("{}-{mc}-{loader_version}.json", loader.as_str())); + let v: VersionJson = http::get_json_cached(client, &url, &cache) + .await + .with_context(|| format!("fetching {} {loader_version} profile for {mc}", loader.as_str()))?; + Ok(v) +} diff --git a/crates/core/src/loaders/forge.rs b/crates/core/src/loaders/forge.rs new file mode 100644 index 0000000..f40314a --- /dev/null +++ b/crates/core/src/loaders/forge.rs @@ -0,0 +1,360 @@ +//! Forge / NeoForge. Their installers ship a version JSON plus a list of +//! "processors" (small Java programs that patch the game jar). We replay +//! exactly what the official installer does, headlessly. + +use crate::http::{self, Download}; +use crate::libraries; +use crate::maven::Coord; +use crate::meta::forge_installer_url; +use crate::paths::Layout; +use crate::progress::{self, Event, Reporter, Stage}; +use crate::rules::Env; +use crate::version::{Library, VersionJson}; +use anyhow::{anyhow, bail, Context, Result}; +use scopenet_shared::Loader; +use serde::Deserialize; +use std::collections::HashMap; +use std::io::Read; +use std::path::{Path, PathBuf}; +use tokio::io::{AsyncBufReadExt, BufReader}; + +pub struct ForgeInstall<'a> { + pub client: &'a reqwest::Client, + pub layout: &'a Layout, + pub loader: Loader, + pub mc: &'a str, + /// Maven version, e.g. `1.20.1-47.2.0` (Forge) or `21.1.77` (NeoForge). + pub version: &'a str, + /// Console Java executable used to run processors. + pub java: &'a Path, + pub client_jar: &'a Path, + pub concurrency: usize, + pub reporter: &'a Reporter, +} + +#[derive(Deserialize)] +struct InstallProfile { + #[serde(default = "default_json")] + json: String, + #[serde(default)] + data: HashMap, + #[serde(default)] + processors: Vec, + #[serde(default)] + libraries: Vec, +} + +fn default_json() -> String { + "/version.json".into() +} + +#[derive(Deserialize)] +struct DataEntry { + client: String, +} + +#[derive(Deserialize)] +struct Processor { + #[serde(default)] + sides: Option>, + jar: String, + #[serde(default)] + classpath: Vec, + #[serde(default)] + args: Vec, + #[serde(default)] + outputs: HashMap, +} + +type Zip = zip::ZipArchive; + +fn read_entry(zip: &mut Zip, name: &str) -> Result> { + let mut entry = zip.by_name(name.trim_start_matches('/')).with_context(|| format!("installer is missing {name}"))?; + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + Ok(buf) +} + +fn extract_entry(zip: &mut Zip, name: &str, dest: &Path) -> Result { + let Ok(mut entry) = zip.by_name(name.trim_start_matches('/')) else { return Ok(false) }; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + std::fs::write(dest, buf)?; + Ok(true) +} + +fn marker_path(layout: &Layout, id: &str) -> PathBuf { + layout.version_dir(id).join(".scopenet-installed") +} + +pub async fn install(ctx: ForgeInstall<'_>) -> Result { + let label = if ctx.loader == Loader::NeoForge { "NeoForge" } else { "Forge" }; + progress::stage(ctx.reporter, Stage::Loader, format!("Installing {label} {}", ctx.version)); + + let installer = ctx.layout.cache().join("installers").join(format!("{}-{}-installer.jar", ctx.loader.as_str(), ctx.version)); + if !installer.exists() { + let url = forge_installer_url(ctx.loader, ctx.version); + http::download_one(ctx.client, &Download::new(url, installer.clone(), None, None), &|_| {}).await?; + } + let mut zip = zip::ZipArchive::new(std::fs::File::open(&installer)?).context("opening installer")?; + let profile_raw: serde_json::Value = serde_json::from_slice(&read_entry(&mut zip, "install_profile.json")?)?; + + if profile_raw.get("versionInfo").is_some() { + return install_legacy(&ctx, &mut zip, profile_raw); + } + + let profile: InstallProfile = serde_json::from_value(profile_raw)?; + let mut version: VersionJson = serde_json::from_slice(&read_entry(&mut zip, &profile.json)?)?; + let libs_dir = ctx.layout.libraries(); + + // Files bundled in the installer's maven/ folder. + let names: Vec = zip.file_names().filter(|n| n.starts_with("maven/") && !n.ends_with('/')).map(String::from).collect(); + for name in names { + let dest = libs_dir.join(name.trim_start_matches("maven/")); + if !dest.exists() { + extract_entry(&mut zip, &name, &dest)?; + } + } + + // Libraries needed by the processors and by the game. + let env = Env::current(); + let mut all_libs = libraries::resolve(&profile.libraries, &env); + all_libs.extend(libraries::resolve(&version.libraries, &env)); + let mut downloads = Vec::new(); + for lib in &all_libs { + let file = lib.file(&libs_dir); + if http::file_ok(&file, lib.sha1.as_deref(), lib.size, false) { + continue; + } + match lib.download(&libs_dir) { + Some(dl) => downloads.push(dl), + None => { + extract_entry(&mut zip, &format!("maven/{}", lib.path), &file)?; + } + } + } + downloads.dedup_by(|a, b| a.dest == b.dest); + http::download_all(ctx.client, downloads, ctx.concurrency, Stage::Loader, ctx.reporter).await?; + + let marker = marker_path(ctx.layout, &version.id); + if std::fs::read_to_string(&marker).ok().as_deref() != Some(ctx.version) { + run_processors(&ctx, &mut zip, &profile, &installer).await?; + std::fs::create_dir_all(marker.parent().unwrap())?; + std::fs::write(&marker, ctx.version)?; + } + + version.inherits_from = Some(ctx.mc.to_string()); + save_version(ctx.layout, &version)?; + Ok(version) +} + +fn save_version(layout: &Layout, version: &VersionJson) -> Result<()> { + let path = layout.version_json(&version.id); + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(path, serde_json::to_vec_pretty(version)?)?; + Ok(()) +} + +/// Forge ≤ 1.12: `install_profile.json` holds `install` + `versionInfo`. +fn install_legacy(ctx: &ForgeInstall<'_>, zip: &mut Zip, raw: serde_json::Value) -> Result { + let info = raw.get("versionInfo").cloned().unwrap_or_default(); + // Old profiles point at the long-dead files.minecraftforge.net/maven. + let fixed = serde_json::to_string(&info)? + .replace("http://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/") + .replace("https://files.minecraftforge.net/maven/", "https://maven.minecraftforge.net/"); + let mut version: VersionJson = serde_json::from_str(&fixed)?; + let install = raw.get("install").ok_or_else(|| anyhow!("legacy installer missing 'install'"))?; + let coord = install.get("path").and_then(|v| v.as_str()).and_then(Coord::parse).ok_or_else(|| anyhow!("bad install.path"))?; + let file_path = install.get("filePath").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("bad install.filePath"))?; + let dest = ctx.layout.libraries().join(coord.path()); + if !dest.exists() { + extract_entry(zip, file_path, &dest)?; + } + version.inherits_from = Some(ctx.mc.to_string()); + save_version(ctx.layout, &version)?; + Ok(version) +} + +fn resolve_data_value(value: &str, libs_dir: &Path, tmp: &Path, zip: &mut Zip) -> Result { + if value.starts_with('[') && value.ends_with(']') { + let coord = Coord::parse(value).ok_or_else(|| anyhow!("bad coordinate {value}"))?; + return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned()); + } + if value.len() >= 2 && value.starts_with('\'') && value.ends_with('\'') { + return Ok(value[1..value.len() - 1].to_string()); + } + if value.starts_with('/') { + let dest = tmp.join(value.trim_start_matches('/')); + extract_entry(zip, value, &dest)?; + return Ok(dest.to_string_lossy().into_owned()); + } + Ok(value.to_string()) +} + +fn resolve_arg(arg: &str, data: &HashMap, libs_dir: &Path) -> Result { + if arg.starts_with('[') && arg.ends_with(']') { + let coord = Coord::parse(arg).ok_or_else(|| anyhow!("bad coordinate {arg}"))?; + return Ok(libs_dir.join(coord.path()).to_string_lossy().into_owned()); + } + if arg.starts_with('{') && arg.ends_with('}') { + let key = &arg[1..arg.len() - 1]; + return data.get(key).cloned().ok_or_else(|| anyhow!("installer references unknown data key {key}")); + } + if arg.len() >= 2 && arg.starts_with('\'') && arg.ends_with('\'') { + return Ok(arg[1..arg.len() - 1].to_string()); + } + let mut out = arg.to_string(); + for (k, v) in data { + let needle = format!("{{{k}}}"); + if out.contains(&needle) { + out = out.replace(&needle, v); + } + } + Ok(out) +} + +pub fn jar_main_class(jar: &Path) -> Result { + let mut zip = zip::ZipArchive::new(std::fs::File::open(jar).with_context(|| format!("opening {}", jar.display()))?)?; + let manifest = String::from_utf8_lossy(&read_entry(&mut zip, "META-INF/MANIFEST.MF")?).into_owned(); + parse_main_class(&manifest).ok_or_else(|| anyhow!("{} has no Main-Class", jar.display())) +} + +fn parse_main_class(manifest: &str) -> Option { + // Manifest lines wrap at 72 bytes; continuation lines start with a space. + let mut unwrapped = String::new(); + for line in manifest.lines() { + if let Some(rest) = line.strip_prefix(' ') { + unwrapped.push_str(rest); + } else { + unwrapped.push('\n'); + unwrapped.push_str(line.trim_end_matches('\r')); + } + } + unwrapped.lines().find_map(|l| l.strip_prefix("Main-Class:").map(|v| v.trim().to_string())) +} + +async fn run_processors(ctx: &ForgeInstall<'_>, zip: &mut Zip, profile: &InstallProfile, installer: &Path) -> Result<()> { + let libs_dir = ctx.layout.libraries(); + let tmp = ctx.layout.cache().join("forge-tmp").join(ctx.version); + std::fs::create_dir_all(&tmp)?; + + let mut data = HashMap::new(); + for (key, entry) in &profile.data { + data.insert(key.clone(), resolve_data_value(&entry.client, &libs_dir, &tmp, zip)?); + } + data.insert("SIDE".into(), "client".into()); + data.insert("MINECRAFT_JAR".into(), ctx.client_jar.to_string_lossy().into_owned()); + data.insert("MINECRAFT_VERSION".into(), ctx.mc.to_string()); + data.insert("ROOT".into(), ctx.layout.root.to_string_lossy().into_owned()); + data.insert("INSTALLER".into(), installer.to_string_lossy().into_owned()); + data.insert("LIBRARY_DIR".into(), libs_dir.to_string_lossy().into_owned()); + + let sep = if cfg!(windows) { ";" } else { ":" }; + let processors: Vec<&Processor> = profile + .processors + .iter() + .filter(|p| p.sides.as_ref().map(|s| s.iter().any(|x| x == "client")).unwrap_or(true)) + .collect(); + let total = processors.len() as u32; + + for (i, proc) in processors.into_iter().enumerate() { + ctx.reporter.as_ref()(Event::Progress { + stage: Stage::Loader, + done: i as u64, + total: total as u64, + files_done: i as u32, + files_total: total, + }); + + // Skip processors whose outputs already exist with the right hash. + if !proc.outputs.is_empty() { + let mut all_ok = true; + for (k, v) in &proc.outputs { + let path = resolve_arg(k, &data, &libs_dir)?; + let sha = resolve_arg(v, &data, &libs_dir)?; + if !http::file_ok(Path::new(&path), Some(&sha), None, true) { + all_ok = false; + break; + } + } + if all_ok { + continue; + } + } + + let jar = libs_dir.join(Coord::parse(&proc.jar).ok_or_else(|| anyhow!("bad processor jar {}", proc.jar))?.path()); + let main_class = jar_main_class(&jar)?; + let mut cp = vec![jar.to_string_lossy().into_owned()]; + for c in &proc.classpath { + let coord = Coord::parse(c).ok_or_else(|| anyhow!("bad classpath entry {c}"))?; + cp.push(libs_dir.join(coord.path()).to_string_lossy().into_owned()); + } + let args = proc.args.iter().map(|a| resolve_arg(a, &data, &libs_dir)).collect::>>()?; + + let mut cmd = tokio::process::Command::new(ctx.java); + cmd.arg("-cp").arg(cp.join(sep)).arg(&main_class).args(&args); + cmd.current_dir(&tmp); + cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()).kill_on_drop(true); + #[cfg(windows)] + cmd.creation_flags(0x0800_0000); + let mut child = cmd.spawn().with_context(|| format!("starting processor {main_class}"))?; + + let tail = std::sync::Arc::new(std::sync::Mutex::new(std::collections::VecDeque::::new())); + let pump = |stream: Option>| { + let reporter = ctx.reporter.clone(); + let tail = tail.clone(); + async move { + let Some(stream) = stream else { return }; + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + { + let mut t = tail.lock().unwrap(); + t.push_back(line.clone()); + if t.len() > 30 { + t.pop_front(); + } + } + reporter(Event::Log { line }); + } + } + }; + let out = child.stdout.take().map(|s| Box::new(s) as Box); + let err = child.stderr.take().map(|s| Box::new(s) as Box); + let (status, _, _) = tokio::join!(child.wait(), pump(out), pump(err)); + let status = status?; + if !status.success() { + let tail = tail.lock().unwrap().iter().cloned().collect::>().join("\n"); + bail!("{main_class} failed ({status}):\n{tail}"); + } + } + std::fs::remove_dir_all(&tmp).ok(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn manifest_main_class_with_wrapping() { + let mf = "Manifest-Version: 1.0\r\nMain-Class: net.minecraftforge.binarypatcher.Consol\r\n eTool\r\nOther: x\r\n"; + assert_eq!(parse_main_class(mf).as_deref(), Some("net.minecraftforge.binarypatcher.ConsoleTool")); + } + + #[test] + fn resolves_processor_args() { + let libs = Path::new("/libs"); + let mut data = HashMap::new(); + data.insert("MC_SLIM".to_string(), "/libs/slim.jar".to_string()); + assert_eq!(resolve_arg("{MC_SLIM}", &data, libs).unwrap(), "/libs/slim.jar"); + assert_eq!(resolve_arg("--flag", &data, libs).unwrap(), "--flag"); + assert_eq!( + resolve_arg("[net.minecraft:client:1.20.1:srg]", &data, libs).unwrap(), + Path::new("/libs").join("net/minecraft/client/1.20.1/client-1.20.1-srg.jar").to_string_lossy() + ); + assert!(resolve_arg("{MISSING}", &data, libs).is_err()); + } +} diff --git a/crates/core/src/loaders/mod.rs b/crates/core/src/loaders/mod.rs new file mode 100644 index 0000000..a81662f --- /dev/null +++ b/crates/core/src/loaders/mod.rs @@ -0,0 +1,5 @@ +//! Mod loader installation. Each loader produces a child version JSON that +//! is merged on top of vanilla. + +pub mod fabric; +pub mod forge; diff --git a/crates/core/src/maven.rs b/crates/core/src/maven.rs new file mode 100644 index 0000000..4db5f9f --- /dev/null +++ b/crates/core/src/maven.rs @@ -0,0 +1,74 @@ +/// A Maven coordinate: `group:artifact:version[:classifier][@ext]`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Coord { + pub group: String, + pub artifact: String, + pub version: String, + pub classifier: Option, + pub ext: String, +} + +impl Coord { + pub fn parse(s: &str) -> Option { + let s = s.trim().trim_start_matches('[').trim_end_matches(']'); + let (main, ext) = match s.split_once('@') { + Some((m, e)) => (m, e.to_string()), + None => (s, "jar".to_string()), + }; + let parts: Vec<&str> = main.split(':').collect(); + if parts.len() < 3 { + return None; + } + Some(Self { + group: parts[0].into(), + artifact: parts[1].into(), + version: parts[2].into(), + classifier: parts.get(3).map(|c| c.to_string()), + ext, + }) + } + + /// Relative repository path (`/`-separated). + pub fn path(&self) -> String { + let classifier = self.classifier.as_ref().map(|c| format!("-{c}")).unwrap_or_default(); + format!( + "{}/{}/{}/{}-{}{}.{}", + self.group.replace('.', "/"), + self.artifact, + self.version, + self.artifact, + self.version, + classifier, + self.ext + ) + } + + /// Identity used to de-duplicate libraries (version-less). + pub fn key(&self) -> String { + match &self.classifier { + Some(c) => format!("{}:{}:{}", self.group, self.artifact, c), + None => format!("{}:{}", self.group, self.artifact), + } + } + + pub fn with_classifier(&self, classifier: &str) -> Self { + Self { classifier: Some(classifier.to_string()), ..self.clone() } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_coords() { + let c = Coord::parse("net.fabricmc:fabric-loader:0.16.9").unwrap(); + assert_eq!(c.path(), "net/fabricmc/fabric-loader/0.16.9/fabric-loader-0.16.9.jar"); + let c = Coord::parse("de.oceanlabs.mcp:mcp_config:1.20.1-20230612.114412@zip").unwrap(); + assert_eq!(c.path(), "de/oceanlabs/mcp/mcp_config/1.20.1-20230612.114412/mcp_config-1.20.1-20230612.114412.zip"); + let c = Coord::parse("[net.minecraft:client:1.20.1-20230612.114412:slim]").unwrap(); + assert_eq!(c.path(), "net/minecraft/client/1.20.1-20230612.114412/client-1.20.1-20230612.114412-slim.jar"); + assert_eq!(c.key(), "net.minecraft:client:slim"); + assert!(Coord::parse("nope").is_none()); + } +} diff --git a/crates/core/src/meta.rs b/crates/core/src/meta.rs new file mode 100644 index 0000000..e8ca63b --- /dev/null +++ b/crates/core/src/meta.rs @@ -0,0 +1,198 @@ +//! Version metadata from Mojang and the mod-loader projects. Used by the +//! launcher (to install) and by the panel (to populate version pickers). + +use crate::http::get_json; +use anyhow::{bail, Context, Result}; +use scopenet_shared::Loader; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +pub const MOJANG_MANIFEST: &str = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; +pub const FABRIC_META: &str = "https://meta.fabricmc.net/v2"; +pub const QUILT_META: &str = "https://meta.quiltmc.org/v3"; +pub const FORGE_MAVEN: &str = "https://maven.minecraftforge.net"; +pub const FORGE_FILES: &str = "https://files.minecraftforge.net/net/minecraftforge/forge"; +pub const NEOFORGE_MAVEN: &str = "https://maven.neoforged.net/releases"; +pub const NEOFORGE_VERSIONS: &str = "https://maven.neoforged.net/api/maven/versions/releases/net/neoforged/neoforge"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VersionManifest { + pub latest: Latest, + pub versions: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Latest { + pub release: String, + pub snapshot: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ManifestVersion { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub url: String, + #[serde(default)] + pub sha1: Option, + #[serde(default)] + pub release_time: Option, +} + +pub async fn mojang_manifest(client: &reqwest::Client) -> Result { + get_json(client, MOJANG_MANIFEST).await +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LoaderVersion { + pub version: String, + pub stable: bool, +} + +#[derive(Deserialize)] +struct FabricLoaderEntry { + loader: FabricLoaderInfo, +} +#[derive(Deserialize)] +struct FabricLoaderInfo { + version: String, + #[serde(default)] + stable: Option, +} + +#[derive(Deserialize)] +struct NeoVersions { + versions: Vec, +} + +#[derive(Deserialize)] +struct ForgePromos { + promos: HashMap, +} + +/// Map a NeoForge version to the Minecraft version it targets. +/// `21.1.77` → `1.21.1`, `21.0.5` → `1.21`, `26.1.0.3` → `26.1` (new +/// year-based Minecraft numbering). +pub fn neoforge_mc_version(neo: &str) -> Option { + let core = neo.split('-').next()?; + let parts: Vec<&str> = core.split('.').collect(); + let major: u32 = parts.first()?.parse().ok()?; + let minor: u32 = parts.get(1)?.parse().ok()?; + if major >= 26 { + let patch: u32 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); + return Some(if patch == 0 { format!("{major}.{minor}") } else { format!("{major}.{minor}.{patch}") }); + } + Some(if minor == 0 { format!("1.{major}") } else { format!("1.{major}.{minor}") }) +} + +/// All loader versions for a Minecraft version, newest first. +pub async fn loader_versions(client: &reqwest::Client, loader: Loader, mc: &str) -> Result> { + Ok(match loader { + Loader::Vanilla => vec![], + Loader::Fabric | Loader::Quilt => { + let url = if loader == Loader::Fabric { + format!("{FABRIC_META}/versions/loader/{mc}") + } else { + format!("{QUILT_META}/versions/loader/{mc}") + }; + let entries: Vec = get_json(client, &url).await?; + entries + .into_iter() + .map(|e| { + let stable = e.loader.stable.unwrap_or_else(|| !e.loader.version.contains("beta")); + LoaderVersion { version: e.loader.version, stable } + }) + .collect() + } + Loader::Forge => { + let all: HashMap> = + get_json(client, &format!("{FORGE_FILES}/maven-metadata.json")).await?; + let promos: ForgePromos = get_json(client, &format!("{FORGE_FILES}/promotions_slim.json")) + .await + .unwrap_or(ForgePromos { promos: HashMap::new() }); + let recommended = promos.promos.get(&format!("{mc}-recommended")).map(|v| format!("{mc}-{v}")); + let mut list: Vec = all + .get(mc) + .cloned() + .unwrap_or_default() + .into_iter() + .rev() + .map(|v| LoaderVersion { stable: Some(&v) == recommended.as_ref(), version: v }) + .collect(); + // Put the recommended build first. + list.sort_by_key(|v| !v.stable); + list + } + Loader::NeoForge => { + let all: NeoVersions = get_json(client, NEOFORGE_VERSIONS).await?; + all.versions + .into_iter() + .rev() + .filter(|v| neoforge_mc_version(v).as_deref() == Some(mc)) + .map(|v| LoaderVersion { stable: !v.contains("beta") && !v.contains("alpha"), version: v }) + .collect() + } + }) +} + +/// Turn "latest"/"recommended"/empty into a concrete loader version. +pub async fn resolve_loader_version( + client: &reqwest::Client, + loader: Loader, + mc: &str, + requested: Option<&str>, +) -> Result> { + if loader == Loader::Vanilla { + return Ok(None); + } + if let Some(v) = requested.map(str::trim).filter(|v| !v.is_empty() && *v != "latest" && *v != "recommended") { + return Ok(Some(normalize_forge_version(loader, mc, v))); + } + let versions = loader_versions(client, loader, mc).await.context("fetching loader versions")?; + let pick = versions.iter().find(|v| v.stable).or_else(|| versions.first()); + match pick { + Some(v) => Ok(Some(v.version.clone())), + None => bail!("no {} versions available for Minecraft {mc}", loader.as_str()), + } +} + +/// Forge versions are stored as `-` (the Maven version); +/// accept a bare `47.2.0` too. +pub fn normalize_forge_version(loader: Loader, mc: &str, v: &str) -> String { + if loader == Loader::Forge && !v.contains('-') { + format!("{mc}-{v}") + } else { + v.to_string() + } +} + +pub fn forge_installer_url(loader: Loader, version: &str) -> String { + match loader { + Loader::NeoForge => format!("{NEOFORGE_MAVEN}/net/neoforged/neoforge/{version}/neoforge-{version}-installer.jar"), + _ => format!("{FORGE_MAVEN}/net/minecraftforge/forge/{version}/forge-{version}-installer.jar"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn neoforge_mapping() { + assert_eq!(neoforge_mc_version("21.1.77").as_deref(), Some("1.21.1")); + assert_eq!(neoforge_mc_version("20.4.237").as_deref(), Some("1.20.4")); + assert_eq!(neoforge_mc_version("21.0.5-beta").as_deref(), Some("1.21")); + assert_eq!(neoforge_mc_version("26.1.0.3").as_deref(), Some("26.1")); + assert_eq!(neoforge_mc_version("26.1.2.1").as_deref(), Some("26.1.2")); + } + + #[test] + fn forge_urls() { + assert_eq!(normalize_forge_version(Loader::Forge, "1.20.1", "47.2.0"), "1.20.1-47.2.0"); + assert_eq!( + forge_installer_url(Loader::Forge, "1.20.1-47.2.0"), + "https://maven.minecraftforge.net/net/minecraftforge/forge/1.20.1-47.2.0/forge-1.20.1-47.2.0-installer.jar" + ); + } +} diff --git a/crates/core/src/msa.rs b/crates/core/src/msa.rs new file mode 100644 index 0000000..3e2c60b --- /dev/null +++ b/crates/core/src/msa.rs @@ -0,0 +1,231 @@ +//! Microsoft account sign-in (device-code flow → Xbox Live → Minecraft). +//! +//! Requires an Azure app registration ("client id") that Mojang has approved +//! for the Minecraft API. The admin configures it in the panel. + +use anyhow::{anyhow, bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::time::Duration; + +const DEVICE_CODE_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/devicecode"; +const TOKEN_URL: &str = "https://login.microsoftonline.com/consumers/oauth2/v2.0/token"; +const SCOPE: &str = "XboxLive.signin offline_access"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeviceCode { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + #[serde(default = "default_interval")] + pub interval: u64, + #[serde(default)] + pub message: String, +} + +fn default_interval() -> u64 { + 5 +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + access_token: Option, + refresh_token: Option, + error: Option, + error_description: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MsaSession { + pub refresh_token: String, + pub mc_access_token: String, + /// Unix seconds. + pub mc_expires_at: i64, + pub profile: McProfile, + pub xuid: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McProfile { + pub id: String, + pub name: String, + #[serde(default)] + pub skins: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct McSkin { + pub url: String, + #[serde(default)] + pub state: String, + #[serde(default)] + pub variant: Option, +} + +pub async fn start_device_code(client: &reqwest::Client, client_id: &str) -> Result { + let resp = client + .post(DEVICE_CODE_URL) + .form(&[("client_id", client_id), ("scope", SCOPE)]) + .send() + .await?; + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + bail!("Microsoft rejected the sign-in request: {body}"); + } + Ok(resp.json().await?) +} + +/// Poll until the user finishes signing in, then complete the full chain. +pub async fn finish_device_code(client: &reqwest::Client, client_id: &str, code: &DeviceCode) -> Result { + let mut interval = code.interval.max(1); + let deadline = std::time::Instant::now() + Duration::from_secs(code.expires_in); + loop { + if std::time::Instant::now() > deadline { + bail!("the sign-in code expired, please try again"); + } + tokio::time::sleep(Duration::from_secs(interval)).await; + let resp: TokenResponse = client + .post(TOKEN_URL) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("client_id", client_id), + ("device_code", code.device_code.as_str()), + ]) + .send() + .await? + .json() + .await?; + match resp.error.as_deref() { + None => { + let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?; + let refresh = resp.refresh_token.ok_or_else(|| anyhow!("no refresh token"))?; + return complete(client, &access, refresh).await; + } + Some("authorization_pending") => continue, + Some("slow_down") => interval += 5, + Some("authorization_declined") => bail!("sign-in was cancelled"), + Some("expired_token") => bail!("the sign-in code expired, please try again"), + Some(other) => bail!("Microsoft sign-in failed: {other} {}", resp.error_description.unwrap_or_default()), + } + } +} + +/// Refresh a saved session (used before each launch when the Minecraft +/// token is close to expiring). +pub async fn refresh(client: &reqwest::Client, client_id: &str, refresh_token: &str) -> Result { + let resp: TokenResponse = client + .post(TOKEN_URL) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh_token), + ("scope", SCOPE), + ]) + .send() + .await? + .json() + .await?; + if let Some(err) = resp.error { + bail!("your Microsoft session expired ({err}); please sign in again"); + } + let access = resp.access_token.ok_or_else(|| anyhow!("no access token"))?; + let refresh = resp.refresh_token.unwrap_or_else(|| refresh_token.to_string()); + complete(client, &access, refresh).await +} + +#[derive(Deserialize)] +#[serde(rename_all = "PascalCase")] +struct XboxResponse { + token: String, + display_claims: DisplayClaims, +} +#[derive(Deserialize)] +struct DisplayClaims { + xui: Vec, +} +#[derive(Deserialize)] +struct Xui { + uhs: String, + #[serde(default)] + xid: Option, +} + +#[derive(Deserialize)] +struct McLogin { + access_token: String, + expires_in: i64, +} + +async fn complete(client: &reqwest::Client, ms_access: &str, refresh_token: String) -> Result { + // Xbox Live + let xbl: XboxResponse = client + .post("https://user.auth.xboxlive.com/user/authenticate") + .json(&json!({ + "Properties": {"AuthMethod": "RPS", "SiteName": "user.auth.xboxlive.com", "RpsTicket": format!("d={ms_access}")}, + "RelyingParty": "http://auth.xboxlive.com", + "TokenType": "JWT" + })) + .send() + .await? + .error_for_status() + .context("Xbox Live authentication failed")? + .json() + .await?; + + // XSTS + let resp = client + .post("https://xsts.auth.xboxlive.com/xsts/authorize") + .json(&json!({ + "Properties": {"SandboxId": "RETAIL", "UserTokens": [xbl.token]}, + "RelyingParty": "rp://api.minecraftservices.com/", + "TokenType": "JWT" + })) + .send() + .await?; + if resp.status().as_u16() == 401 { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + let msg = match body.get("XErr").and_then(|v| v.as_u64()) { + Some(2148916233) => "this Microsoft account has no Xbox profile yet — sign in once at xbox.com, then try again", + Some(2148916235) => "Xbox Live isn't available in your country", + Some(2148916236) | Some(2148916237) => "this account needs adult verification on xbox.com", + Some(2148916238) => "this is a child account — an adult must add it to a Microsoft family first", + _ => "Xbox Live refused the sign-in", + }; + bail!("{msg}"); + } + let xsts: XboxResponse = resp.error_for_status()?.json().await?; + let claims = xsts.display_claims.xui.first().ok_or_else(|| anyhow!("missing Xbox user hash"))?; + let uhs = claims.uhs.clone(); + let xuid = claims.xid.clone(); + + // Minecraft + let mc: McLogin = client + .post("https://api.minecraftservices.com/authentication/login_with_xbox") + .json(&json!({ "identityToken": format!("XBL3.0 x={uhs};{}", xsts.token) })) + .send() + .await? + .error_for_status() + .context("Minecraft services rejected the Xbox token (is the Azure app approved for Minecraft?)")? + .json() + .await?; + + let resp = client + .get("https://api.minecraftservices.com/minecraft/profile") + .bearer_auth(&mc.access_token) + .send() + .await?; + if resp.status().as_u16() == 404 { + bail!("this Microsoft account doesn't own Minecraft: Java Edition"); + } + let profile: McProfile = resp.error_for_status()?.json().await?; + + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs() as i64; + Ok(MsaSession { + refresh_token, + mc_access_token: mc.access_token, + mc_expires_at: now + mc.expires_in, + profile, + xuid, + }) +} diff --git a/crates/core/src/options.rs b/crates/core/src/options.rs new file mode 100644 index 0000000..0588b59 --- /dev/null +++ b/crates/core/src/options.rs @@ -0,0 +1,54 @@ +//! Writes player keybinds / preferences into Minecraft's `options.txt`. + +use anyhow::Result; +use std::collections::BTreeMap; +use std::path::Path; + +/// Merge `key_:` lines into options.txt, keeping everything +/// else the game has written. Only for 1.13+ (named key codes). +pub fn apply_keybinds(game_dir: &Path, binds: &BTreeMap) -> Result<()> { + let mut values: BTreeMap = binds.iter().map(|(k, v)| (format!("key_{k}"), v.clone())).collect(); + apply(game_dir, &mut values) +} + +/// Merge arbitrary `name:value` options. +pub fn apply(game_dir: &Path, values: &mut BTreeMap) -> Result<()> { + if values.is_empty() { + return Ok(()); + } + let path = game_dir.join("options.txt"); + let existing = std::fs::read_to_string(&path).unwrap_or_default(); + let mut lines: Vec = Vec::new(); + for line in existing.lines() { + match line.split_once(':') { + Some((k, _)) if values.contains_key(k) => { + let v = values.remove(k).unwrap(); + lines.push(format!("{k}:{v}")); + } + _ => lines.push(line.to_string()), + } + } + for (k, v) in values.iter() { + lines.push(format!("{k}:{v}")); + } + std::fs::create_dir_all(game_dir)?; + std::fs::write(path, lines.join("\n") + "\n")?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merges_keybinds() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("options.txt"), "version:3465\nkey_key.forward:key.keyboard.w\nfov:0.0\n").unwrap(); + let mut binds = BTreeMap::new(); + binds.insert("key.forward".to_string(), "key.keyboard.up".to_string()); + binds.insert("key.sprint".to_string(), "key.keyboard.left.control".to_string()); + apply_keybinds(dir.path(), &binds).unwrap(); + let out = std::fs::read_to_string(dir.path().join("options.txt")).unwrap(); + assert_eq!(out, "version:3465\nkey_key.forward:key.keyboard.up\nfov:0.0\nkey_key.sprint:key.keyboard.left.control\n"); + } +} diff --git a/crates/core/src/paths.rs b/crates/core/src/paths.rs new file mode 100644 index 0000000..17d18fc --- /dev/null +++ b/crates/core/src/paths.rs @@ -0,0 +1,99 @@ +use std::path::{Path, PathBuf}; + +/// On-disk layout. Versions, libraries, assets and Java runtimes are shared +/// between instances so a second instance of the same version costs almost +/// no disk space. +#[derive(Debug, Clone)] +pub struct Layout { + pub root: PathBuf, +} + +impl Layout { + pub fn new(root: impl Into) -> Self { + Self { root: root.into() } + } + pub fn libraries(&self) -> PathBuf { + self.root.join("libraries") + } + pub fn versions(&self) -> PathBuf { + self.root.join("versions") + } + pub fn version_dir(&self, id: &str) -> PathBuf { + self.versions().join(id) + } + pub fn version_json(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.json")) + } + pub fn version_jar(&self, id: &str) -> PathBuf { + self.version_dir(id).join(format!("{id}.jar")) + } + pub fn assets(&self) -> PathBuf { + self.root.join("assets") + } + pub fn runtimes(&self) -> PathBuf { + self.root.join("runtimes") + } + pub fn natives(&self, version_id: &str) -> PathBuf { + self.root.join("natives").join(version_id) + } + pub fn instances(&self) -> PathBuf { + self.root.join("instances") + } + pub fn instance_dir(&self, id: &str) -> PathBuf { + self.instances().join(sanitize_id(id)) + } + pub fn cache(&self) -> PathBuf { + self.root.join("cache") + } +} + +/// Keep instance ids filesystem-safe no matter what the panel sends. +pub fn sanitize_id(id: &str) -> String { + let s: String = id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' { c } else { '_' }) + .collect(); + let s = s.trim_matches('.').to_string(); + if s.is_empty() { "_".into() } else { s } +} + +/// Join a server-provided relative path onto `base`, refusing anything that +/// could escape it (`..`, absolute paths, drive letters). +pub fn safe_join(base: &Path, rel: &str) -> Option { + let rel = rel.replace('\\', "/"); + if rel.starts_with('/') || rel.contains(':') { + return None; + } + let mut out = base.to_path_buf(); + for part in rel.split('/') { + match part { + "" | "." => continue, + ".." => return None, + p => out.push(p), + } + } + if out == base { None } else { Some(out) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn safe_join_blocks_traversal() { + let base = Path::new("/game"); + assert_eq!(safe_join(base, "mods/a.jar"), Some(PathBuf::from("/game/mods/a.jar"))); + assert_eq!(safe_join(base, "config\\x.toml"), Some(PathBuf::from("/game/config/x.toml"))); + assert!(safe_join(base, "../evil").is_none()); + assert!(safe_join(base, "mods/../../evil").is_none()); + assert!(safe_join(base, "/etc/passwd").is_none()); + assert!(safe_join(base, "C:/Windows").is_none()); + assert!(safe_join(base, "").is_none()); + } + + #[test] + fn ids_are_sanitized() { + assert_eq!(sanitize_id("my pack/../x"), "my_pack_.._x"); + assert_eq!(sanitize_id(".."), "_"); + } +} diff --git a/crates/core/src/ping.rs b/crates/core/src/ping.rs new file mode 100644 index 0000000..d5f4c0e --- /dev/null +++ b/crates/core/src/ping.rs @@ -0,0 +1,264 @@ +//! Minecraft Server List Ping: online players, MOTD, version, latency. + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::time::{Duration, Instant}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpStream; + +#[derive(Debug, Clone, Serialize, Default)] +pub struct ServerStatus { + pub online: bool, + pub version: String, + pub protocol: i64, + pub players_online: i64, + pub players_max: i64, + pub sample: Vec, + /// MOTD using legacy `§` colour codes (rendered by the UI). + pub motd: String, + /// `data:image/png;base64,...` + pub favicon: Option, + pub latency_ms: u64, +} + +fn write_varint(buf: &mut Vec, mut value: i32) { + loop { + let mut byte = (value & 0x7f) as u8; + value = ((value as u32) >> 7) as i32; + if value != 0 { + byte |= 0x80; + } + buf.push(byte); + if value == 0 { + break; + } + } +} + +async fn read_varint(stream: &mut TcpStream) -> Result { + let mut result = 0i32; + for i in 0..5 { + let byte = stream.read_u8().await?; + result |= ((byte & 0x7f) as i32) << (7 * i); + if byte & 0x80 == 0 { + return Ok(result); + } + } + bail!("varint too long") +} + +fn packet(id: i32, body: &[u8]) -> Vec { + let mut inner = Vec::new(); + write_varint(&mut inner, id); + inner.extend_from_slice(body); + let mut out = Vec::new(); + write_varint(&mut out, inner.len() as i32); + out.extend(inner); + out +} + +#[derive(Deserialize)] +struct RawStatus { + #[serde(default)] + version: Option, + #[serde(default)] + players: Option, + #[serde(default)] + description: Option, + #[serde(default)] + favicon: Option, +} +#[derive(Deserialize)] +struct RawVersion { + #[serde(default)] + name: String, + #[serde(default)] + protocol: i64, +} +#[derive(Deserialize)] +struct RawPlayers { + #[serde(default)] + max: i64, + #[serde(default)] + online: i64, + #[serde(default)] + sample: Vec, +} +#[derive(Deserialize)] +struct RawSample { + #[serde(default)] + name: String, +} + +fn color_code(name: &str) -> Option { + Some(match name { + "black" => '0', + "dark_blue" => '1', + "dark_green" => '2', + "dark_aqua" => '3', + "dark_red" => '4', + "dark_purple" => '5', + "gold" => '6', + "gray" => '7', + "dark_gray" => '8', + "blue" => '9', + "green" => 'a', + "aqua" => 'b', + "red" => 'c', + "light_purple" => 'd', + "yellow" => 'e', + "white" => 'f', + _ => return None, + }) +} + +/// Flatten a chat component into a `§`-coded string. +pub fn flatten_chat(value: &serde_json::Value) -> String { + let mut out = String::new(); + fn walk(v: &serde_json::Value, out: &mut String) { + match v { + serde_json::Value::String(s) => out.push_str(s), + serde_json::Value::Array(items) => items.iter().for_each(|i| walk(i, out)), + serde_json::Value::Object(map) => { + if let Some(c) = map.get("color").and_then(|c| c.as_str()) { + if let Some(code) = color_code(c) { + out.push('§'); + out.push(code); + } else if c.starts_with('#') && c.len() == 7 { + // Hex colours: emit a custom marker the UI understands. + out.push_str("§#"); + out.push_str(&c[1..]); + } + } + for (key, code) in [("bold", 'l'), ("italic", 'o'), ("underlined", 'n'), ("strikethrough", 'm')] { + if map.get(key).and_then(|b| b.as_bool()) == Some(true) { + out.push('§'); + out.push(code); + } + } + if let Some(t) = map.get("text").and_then(|t| t.as_str()) { + out.push_str(t); + } + if let Some(extra) = map.get("extra") { + walk(extra, out); + } + if map.contains_key("color") { + out.push_str("§r"); + } + } + _ => {} + } + } + walk(value, &mut out); + out +} + +pub async fn ping(host: &str, port: u16) -> Result { + tokio::time::timeout(Duration::from_secs(5), ping_inner(host, port)).await.context("server did not respond in time")? +} + +async fn ping_inner(host: &str, port: u16) -> Result { + let started = Instant::now(); + let mut stream = TcpStream::connect((host, port)).await.with_context(|| format!("connecting to {host}:{port}"))?; + stream.set_nodelay(true).ok(); + let connect_ms = started.elapsed().as_millis() as u64; + + let mut body = Vec::new(); + write_varint(&mut body, -1); // protocol: "whatever you are" + write_varint(&mut body, host.len() as i32); + body.extend_from_slice(host.as_bytes()); + body.extend_from_slice(&port.to_be_bytes()); + write_varint(&mut body, 1); // next state: status + stream.write_all(&packet(0x00, &body)).await?; + stream.write_all(&packet(0x00, &[])).await?; + + let _len = read_varint(&mut stream).await?; + let id = read_varint(&mut stream).await?; + if id != 0x00 { + bail!("unexpected packet {id}"); + } + let str_len = read_varint(&mut stream).await? as usize; + if str_len > 1 << 21 { + bail!("status response too large"); + } + let mut json = vec![0u8; str_len]; + stream.read_exact(&mut json).await?; + + // Latency via ping/pong (fallback: TCP connect time). + let t = Instant::now(); + let mut latency_ms = connect_ms; + if stream.write_all(&packet(0x01, &42i64.to_be_bytes())).await.is_ok() { + if let Ok(Ok(_)) = tokio::time::timeout(Duration::from_secs(2), async { + let _ = read_varint(&mut stream).await?; + let _ = read_varint(&mut stream).await?; + stream.read_i64().await.map_err(anyhow::Error::from) + }) + .await + { + latency_ms = t.elapsed().as_millis() as u64; + } + } + + let raw: RawStatus = serde_json::from_slice(&json).context("invalid status JSON")?; + let players = raw.players.unwrap_or(RawPlayers { max: 0, online: 0, sample: vec![] }); + let version = raw.version.unwrap_or(RawVersion { name: String::new(), protocol: 0 }); + Ok(ServerStatus { + online: true, + version: version.name, + protocol: version.protocol, + players_online: players.online, + players_max: players.max, + sample: players.sample.into_iter().map(|s| s.name).filter(|n| !n.is_empty()).take(12).collect(), + motd: raw.description.as_ref().map(flatten_chat).unwrap_or_default(), + favicon: raw.favicon, + latency_ms, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn varints() { + let mut b = Vec::new(); + write_varint(&mut b, 300); + assert_eq!(b, vec![0xac, 0x02]); + let mut b = Vec::new(); + write_varint(&mut b, -1); + assert_eq!(b, vec![0xff, 0xff, 0xff, 0xff, 0x0f]); + } + + #[test] + fn flattens_motd() { + let v: serde_json::Value = serde_json::from_str(r#"{"text":"","extra":[{"text":"Scope","color":"aqua","bold":true},{"text":"Net"}]}"#).unwrap(); + assert_eq!(flatten_chat(&v), "§b§lScope§rNet"); + assert_eq!(flatten_chat(&serde_json::json!("§aHello")), "§aHello"); + } + + #[tokio::test] + async fn pings_a_fake_server() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + // read handshake + request (we don't validate them here) + let mut buf = [0u8; 256]; + let _ = sock.read(&mut buf).await.unwrap(); + let json = br#"{"version":{"name":"1.21.1","protocol":767},"players":{"max":100,"online":7,"sample":[{"name":"Steve","id":"x"}]},"description":"A server"}"#; + let mut body = Vec::new(); + write_varint(&mut body, json.len() as i32); + body.extend_from_slice(json); + sock.write_all(&packet(0, &body)).await.unwrap(); + let mut ping = [0u8; 64]; + let _ = sock.read(&mut ping).await; + sock.write_all(&packet(1, &42i64.to_be_bytes())).await.ok(); + }); + let s = ping("127.0.0.1", port).await.unwrap(); + assert!(s.online); + assert_eq!(s.players_online, 7); + assert_eq!(s.version, "1.21.1"); + assert_eq!(s.sample, vec!["Steve"]); + assert_eq!(s.motd, "A server"); + } +} diff --git a/crates/core/src/progress.rs b/crates/core/src/progress.rs new file mode 100644 index 0000000..189bf69 --- /dev/null +++ b/crates/core/src/progress.rs @@ -0,0 +1,33 @@ +use serde::Serialize; +use std::sync::Arc; + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum Stage { + Preparing, + Java, + Game, + Loader, + Files, + Launching, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Event { + Stage { stage: Stage, label: String }, + Progress { stage: Stage, done: u64, total: u64, files_done: u32, files_total: u32 }, + Log { line: String }, +} + +/// Callback the engine uses to report progress. Must be cheap: it is called +/// from download tasks (throttled to ~10 Hz). +pub type Reporter = Arc; + +pub fn noop() -> Reporter { + Arc::new(|_| {}) +} + +pub fn stage(r: &Reporter, stage: Stage, label: impl Into) { + r(Event::Stage { stage, label: label.into() }); +} diff --git a/crates/core/src/rules.rs b/crates/core/src/rules.rs new file mode 100644 index 0000000..5015c7e --- /dev/null +++ b/crates/core/src/rules.rs @@ -0,0 +1,147 @@ +//! Mojang's `rules` mini-language used by libraries and arguments. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Rule { + pub action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub os: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub features: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct OsRule { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, +} + +#[derive(Debug, Clone)] +pub struct Env { + /// "windows" | "osx" | "linux" + pub os: &'static str, + /// "x86_64" | "x86" | "arm64" + pub arch: &'static str, + pub features: HashMap, +} + +impl Env { + pub fn current() -> Self { + let os = if cfg!(windows) { + "windows" + } else if cfg!(target_os = "macos") { + "osx" + } else { + "linux" + }; + let arch = if cfg!(target_arch = "x86") { + "x86" + } else if cfg!(target_arch = "aarch64") { + "arm64" + } else { + "x86_64" + }; + Self { os, arch, features: HashMap::new() } + } + + pub fn with_feature(mut self, name: &str, on: bool) -> Self { + self.features.insert(name.to_string(), on); + self + } + + /// `${arch}` substitution used in legacy native classifiers. + pub fn bits(&self) -> &'static str { + if self.arch == "x86" { "32" } else { "64" } + } +} + +fn os_matches(os: &OsRule, env: &Env) -> bool { + if let Some(name) = &os.name { + if name != env.os { + return false; + } + } + if let Some(arch) = &os.arch { + // Mojang only ever uses "x86" here, meaning 32-bit. + let matches = match arch.as_str() { + "x86" => env.arch == "x86", + "x86_64" | "amd64" => env.arch == "x86_64", + "arm64" | "aarch64" => env.arch == "arm64", + _ => false, + }; + if !matches { + return false; + } + } + // `version` is a regex against the OS version (e.g. "^10\\."). We can't + // evaluate it reliably, and every use is a harmless JVM hint, so treat + // it as matching. + true +} + +pub fn rule_matches(rule: &Rule, env: &Env) -> bool { + if let Some(os) = &rule.os { + if !os_matches(os, env) { + return false; + } + } + if let Some(features) = &rule.features { + for (name, want) in features { + if env.features.get(name).copied().unwrap_or(false) != *want { + return false; + } + } + } + true +} + +/// No rules → allowed. Otherwise start disallowed and let the last matching +/// rule decide. +pub fn allowed(rules: &[Rule], env: &Env) -> bool { + if rules.is_empty() { + return true; + } + let mut allow = false; + for rule in rules { + if rule_matches(rule, env) { + allow = rule.action == "allow"; + } + } + allow +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(os: &'static str) -> Env { + Env { os, arch: "x86_64", features: HashMap::new() } + } + + #[test] + fn evaluates_rules() { + let rules: Vec = serde_json::from_str( + r#"[{"action":"allow"},{"action":"disallow","os":{"name":"osx"}}]"#, + ) + .unwrap(); + assert!(allowed(&rules, &env("windows"))); + assert!(!allowed(&rules, &env("osx"))); + + let only_win: Vec = serde_json::from_str(r#"[{"action":"allow","os":{"name":"windows"}}]"#).unwrap(); + assert!(allowed(&only_win, &env("windows"))); + assert!(!allowed(&only_win, &env("linux"))); + + let x86: Vec = serde_json::from_str(r#"[{"action":"allow","os":{"arch":"x86"}}]"#).unwrap(); + assert!(!allowed(&x86, &env("windows"))); + + let feat: Vec = serde_json::from_str(r#"[{"action":"allow","features":{"has_custom_resolution":true}}]"#).unwrap(); + assert!(!allowed(&feat, &env("windows"))); + assert!(allowed(&feat, &env("windows").with_feature("has_custom_resolution", true))); + } +} diff --git a/crates/core/src/servers_dat.rs b/crates/core/src/servers_dat.rs new file mode 100644 index 0000000..5bb4e9b --- /dev/null +++ b/crates/core/src/servers_dat.rs @@ -0,0 +1,84 @@ +//! Adds the instance's server to Minecraft's multiplayer list. + +use anyhow::Result; +use fastnbt::Value; +use std::collections::HashMap; +use std::path::Path; + +/// Insert (or update) `name`/`address` at the top of `servers.dat`, +/// preserving every other entry the player has added. +pub fn upsert(game_dir: &Path, name: &str, address: &str) -> Result<()> { + let path = game_dir.join("servers.dat"); + let mut root: HashMap = match std::fs::read(&path) { + Ok(bytes) => match fastnbt::from_bytes::(&bytes) { + Ok(Value::Compound(map)) => map, + _ => HashMap::new(), + }, + Err(_) => HashMap::new(), + }; + + let mut servers = match root.remove("servers") { + Some(Value::List(list)) => list, + _ => Vec::new(), + }; + + let same = |v: &Value| match v { + Value::Compound(m) => matches!(m.get("ip"), Some(Value::String(ip)) if ip.eq_ignore_ascii_case(address)), + _ => false, + }; + let existing = servers.iter().position(same); + let mut entry = match existing { + Some(i) => match servers.remove(i) { + Value::Compound(m) => m, + _ => HashMap::new(), + }, + None => HashMap::new(), + }; + entry.insert("name".into(), Value::String(name.to_string())); + entry.insert("ip".into(), Value::String(address.to_string())); + entry.entry("acceptTextures".into()).or_insert(Value::Byte(1)); + servers.insert(0, Value::Compound(entry)); + + root.insert("servers".into(), Value::List(servers)); + std::fs::create_dir_all(game_dir)?; + std::fs::write(&path, fastnbt::to_bytes(&Value::Compound(root))?)?; + Ok(()) +} + +pub fn format_address(host: &str, port: u16) -> String { + if port == 25565 { host.to_string() } else { format!("{host}:{port}") } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn names(dir: &Path) -> Vec<(String, String)> { + let v: Value = fastnbt::from_bytes(&std::fs::read(dir.join("servers.dat")).unwrap()).unwrap(); + let Value::Compound(root) = v else { panic!() }; + let Some(Value::List(list)) = root.get("servers") else { panic!() }; + list.iter() + .map(|s| match s { + Value::Compound(m) => match (m.get("name"), m.get("ip")) { + (Some(Value::String(n)), Some(Value::String(i))) => (n.clone(), i.clone()), + _ => panic!(), + }, + _ => panic!(), + }) + .collect() + } + + #[test] + fn upserts_without_losing_player_servers() { + let dir = tempfile::tempdir().unwrap(); + upsert(dir.path(), "Friend", "friend.net").unwrap(); + upsert(dir.path(), "ScopeNet", "play.scopenet.gg").unwrap(); + upsert(dir.path(), "ScopeNet SMP", "play.scopenet.gg").unwrap(); + assert_eq!( + names(dir.path()), + vec![("ScopeNet SMP".into(), "play.scopenet.gg".into()), ("Friend".into(), "friend.net".into())] + ); + assert_eq!(format_address("a.b", 25565), "a.b"); + assert_eq!(format_address("a.b", 25570), "a.b:25570"); + } +} diff --git a/crates/core/src/sync.rs b/crates/core/src/sync.rs new file mode 100644 index 0000000..a876adb --- /dev/null +++ b/crates/core/src/sync.rs @@ -0,0 +1,134 @@ +//! Keeps an instance's mods/configs in sync with what the admin published. +//! +//! Files the panel sends are "managed": they get updated and removed when +//! the admin changes the instance. Anything the player adds themselves is +//! never touched. + +use crate::http::{self, Download}; +use crate::paths::safe_join; +use crate::progress::{self, Reporter, Stage}; +use anyhow::Result; +use scopenet_shared::FileEntry; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct SyncState { + pub revision: i64, + pub managed: Vec, +} + +fn state_path(game_dir: &Path) -> PathBuf { + game_dir.join(".scopenet").join("state.json") +} + +pub fn load_state(game_dir: &Path) -> SyncState { + std::fs::read(state_path(game_dir)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() +} + +fn save_state(game_dir: &Path, state: &SyncState) -> Result<()> { + let path = state_path(game_dir); + std::fs::create_dir_all(path.parent().unwrap())?; + std::fs::write(path, serde_json::to_vec_pretty(state)?)?; + Ok(()) +} + +pub fn resolve_url(base: &str, url: &str) -> String { + if url.starts_with("http://") || url.starts_with("https://") { + url.to_string() + } else { + format!("{}/{}", base.trim_end_matches('/'), url.trim_start_matches('/')) + } +} + +#[derive(Debug, Default, Serialize, Clone)] +pub struct SyncReport { + pub downloaded: usize, + pub removed: usize, +} + +/// Sync `files` into `game_dir`. +/// +/// Fast path: when the revision hasn't changed we only check that files +/// exist, so launching an up-to-date instance costs a few `stat` calls. +#[allow(clippy::too_many_arguments)] +pub async fn sync( + client: &reqwest::Client, + panel_base: &str, + game_dir: &Path, + revision: i64, + files: &[FileEntry], + concurrency: usize, + deep: bool, + reporter: &Reporter, +) -> Result { + std::fs::create_dir_all(game_dir)?; + let state = load_state(game_dir); + let changed = state.revision != revision || deep; + progress::stage(reporter, Stage::Files, if changed { "Syncing instance files" } else { "Checking instance files" }); + + let mut wanted = HashSet::new(); + let mut downloads = Vec::new(); + for f in files { + let Some(dest) = safe_join(game_dir, &f.path) else { + tracing::warn!("skipping unsafe path from panel: {}", f.path); + continue; + }; + wanted.insert(f.path.replace('\\', "/")); + let ok = if changed { + http::file_ok(&dest, Some(&f.sha1), Some(f.size), true) + } else { + dest.exists() + }; + if !ok { + downloads.push(Download::new(resolve_url(panel_base, &f.url), dest, Some(f.sha1.clone()), Some(f.size))); + } + } + let downloaded = downloads.len(); + http::download_all(client, downloads, concurrency, Stage::Files, reporter).await?; + + let mut removed = 0; + for old in &state.managed { + if !wanted.contains(old) { + if let Some(path) = safe_join(game_dir, old) { + if std::fs::remove_file(&path).is_ok() { + removed += 1; + } + } + } + } + + let mut managed: Vec = wanted.into_iter().collect(); + managed.sort(); + save_state(game_dir, &SyncState { revision, managed })?; + Ok(SyncReport { downloaded, removed }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn urls() { + assert_eq!(resolve_url("https://panel.example/", "/files/a/mods/x.jar"), "https://panel.example/files/a/mods/x.jar"); + assert_eq!(resolve_url("https://panel.example", "https://cdn.modrinth.com/x.jar"), "https://cdn.modrinth.com/x.jar"); + } + + #[tokio::test] + async fn removes_files_the_admin_dropped() { + let dir = tempfile::tempdir().unwrap(); + let game = dir.path(); + std::fs::create_dir_all(game.join("mods")).unwrap(); + std::fs::write(game.join("mods/old.jar"), b"old").unwrap(); + std::fs::write(game.join("mods/mine.jar"), b"player added").unwrap(); + save_state(game, &SyncState { revision: 1, managed: vec!["mods/old.jar".into()] }).unwrap(); + + let client = crate::http::client(); + let report = sync(&client, "http://unused", game, 2, &[], 4, false, &crate::progress::noop()).await.unwrap(); + assert_eq!(report.removed, 1); + assert!(!game.join("mods/old.jar").exists()); + assert!(game.join("mods/mine.jar").exists(), "player files are never touched"); + assert_eq!(load_state(game).revision, 2); + } +} diff --git a/crates/core/src/sys.rs b/crates/core/src/sys.rs new file mode 100644 index 0000000..b2f56b1 --- /dev/null +++ b/crates/core/src/sys.rs @@ -0,0 +1,41 @@ +//! Tiny system probes (kept dependency-free to stay lightweight). + +/// Total physical memory in MiB (best effort; 8 GiB if unknown). +pub fn total_memory_mb() -> u64 { + imp().unwrap_or(8192) +} + +#[cfg(windows)] +fn imp() -> Option { + use windows_sys::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX}; + let mut status: MEMORYSTATUSEX = unsafe { std::mem::zeroed() }; + status.dwLength = std::mem::size_of::() as u32; + let ok = unsafe { GlobalMemoryStatusEx(&mut status) }; + (ok != 0).then(|| status.ullTotalPhys / 1024 / 1024) +} + +#[cfg(target_os = "linux")] +fn imp() -> Option { + let info = std::fs::read_to_string("/proc/meminfo").ok()?; + let line = info.lines().find(|l| l.starts_with("MemTotal:"))?; + let kb: u64 = line.split_whitespace().nth(1)?.parse().ok()?; + Some(kb / 1024) +} + +#[cfg(not(any(windows, target_os = "linux")))] +fn imp() -> Option { + None +} + +/// Recursively sum file sizes under `path`. +pub fn dir_size(path: &std::path::Path) -> u64 { + let Ok(entries) = std::fs::read_dir(path) else { return 0 }; + entries + .filter_map(|e| e.ok()) + .map(|e| match e.file_type() { + Ok(t) if t.is_dir() => dir_size(&e.path()), + Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0), + _ => 0, + }) + .sum() +} diff --git a/crates/core/src/version.rs b/crates/core/src/version.rs new file mode 100644 index 0000000..31dd4b6 --- /dev/null +++ b/crates/core/src/version.rs @@ -0,0 +1,293 @@ +//! Mojang version JSON (also used by Fabric/Quilt/Forge profiles). + +use crate::rules::Rule; +use serde::{Deserialize, Serialize}; +use std::collections::{HashMap, HashSet}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct VersionJson { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub inherits_from: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub jar: Option, + #[serde(default, rename = "type", skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub main_class: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub minecraft_arguments: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arguments: Option, + #[serde(default)] + pub libraries: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub asset_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub assets: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub java_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub logging: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub release_time: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Arguments { + #[serde(default)] + pub game: Vec, + #[serde(default)] + pub jvm: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Arg { + Plain(String), + Ruled { rules: Vec, value: ArgValue }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum ArgValue { + One(String), + Many(Vec), +} + +impl ArgValue { + pub fn values(&self) -> Vec { + match self { + ArgValue::One(s) => vec![s.clone()], + ArgValue::Many(v) => v.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Library { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloads: Option, + /// Maven repository base (Fabric/Quilt/legacy Forge style). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rules: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub natives: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub extract: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LibraryDownloads { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub classifiers: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Artifact { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha1: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, + #[serde(default)] + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Extract { + #[serde(default)] + pub exclude: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct AssetIndexRef { + pub id: String, + #[serde(default)] + pub sha1: Option, + #[serde(default)] + pub size: Option, + #[serde(default)] + pub total_size: Option, + pub url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct VersionDownloads { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct JavaVersion { + #[serde(default)] + pub component: String, + #[serde(default)] + pub major_version: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Logging { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LoggingClient { + pub argument: String, + pub file: LogFile, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct LogFile { + pub id: String, + #[serde(default)] + pub sha1: Option, + #[serde(default)] + pub size: Option, + pub url: String, +} + +impl VersionJson { + /// Java major version this version wants (8 for anything old). + pub fn java_major(&self) -> u32 { + self.java_version.as_ref().map(|j| j.major_version).filter(|m| *m > 0).unwrap_or(8) + } + + pub fn java_component(&self) -> String { + self.java_version + .as_ref() + .map(|j| j.component.clone()) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "jre-legacy".into()) + } + + /// True for 1.13+ style argument lists. + pub fn is_modern(&self) -> bool { + self.arguments.is_some() + } + + /// Does this version understand `--quickPlayMultiplayer` (1.20+)? + pub fn supports_quick_play(&self) -> bool { + self.arguments + .as_ref() + .map(|a| { + a.game.iter().any(|arg| match arg { + Arg::Plain(s) => s.contains("quickPlayMultiplayer"), + Arg::Ruled { value, .. } => value.values().iter().any(|v| v.contains("quickPlayMultiplayer")), + }) + }) + .unwrap_or(false) + } +} + +fn lib_key(name: &str) -> String { + crate::maven::Coord::parse(name).map(|c| c.key()).unwrap_or_else(|| name.to_string()) +} + +/// Merge a child profile (Fabric/Forge/...) onto its parent (vanilla). +/// Child values win; argument lists are concatenated; libraries are +/// de-duplicated with the child's copy taking precedence. +pub fn merge(parent: VersionJson, child: VersionJson) -> VersionJson { + let mut seen = HashSet::new(); + let mut libraries = Vec::new(); + for lib in child.libraries.into_iter().chain(parent.libraries) { + // Natives-map libraries (LWJGL 2) share a key with their jar entry. + let key = if lib.natives.is_some() { format!("{}#natives", lib_key(&lib.name)) } else { lib_key(&lib.name) }; + if seen.insert(key) { + libraries.push(lib); + } + } + + let arguments = match (parent.arguments, child.arguments) { + (Some(mut p), Some(c)) => { + p.game.extend(c.game); + p.jvm.extend(c.jvm); + Some(p) + } + (p, c) => c.or(p), + }; + + VersionJson { + id: child.id, + inherits_from: None, + jar: child.jar.or(parent.jar), + kind: child.kind.or(parent.kind), + main_class: child.main_class.or(parent.main_class), + minecraft_arguments: child.minecraft_arguments.or(parent.minecraft_arguments), + arguments, + libraries, + asset_index: child.asset_index.or(parent.asset_index), + assets: child.assets.or(parent.assets), + downloads: child.downloads.or(parent.downloads), + java_version: child.java_version.or(parent.java_version), + logging: child.logging.or(parent.logging), + release_time: parent.release_time.or(child.release_time), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + pub const VANILLA: &str = r#"{ + "id": "1.20.1", "type": "release", "mainClass": "net.minecraft.client.main.Main", + "javaVersion": {"component": "java-runtime-gamma", "majorVersion": 17}, + "assetIndex": {"id": "5", "sha1": "abc", "size": 1, "totalSize": 2, "url": "https://x/5.json"}, + "assets": "5", + "downloads": {"client": {"sha1": "c", "size": 3, "url": "https://x/client.jar"}}, + "arguments": { + "game": ["--username", "${auth_player_name}", + {"rules": [{"action": "allow", "features": {"has_custom_resolution": true}}], "value": ["--width", "${resolution_width}", "--height", "${resolution_height}"]}, + {"rules": [{"action": "allow", "features": {"is_quick_play_multiplayer": true}}], "value": ["--quickPlayMultiplayer", "${quickPlayMultiplayer}"]}], + "jvm": [{"rules": [{"action": "allow", "os": {"name": "osx"}}], "value": ["-XstartOnFirstThread"]}, "-cp", "${classpath}"] + }, + "libraries": [ + {"name": "org.ow2.asm:asm:9.3", "downloads": {"artifact": {"path": "org/ow2/asm/asm/9.3/asm-9.3.jar", "sha1": "a", "size": 1, "url": "https://libraries.minecraft.net/org/ow2/asm/asm/9.3/asm-9.3.jar"}}}, + {"name": "org.lwjgl:lwjgl:3.3.1:natives-windows", "downloads": {"artifact": {"path": "org/lwjgl/lwjgl/3.3.1/lwjgl-3.3.1-natives-windows.jar", "sha1": "n", "size": 1, "url": "https://libraries.minecraft.net/n.jar"}}, "rules": [{"action": "allow", "os": {"name": "windows"}}]} + ] + }"#; + + const FABRIC: &str = r#"{ + "id": "fabric-loader-0.16.9-1.20.1", "inheritsFrom": "1.20.1", "mainClass": "net.fabricmc.loader.impl.launch.knot.KnotClient", + "arguments": {"game": [], "jvm": ["-DFabricMcEmu= net.minecraft.client.main.Main "]}, + "libraries": [ + {"name": "org.ow2.asm:asm:9.7.1", "url": "https://maven.fabricmc.net/", "sha1": "f"}, + {"name": "net.fabricmc:fabric-loader:0.16.9", "url": "https://maven.fabricmc.net/"} + ] + }"#; + + #[test] + fn merges_fabric_onto_vanilla() { + let parent: VersionJson = serde_json::from_str(VANILLA).unwrap(); + let child: VersionJson = serde_json::from_str(FABRIC).unwrap(); + assert!(parent.supports_quick_play()); + let merged = merge(parent, child); + assert_eq!(merged.id, "fabric-loader-0.16.9-1.20.1"); + assert_eq!(merged.main_class.as_deref(), Some("net.fabricmc.loader.impl.launch.knot.KnotClient")); + assert_eq!(merged.java_major(), 17); + // asm de-duplicated, Fabric's newer copy wins + let asm: Vec<_> = merged.libraries.iter().filter(|l| l.name.starts_with("org.ow2.asm:asm:")).collect(); + assert_eq!(asm.len(), 1); + assert_eq!(asm[0].name, "org.ow2.asm:asm:9.7.1"); + assert_eq!(merged.libraries.len(), 3); + let args = merged.arguments.unwrap(); + assert_eq!(args.jvm.len(), 4); + assert!(merged.asset_index.is_some()); + } +} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml new file mode 100644 index 0000000..d07fd5f --- /dev/null +++ b/crates/shared/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "scopenet-shared" +description = "Types shared between the ScopeNet launcher and admin panel" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +serde.workspace = true +serde_json.workspace = true +md-5.workspace = true diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs new file mode 100644 index 0000000..6d7b001 --- /dev/null +++ b/crates/shared/src/lib.rs @@ -0,0 +1,403 @@ +//! Wire types shared by the ScopeNet launcher and the admin panel. +//! +//! Everything the panel sends to the launcher is described here, so both +//! sides always agree on the JSON shape. + +use serde::{Deserialize, Serialize}; + +pub const API_VERSION: u32 = 1; + +// --------------------------------------------------------------------------- +// Branding / theming +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Branding { + pub name: String, + pub tagline: String, + pub logo_url: Option, + pub icon_url: Option, + pub background: Background, + pub colors: Palette, + /// One of the fonts bundled in the launcher: "Inter", "Outfit", + /// "Space Grotesk", "Plus Jakarta Sans". + pub font: String, + /// Corner radius in px used for cards and buttons. + pub radius: u8, + /// Frosted-glass surfaces (backdrop blur). Disable for very old GPUs. + pub glass: bool, + /// Shown on the Minecraft title screen / F3 (the `version_type` arg). + pub version_label: String, + pub news: Vec, + pub links: Vec, + pub features: Features, + /// Raw CSS appended to the launcher stylesheet (power users). + pub custom_css: String, +} + +impl Default for Branding { + fn default() -> Self { + Self { + name: "ScopeNet".into(), + tagline: "Your worlds, one click away.".into(), + logo_url: None, + icon_url: None, + background: Background::default(), + colors: Palette::default(), + font: "Outfit".into(), + radius: 14, + glass: true, + version_label: "ScopeNet".into(), + news: vec![NewsItem { + id: "welcome".into(), + title: "Welcome to ScopeNet".into(), + body: "Your admin can customise this launcher from the panel: colours, news, instances and more.".into(), + image_url: None, + link: None, + date: None, + pinned: true, + tag: Some("News".into()), + }], + links: vec![], + features: Features::default(), + custom_css: String::new(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Background { + /// "gradient" | "image" | "video" + pub kind: String, + pub url: Option, + /// Blur in px applied to image/video backgrounds. + pub blur: u8, + /// Darkening overlay 0-100. + pub dim: u8, +} + +impl Default for Background { + fn default() -> Self { + Self { kind: "gradient".into(), url: None, blur: 0, dim: 45 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Palette { + pub accent: String, + pub accent_2: String, + pub background: String, + pub surface: String, + pub text: String, + pub muted: String, + pub success: String, + pub danger: String, +} + +impl Default for Palette { + fn default() -> Self { + Self { + accent: "#7c5cff".into(), + accent_2: "#22d3ee".into(), + background: "#0b0d17".into(), + surface: "#141828".into(), + text: "#eef0ff".into(), + muted: "#8b90b3".into(), + success: "#34d399".into(), + danger: "#f43f5e".into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct NewsItem { + pub id: String, + pub title: String, + pub body: String, + pub image_url: Option, + pub link: Option, + /// ISO date (YYYY-MM-DD) + pub date: Option, + pub pinned: bool, + pub tag: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct SocialLink { + pub label: String, + pub url: String, + /// "discord" | "website" | "youtube" | "twitter" | "github" | "store" | "twitch" + pub icon: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Features { + pub news: bool, + pub server_status: bool, + /// Let players override colours/theme in their own settings. + pub allow_user_theme: bool, + /// Let players change JVM arguments / Java path. + pub allow_advanced_java: bool, +} + +impl Default for Features { + fn default() -> Self { + Self { news: true, server_status: true, allow_user_theme: true, allow_advanced_java: true } + } +} + +// --------------------------------------------------------------------------- +// Auth +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RegistrationMode { + /// Only admins can create accounts. + #[default] + Closed, + /// Anyone can sign up from the launcher. + Open, + /// Anyone can sign up, but an admin must approve the account. + Approval, +} + +/// Public auth configuration the launcher uses to decide which sign-in +/// options to show. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct AuthConfig { + pub panel_accounts: bool, + pub registration: RegistrationMode, + pub microsoft: bool, + pub microsoft_client_id: Option, + pub offline_local: bool, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + panel_accounts: true, + registration: RegistrationMode::Closed, + microsoft: false, + microsoft_client_id: None, + offline_local: true, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegisterRequest { + pub username: String, + pub password: String, + #[serde(default)] + pub email: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct PublicUser { + pub id: i64, + pub username: String, + /// Offline-mode UUID (dashed), identical to what an offline server computes. + pub uuid: String, + pub role: String, + pub groups: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthResponse { + pub token: String, + pub user: PublicUser, + /// Set when the account exists but is waiting for admin approval. + #[serde(default)] + pub pending: bool, +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default, Hash)] +#[serde(rename_all = "lowercase")] +pub enum Loader { + #[default] + Vanilla, + Fabric, + Quilt, + Forge, + NeoForge, +} + +impl Loader { + pub fn as_str(&self) -> &'static str { + match self { + Loader::Vanilla => "vanilla", + Loader::Fabric => "fabric", + Loader::Quilt => "quilt", + Loader::Forge => "forge", + Loader::NeoForge => "neoforge", + } + } + + pub fn parse(s: &str) -> Option { + Some(match s.to_ascii_lowercase().as_str() { + "vanilla" | "" => Loader::Vanilla, + "fabric" | "fabric-loader" => Loader::Fabric, + "quilt" | "quilt-loader" => Loader::Quilt, + "forge" => Loader::Forge, + "neoforge" | "neo-forge" => Loader::NeoForge, + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct ServerEntry { + pub name: String, + pub address: String, + pub port: u16, + /// Connect straight to the server when the game starts. + pub auto_join: bool, + /// Add the server to the in-game multiplayer list (servers.dat). + pub inject: bool, +} + +impl Default for ServerEntry { + fn default() -> Self { + Self { name: String::new(), address: String::new(), port: 25565, auto_join: false, inject: true } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct MemoryDefaults { + pub min_mb: u32, + pub max_mb: u32, +} + +impl Default for MemoryDefaults { + fn default() -> Self { + Self { min_mb: 1024, max_mb: 4096 } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct InstanceSummary { + pub id: String, + pub name: String, + pub description: String, + pub icon_url: Option, + pub banner_url: Option, + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + /// Bumped by the panel whenever the instance changes; the launcher + /// re-syncs files when this differs from what it last installed. + pub revision: i64, + pub server: Option, + pub memory: MemoryDefaults, + pub jvm_args: String, + pub featured: bool, + /// Human description of where the instance came from ("Modrinth: Fabulously Optimized 6.2"). + pub source_label: String, + pub file_count: u32, + pub total_size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct FileEntry { + /// Path relative to the instance game directory, always using `/`. + pub path: String, + /// Absolute URL, or a panel-relative URL starting with `/`. + pub url: String, + pub sha1: String, + pub size: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub struct InstanceManifest { + pub instance: InstanceSummary, + pub files: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(default)] +pub struct LauncherManifest { + pub api_version: u32, + pub panel_version: String, + pub branding: Branding, + pub auth: AuthConfig, + pub instances: Vec, + /// Present when the request carried a valid panel token. + pub user: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LaunchEvent { + pub instance_id: String, + pub kind: String, + #[serde(default)] + pub username: Option, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// The UUID an offline-mode server assigns to `name` +/// (Java's `UUID.nameUUIDFromBytes("OfflinePlayer:" + name)`). +pub fn offline_uuid(name: &str) -> String { + use md5::{Digest, Md5}; + let mut hash: [u8; 16] = Md5::digest(format!("OfflinePlayer:{name}").as_bytes()).into(); + hash[6] = (hash[6] & 0x0f) | 0x30; // version 3 + hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant + let h: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + format!("{}-{}-{}-{}-{}", &h[0..8], &h[8..12], &h[12..16], &h[16..20], &h[20..32]) +} + +/// Minecraft usernames: 3-16 chars of `[A-Za-z0-9_]`. +pub fn valid_username(name: &str) -> bool { + (3..=16).contains(&name.len()) && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn offline_uuid_matches_java() { + // Reference values from java.util.UUID.nameUUIDFromBytes. + assert_eq!(offline_uuid("Notch"), "b50ad385-829d-3141-a216-7e7d7539ba7f"); + assert_eq!(offline_uuid("jeb_"), "a762f560-4fce-3236-812a-b80efff0b62b"); + } + + #[test] + fn usernames() { + assert!(valid_username("Steve_01")); + assert!(!valid_username("ab")); + assert!(!valid_username("has space")); + assert!(!valid_username("waytoolongusername1")); + } + + #[test] + fn manifest_roundtrip_with_defaults() { + let m: LauncherManifest = serde_json::from_str("{}").unwrap(); + assert_eq!(m.branding.name, "ScopeNet"); + let loader: Loader = serde_json::from_str("\"neoforge\"").unwrap(); + assert_eq!(loader, Loader::NeoForge); + } +} diff --git a/panel/server/Cargo.toml b/panel/server/Cargo.toml new file mode 100644 index 0000000..9a4a189 --- /dev/null +++ b/panel/server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "scopenet-panel" +description = "ScopeNet admin panel: manages instances, branding and players for the launcher" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +scopenet-shared.workspace = true +scopenet-core.workspace = true +anyhow.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +reqwest.workspace = true +futures.workspace = true +sha1.workspace = true +hex.workspace = true +uuid.workspace = true +zip.workspace = true +tracing.workspace = true +chrono.workspace = true +rand.workspace = true +axum = { version = "0.8", features = ["multipart", "macros"] } +tower = { version = "0.5", features = ["util"] } +tower-http = { version = "0.6", features = ["fs", "trace", "compression-gzip", "set-header"] } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "sqlite", "derive"] } +argon2 = "0.5" +jsonwebtoken = "9" +percent-encoding = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/panel/server/src/auth.rs b/panel/server/src/auth.rs new file mode 100644 index 0000000..c4f8be5 --- /dev/null +++ b/panel/server/src/auth.rs @@ -0,0 +1,201 @@ +//! Passwords (Argon2), tokens (JWT) and the request extractors that guard +//! routes. + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use argon2::password_hash::rand_core::OsRng; +use argon2::password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; +use argon2::Argon2; +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use scopenet_shared::{offline_uuid, PublicUser}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +const TOKEN_DAYS: i64 = 30; + +pub fn hash_password(password: &str) -> AppResult { + let salt = SaltString::generate(&mut OsRng); + Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map(|h| h.to_string()) + .map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("hashing failed: {e}"))) +} + +pub fn verify_password(password: &str, hash: &str) -> bool { + PasswordHash::new(hash).map(|h| Argon2::default().verify_password(password.as_bytes(), &h).is_ok()).unwrap_or(false) +} + +pub fn validate_password(password: &str) -> AppResult<()> { + if password.chars().count() < 8 { + return Err(AppError::bad_request("passwords need at least 8 characters")); + } + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Claims { + pub sub: i64, + pub name: String, + pub role: String, + pub exp: i64, +} + +pub struct Keys { + enc: EncodingKey, + dec: DecodingKey, +} + +impl Keys { + pub fn new(secret: &[u8]) -> Self { + Self { enc: EncodingKey::from_secret(secret), dec: DecodingKey::from_secret(secret) } + } + + pub fn issue(&self, user: &UserRow) -> AppResult { + let claims = Claims { + sub: user.id, + name: user.username.clone(), + role: user.role.clone(), + exp: (chrono::Utc::now() + chrono::Duration::days(TOKEN_DAYS)).timestamp(), + }; + encode(&Header::default(), &claims, &self.enc) + .map_err(|e| AppError::new(axum::http::StatusCode::INTERNAL_SERVER_ERROR, e.to_string())) + } + + pub fn verify(&self, token: &str) -> Option { + decode::(token, &self.dec, &Validation::default()).ok().map(|d| d.claims) + } +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct UserRow { + pub id: i64, + pub username: String, + #[serde(skip)] + pub password_hash: String, + pub email: Option, + pub role: String, + pub status: String, + pub created_at: String, + pub last_login: Option, +} + +impl UserRow { + pub fn is_admin(&self) -> bool { + self.role == "admin" + } +} + +pub async fn user_groups(state: &AppState, user_id: i64) -> AppResult> { + Ok(sqlx::query_scalar( + "SELECT g.name FROM groups g JOIN user_groups ug ON ug.group_id = g.id WHERE ug.user_id = ? ORDER BY g.name", + ) + .bind(user_id) + .fetch_all(&state.db) + .await?) +} + +pub async fn public_user(state: &AppState, user: &UserRow) -> AppResult { + Ok(PublicUser { + id: user.id, + username: user.username.clone(), + uuid: offline_uuid(&user.username), + role: user.role.clone(), + groups: user_groups(state, user.id).await?, + }) +} + +fn bearer(parts: &Parts) -> Option<&str> { + parts + .headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ").or_else(|| v.strip_prefix("bearer "))) +} + +async fn resolve(parts: &Parts, state: &AppState) -> AppResult> { + let Some(token) = bearer(parts) else { return Ok(None) }; + let Some(claims) = state.keys.verify(token) else { + return Err(AppError::unauthorized("your session expired, please sign in again")); + }; + let user: Option = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(claims.sub).fetch_optional(&state.db).await?; + match user { + Some(u) if u.status == "active" => Ok(Some(u)), + Some(u) if u.status == "pending" => Err(AppError::forbidden("your account is waiting for approval")), + _ => Err(AppError::unauthorized("account disabled or removed")), + } +} + +/// Signed-in user if a valid token was sent, otherwise `None`. +pub struct MaybeUser(pub Option); + +impl FromRequestParts for MaybeUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + // A bad token on a public endpoint just means "anonymous". + Ok(MaybeUser(resolve(parts, state).await.unwrap_or(None))) + } +} + +pub struct AuthUser(pub UserRow); + +impl FromRequestParts for AuthUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + resolve(parts, state).await?.map(AuthUser).ok_or_else(|| AppError::unauthorized("sign in required")) + } +} + +pub struct AdminUser(pub UserRow); + +impl FromRequestParts for AdminUser { + type Rejection = AppError; + async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result { + let user = resolve(parts, state).await?.ok_or_else(|| AppError::unauthorized("sign in required"))?; + if !user.is_admin() { + return Err(AppError::forbidden("admins only")); + } + Ok(AdminUser(user)) + } +} + +/// Very small brute-force guard: 10 failed attempts per username locks it +/// for 5 minutes. +#[derive(Default)] +pub struct LoginGuard { + failures: Mutex>, +} + +impl LoginGuard { + const MAX: u32 = 10; + const WINDOW: Duration = Duration::from_secs(300); + + pub fn check(&self, username: &str) -> AppResult<()> { + let map = self.failures.lock().unwrap(); + if let Some((count, since)) = map.get(&username.to_lowercase()) { + if *count >= Self::MAX && since.elapsed() < Self::WINDOW { + return Err(AppError::new( + axum::http::StatusCode::TOO_MANY_REQUESTS, + "too many failed attempts, try again in a few minutes", + )); + } + } + Ok(()) + } + + pub fn fail(&self, username: &str) { + let mut map = self.failures.lock().unwrap(); + let entry = map.entry(username.to_lowercase()).or_insert((0, Instant::now())); + if entry.1.elapsed() >= Self::WINDOW { + *entry = (0, Instant::now()); + } + entry.0 += 1; + } + + pub fn succeed(&self, username: &str) { + self.failures.lock().unwrap().remove(&username.to_lowercase()); + } +} diff --git a/panel/server/src/config.rs b/panel/server/src/config.rs new file mode 100644 index 0000000..536a634 --- /dev/null +++ b/panel/server/src/config.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; + +/// Runtime configuration, read from environment variables (see +/// `docker-compose.yml` for the documented list). +#[derive(Debug, Clone)] +pub struct Config { + pub bind: String, + pub data_dir: PathBuf, + pub web_dir: PathBuf, + pub admin_username: String, + pub admin_password: Option, + pub jwt_secret: Option, + pub curseforge_api_key: Option, + pub max_upload_mb: usize, +} + +fn var(name: &str) -> Option { + std::env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty()) +} + +impl Config { + pub fn from_env() -> Self { + Self { + bind: var("SCOPENET_BIND").unwrap_or_else(|| "0.0.0.0:8080".into()), + data_dir: var("SCOPENET_DATA_DIR").unwrap_or_else(|| "./data".into()).into(), + web_dir: var("SCOPENET_WEB_DIR").unwrap_or_else(|| "./panel/web/dist".into()).into(), + admin_username: var("ADMIN_USERNAME").unwrap_or_else(|| "admin".into()), + admin_password: var("ADMIN_PASSWORD"), + jwt_secret: var("JWT_SECRET"), + curseforge_api_key: var("CURSEFORGE_API_KEY"), + max_upload_mb: var("MAX_UPLOAD_MB").and_then(|v| v.parse().ok()).unwrap_or(2048), + } + } + + pub fn files_dir(&self) -> PathBuf { + self.data_dir.join("files") + } + pub fn uploads_dir(&self) -> PathBuf { + self.data_dir.join("uploads") + } +} diff --git a/panel/server/src/db.rs b/panel/server/src/db.rs new file mode 100644 index 0000000..7bd2ad2 --- /dev/null +++ b/panel/server/src/db.rs @@ -0,0 +1,120 @@ +use anyhow::Result; +use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous}; +use sqlx::SqlitePool; +use std::path::Path; +use std::str::FromStr; + +/// Schema migrations, applied in order. Never edit a released entry — +/// append a new one instead. +const MIGRATIONS: &[&str] = &[ + // 1: initial schema + r#" + CREATE TABLE users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + password_hash TEXT NOT NULL, + email TEXT, + role TEXT NOT NULL DEFAULT 'player', + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT NOT NULL, + last_login TEXT + ); + CREATE TABLE groups ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE COLLATE NOCASE, + color TEXT NOT NULL DEFAULT '#7c5cff' + ); + CREATE TABLE user_groups ( + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + group_id INTEGER NOT NULL REFERENCES groups(id) ON DELETE CASCADE, + PRIMARY KEY (user_id, group_id) + ); + CREATE TABLE instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + icon_url TEXT, + banner_url TEXT, + mc_version TEXT NOT NULL, + loader TEXT NOT NULL DEFAULT 'vanilla', + loader_version TEXT, + source_kind TEXT NOT NULL DEFAULT 'vanilla', + source_label TEXT NOT NULL DEFAULT '', + source_ref TEXT NOT NULL DEFAULT '{}', + visibility TEXT NOT NULL DEFAULT 'public', + allowed_groups TEXT NOT NULL DEFAULT '[]', + memory_min INTEGER NOT NULL DEFAULT 1024, + memory_max INTEGER NOT NULL DEFAULT 4096, + jvm_args TEXT NOT NULL DEFAULT '', + server TEXT, + featured INTEGER NOT NULL DEFAULT 0, + enabled INTEGER NOT NULL DEFAULT 1, + sort INTEGER NOT NULL DEFAULT 0, + revision INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE instance_files ( + instance_id TEXT NOT NULL REFERENCES instances(id) ON DELETE CASCADE, + path TEXT NOT NULL, + url TEXT NOT NULL, + sha1 TEXT NOT NULL, + size INTEGER NOT NULL, + origin TEXT NOT NULL, + note TEXT, + PRIMARY KEY (instance_id, path) + ); + CREATE TABLE kv ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + instance_id TEXT, + username TEXT, + kind TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE INDEX events_created ON events(created_at); + "#, +]; + +pub async fn connect(data_dir: &Path) -> Result { + std::fs::create_dir_all(data_dir)?; + let url = format!("sqlite://{}", data_dir.join("panel.db").display()); + let opts = SqliteConnectOptions::from_str(&url)? + .create_if_missing(true) + .journal_mode(SqliteJournalMode::Wal) + .synchronous(SqliteSynchronous::Normal) + .foreign_keys(true); + let pool = SqlitePoolOptions::new().max_connections(8).connect_with(opts).await?; + migrate(&pool).await?; + Ok(pool) +} + +pub async fn connect_memory() -> Result { + let opts = SqliteConnectOptions::from_str("sqlite::memory:")?.foreign_keys(true); + let pool = SqlitePoolOptions::new().max_connections(1).connect_with(opts).await?; + migrate(&pool).await?; + Ok(pool) +} + +async fn migrate(pool: &SqlitePool) -> Result<()> { + let current: i64 = sqlx::query_scalar("PRAGMA user_version").fetch_one(pool).await?; + for (i, sql) in MIGRATIONS.iter().enumerate() { + let version = i as i64 + 1; + if version <= current { + continue; + } + let mut tx = pool.begin().await?; + sqlx::raw_sql(sql).execute(&mut *tx).await?; + sqlx::raw_sql(&format!("PRAGMA user_version = {version}")).execute(&mut *tx).await?; + tx.commit().await?; + tracing::info!("applied database migration {version}"); + } + Ok(()) +} + +pub fn now() -> String { + chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} diff --git a/panel/server/src/error.rs b/panel/server/src/error.rs new file mode 100644 index 0000000..6285270 --- /dev/null +++ b/panel/server/src/error.rs @@ -0,0 +1,74 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde_json::json; + +/// Every handler returns `Result<_, AppError>`; errors become +/// `{"error": "..."}` with a sensible status code. +#[derive(Debug)] +pub struct AppError { + pub status: StatusCode, + pub message: String, +} + +pub type AppResult = Result; + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { status, message: message.into() } + } + pub fn bad_request(m: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, m) + } + pub fn unauthorized(m: impl Into) -> Self { + Self::new(StatusCode::UNAUTHORIZED, m) + } + pub fn forbidden(m: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, m) + } + pub fn not_found(m: impl Into) -> Self { + Self::new(StatusCode::NOT_FOUND, m) + } + pub fn conflict(m: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, m) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + if self.status.is_server_error() { + tracing::error!("{}", self.message); + } + (self.status, Json(json!({ "error": self.message }))).into_response() + } +} + +impl From for AppError { + fn from(e: anyhow::Error) -> Self { + Self::new(StatusCode::BAD_GATEWAY, format!("{e:#}")) + } +} + +impl From for AppError { + fn from(e: sqlx::Error) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("database error: {e}")) + } +} + +impl From for AppError { + fn from(e: std::io::Error) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("io error: {e}")) + } +} + +impl From for AppError { + fn from(e: serde_json::Error) -> Self { + Self::bad_request(format!("invalid JSON: {e}")) + } +} + +impl From for AppError { + fn from(e: axum::extract::multipart::MultipartError) -> Self { + Self::bad_request(format!("upload failed: {e}")) + } +} diff --git a/panel/server/src/lib.rs b/panel/server/src/lib.rs new file mode 100644 index 0000000..b3b06da --- /dev/null +++ b/panel/server/src/lib.rs @@ -0,0 +1,100 @@ +//! ScopeNet admin panel. + +pub mod auth; +pub mod config; +pub mod db; +pub mod error; +pub mod packs; +pub mod routes; +pub mod state; +pub mod store; + +use axum::http::{header, HeaderValue}; +use axum::Router; +use rand::RngCore; +use state::AppState; +use std::sync::Arc; +use tower_http::compression::CompressionLayer; +use tower_http::services::{ServeDir, ServeFile}; +use tower_http::set_header::SetResponseHeaderLayer; +use tower_http::trace::TraceLayer; + +/// Load (or create) the JWT signing secret. +pub fn jwt_secret(cfg: &config::Config) -> anyhow::Result> { + if let Some(s) = &cfg.jwt_secret { + return Ok(s.as_bytes().to_vec()); + } + let path = cfg.data_dir.join("jwt.secret"); + if let Ok(bytes) = std::fs::read(&path) { + if bytes.len() >= 32 { + return Ok(bytes); + } + } + let mut bytes = vec![0u8; 64]; + rand::thread_rng().fill_bytes(&mut bytes); + std::fs::create_dir_all(&cfg.data_dir)?; + std::fs::write(&path, &bytes)?; + Ok(bytes) +} + +pub async fn build_state(cfg: config::Config, db: sqlx::SqlitePool) -> anyhow::Result { + let secret = jwt_secret(&cfg)?; + Ok(AppState { + db, + keys: Arc::new(auth::Keys::new(&secret)), + http: scopenet_core::http::client(), + login_guard: Arc::new(auth::LoginGuard::default()), + cfg: Arc::new(cfg), + }) +} + +/// Create the first admin account if none exists. +pub async fn bootstrap_admin(state: &AppState) -> anyhow::Result<()> { + let admins: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE role = 'admin'").fetch_one(&state.db).await?; + if admins > 0 { + return Ok(()); + } + let (password, generated) = match &state.cfg.admin_password { + Some(p) => (p.clone(), false), + None => { + let mut bytes = [0u8; 12]; + rand::thread_rng().fill_bytes(&mut bytes); + (hex::encode(bytes), true) + } + }; + let hash = auth::hash_password(&password).map_err(|e| anyhow::anyhow!(e.message))?; + sqlx::query("INSERT INTO users (username, password_hash, role, status, created_at) VALUES (?, ?, 'admin', 'active', ?)") + .bind(&state.cfg.admin_username) + .bind(hash) + .bind(db::now()) + .execute(&state.db) + .await?; + if generated { + tracing::warn!("============================================================"); + tracing::warn!(" Created admin account '{}' with password: {password}", state.cfg.admin_username); + tracing::warn!(" Set ADMIN_PASSWORD to choose your own. Change it after login!"); + tracing::warn!("============================================================"); + } else { + tracing::info!("created admin account '{}'", state.cfg.admin_username); + } + Ok(()) +} + +pub fn app(state: AppState) -> Router { + let web = &state.cfg.web_dir; + let spa = ServeDir::new(web).fallback(ServeFile::new(web.join("index.html"))); + let long_cache = SetResponseHeaderLayer::overriding(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=86400")); + + Router::new() + .route("/healthz", axum::routing::get(routes::public::health)) + .merge(routes::api(&state)) + .nest_service("/files", ServeDir::new(state.cfg.files_dir())) + .nest_service( + "/uploads", + tower::ServiceBuilder::new().layer(long_cache).service(ServeDir::new(state.cfg.uploads_dir())), + ) + .fallback_service(spa) + .layer(CompressionLayer::new()) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} diff --git a/panel/server/src/main.rs b/panel/server/src/main.rs new file mode 100644 index 0000000..bc4a299 --- /dev/null +++ b/panel/server/src/main.rs @@ -0,0 +1,59 @@ +use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db}; +use tracing_subscriber::EnvFilter; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + // `scopenet-panel healthcheck` — used by the Docker HEALTHCHECK so the + // image doesn't need curl. + if std::env::args().nth(1).as_deref() == Some("healthcheck") { + return healthcheck().await; + } + + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info,tower_http=warn,sqlx=warn"))) + .compact() + .init(); + + let cfg = Config::from_env(); + let pool = db::connect(&cfg.data_dir).await?; + if !cfg.web_dir.join("index.html").exists() { + tracing::warn!("web UI not found at {} (build panel/web or set SCOPENET_WEB_DIR)", cfg.web_dir.display()); + } + let bind = cfg.bind.clone(); + let state = build_state(cfg, pool).await?; + bootstrap_admin(&state).await?; + + let listener = tokio::net::TcpListener::bind(&bind).await?; + tracing::info!("ScopeNet panel v{} listening on http://{bind}", env!("CARGO_PKG_VERSION")); + axum::serve(listener, app(state)).with_graceful_shutdown(shutdown()).await?; + Ok(()) +} + +async fn shutdown() { + let ctrl_c = async { tokio::signal::ctrl_c().await.ok(); }; + #[cfg(unix)] + let term = async { + if let Ok(mut s) = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) { + s.recv().await; + } + }; + #[cfg(not(unix))] + let term = std::future::pending::<()>(); + tokio::select! { _ = ctrl_c => {}, _ = term => {} } + tracing::info!("shutting down"); +} + +async fn healthcheck() -> anyhow::Result<()> { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let bind = std::env::var("SCOPENET_BIND").unwrap_or_else(|_| "0.0.0.0:8080".into()); + let port = bind.rsplit(':').next().unwrap_or("8080"); + let mut s = tokio::net::TcpStream::connect(format!("127.0.0.1:{port}")).await?; + s.write_all(b"GET /healthz HTTP/1.0\r\nHost: localhost\r\n\r\n").await?; + let mut buf = String::new(); + s.read_to_string(&mut buf).await?; + if buf.starts_with("HTTP/1.1 200") || buf.starts_with("HTTP/1.0 200") { + Ok(()) + } else { + anyhow::bail!("unhealthy: {}", buf.lines().next().unwrap_or("")) + } +} diff --git a/panel/server/src/packs.rs b/panel/server/src/packs.rs new file mode 100644 index 0000000..1587cb7 --- /dev/null +++ b/panel/server/src/packs.rs @@ -0,0 +1,599 @@ +//! Modpack import: Modrinth (.mrpack), CurseForge (.zip + API) and plain +//! instance zips. Everything is flattened into a list of files the launcher +//! downloads — mods straight from the CDN, overrides from this panel. + +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use crate::store; +use percent_encoding::{utf8_percent_encode, AsciiSet, NON_ALPHANUMERIC}; +use scopenet_core::paths::safe_join; +use scopenet_shared::Loader; +use serde::Deserialize; +use serde_json::json; +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::path::Path; + +const CF_API: &str = "https://api.curseforge.com/v1"; +const MODRINTH_API: &str = "https://api.modrinth.com/v2"; +const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC.remove(b'-').remove(b'.').remove(b'_').remove(b'~'); + +#[derive(Debug, Clone)] +pub struct NewFile { + pub path: String, + pub url: String, + pub sha1: String, + pub size: u64, + pub origin: &'static str, + pub note: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct PackInfo { + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + pub name: String, + pub version: String, + pub files: Vec, +} + +/// Panel-relative URL for a file stored under `data/files//`. +pub fn files_url(instance_id: &str, rel: &str) -> String { + let encoded: Vec = rel.split('/').map(|s| utf8_percent_encode(s, PATH_SEGMENT).to_string()).collect(); + format!("/files/{}/{}", utf8_percent_encode(instance_id, PATH_SEGMENT), encoded.join("/")) +} + +type Zip = zip::ZipArchive>>; + +fn read_json(zip: &mut Zip, name: &str) -> Option { + let mut entry = zip.by_name(name).ok()?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).ok()?; + serde_json::from_slice(&buf).ok() +} + +/// Paths we never ship from a plain instance export. +fn is_junk(rel: &str) -> bool { + const DIRS: &[&str] = &[ + "logs/", "crash-reports/", "screenshots/", "versions/", "libraries/", "assets/", "natives/", "__MACOSX/", ".fabric/", ".scopenet/", + ]; + const FILES: &[&str] = &["usercache.json", "usernamecache.json", "launcher_profiles.json", "launcher_accounts.json", ".DS_Store", "instance.cfg", "mmc-pack.json"]; + DIRS.iter().any(|d| rel.starts_with(d)) || FILES.iter().any(|f| rel == *f || rel.ends_with(&format!("/{f}"))) +} + +/// Extract entries under `prefix` into the instance's file store. +fn extract_prefix(zip: &mut Zip, prefix: &str, dest_root: &Path, instance_id: &str, origin: &'static str, skip_junk: bool) -> AppResult> { + let mut out = Vec::new(); + for i in 0..zip.len() { + let mut entry = zip.by_index(i).map_err(|e| AppError::bad_request(format!("corrupt zip: {e}")))?; + if entry.is_dir() { + continue; + } + let name = entry.name().replace('\\', "/"); + let Some(rel) = name.strip_prefix(prefix) else { continue }; + if rel.is_empty() || (skip_junk && is_junk(rel)) { + continue; + } + let Some(dest) = safe_join(dest_root, rel) else { continue }; + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + let mut buf = Vec::with_capacity(entry.size() as usize); + entry.read_to_end(&mut buf)?; + let sha1 = scopenet_core::http::sha1_bytes(&buf); + std::fs::write(&dest, &buf)?; + out.push(NewFile { path: rel.to_string(), url: files_url(instance_id, rel), sha1, size: buf.len() as u64, origin, note: None }); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// Modrinth +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrIndex { + #[serde(default)] + name: String, + #[serde(default)] + version_id: String, + files: Vec, + dependencies: HashMap, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct MrFile { + path: String, + hashes: HashMap, + #[serde(default)] + env: Option>, + downloads: Vec, + #[serde(default)] + file_size: u64, +} + +fn import_mrpack(zip: &mut Zip, index: MrIndex, dest_root: &Path, instance_id: &str) -> AppResult { + let deps = &index.dependencies; + let mc = deps.get("minecraft").cloned().ok_or_else(|| AppError::bad_request("mrpack has no minecraft dependency"))?; + let (loader, loader_version) = if let Some(v) = deps.get("fabric-loader") { + (Loader::Fabric, Some(v.clone())) + } else if let Some(v) = deps.get("quilt-loader") { + (Loader::Quilt, Some(v.clone())) + } else if let Some(v) = deps.get("neoforge") { + (Loader::NeoForge, Some(v.clone())) + } else if let Some(v) = deps.get("forge") { + (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, &mc, v))) + } else { + (Loader::Vanilla, None) + }; + + let mut files: Vec = Vec::new(); + for f in index.files { + if f.env.as_ref().and_then(|e| e.get("client")).map(|c| c == "unsupported").unwrap_or(false) { + continue; + } + let (Some(url), Some(sha1)) = (f.downloads.first(), f.hashes.get("sha1")) else { continue }; + if safe_join(dest_root, &f.path).is_none() { + continue; + } + files.push(NewFile { path: f.path.replace('\\', "/"), url: url.clone(), sha1: sha1.clone(), size: f.file_size, origin: "pack", note: None }); + } + // client-overrides win over overrides. + let mut overrides = extract_prefix(zip, "overrides/", dest_root, instance_id, "override", false)?; + overrides.extend(extract_prefix(zip, "client-overrides/", dest_root, instance_id, "override", false)?); + merge_files(&mut files, overrides); + + Ok(PackInfo { mc_version: mc, loader, loader_version, name: index.name, version: index.version_id, files }) +} + +fn merge_files(files: &mut Vec, extra: Vec) { + for f in extra { + files.retain(|x| x.path != f.path); + files.push(f); + } +} + +#[derive(Deserialize, serde::Serialize)] +pub struct MrVersion { + pub id: String, + pub project_id: String, + pub name: String, + pub version_number: String, + #[serde(default)] + pub game_versions: Vec, + #[serde(default)] + pub loaders: Vec, + pub files: Vec, + #[serde(default)] + pub date_published: String, +} + +#[derive(Deserialize, serde::Serialize)] +pub struct MrVersionFile { + pub url: String, + pub filename: String, + #[serde(default)] + pub primary: bool, + #[serde(default)] + pub hashes: HashMap, +} + +#[derive(Deserialize)] +struct MrProject { + title: String, + #[serde(default)] + icon_url: Option, +} + +pub async fn fetch_modrinth(state: &AppState, version_id: &str) -> AppResult<(Vec, String, Option)> { + let version: MrVersion = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/version/{version_id}")).await?; + let project: MrProject = scopenet_core::http::get_json(&state.http, &format!("{MODRINTH_API}/project/{}", version.project_id)).await?; + let file = version + .files + .iter() + .find(|f| f.primary && f.filename.ends_with(".mrpack")) + .or_else(|| version.files.iter().find(|f| f.filename.ends_with(".mrpack"))) + .ok_or_else(|| AppError::bad_request("that version has no .mrpack file"))?; + let bytes = download_bytes(state, &file.url).await?; + if let Some(expected) = file.hashes.get("sha1") { + if !scopenet_core::http::sha1_bytes(&bytes).eq_ignore_ascii_case(expected) { + return Err(AppError::bad_request("downloaded pack failed its checksum")); + } + } + Ok((bytes, format!("Modrinth · {} {}", project.title, version.version_number), project.icon_url)) +} + +async fn download_bytes(state: &AppState, url: &str) -> AppResult> { + let resp = state.http.get(url).send().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("download failed: {} returned {}", url, resp.status()))); + } + Ok(resp.bytes().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.to_vec()) +} + +// --------------------------------------------------------------------------- +// CurseForge +// --------------------------------------------------------------------------- + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifest { + minecraft: CfMinecraft, + #[serde(default)] + files: Vec, + #[serde(default = "default_overrides")] + overrides: String, + #[serde(default)] + name: String, + #[serde(default)] + version: String, +} +fn default_overrides() -> String { + "overrides".into() +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMinecraft { + version: String, + #[serde(default)] + mod_loaders: Vec, +} +#[derive(Deserialize)] +struct CfLoader { + id: String, + #[serde(default)] + primary: bool, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfManifestFile { + #[serde(rename = "projectID")] + project_id: i64, + #[serde(rename = "fileID")] + file_id: i64, + #[serde(default = "yes")] + required: bool, +} +fn yes() -> bool { + true +} + +#[derive(Deserialize)] +struct CfData { + data: T, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfFile { + id: i64, + mod_id: i64, + file_name: String, + #[serde(default)] + download_url: Option, + #[serde(default)] + file_length: u64, + #[serde(default)] + hashes: Vec, +} +#[derive(Deserialize)] +struct CfHash { + value: String, + algo: i32, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfMod { + id: i64, + name: String, + #[serde(default)] + class_id: Option, + #[serde(default)] + links: Option, +} +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct CfLinks { + #[serde(default)] + website_url: Option, +} + +pub fn parse_cf_loader(mc: &str, id: &str) -> (Loader, Option) { + let (name, version) = id.split_once('-').unwrap_or((id, "")); + match Loader::parse(name) { + Some(Loader::Forge) => (Loader::Forge, Some(scopenet_core::meta::normalize_forge_version(Loader::Forge, mc, version))), + Some(l) if l != Loader::Vanilla => (l, Some(version.to_string()).filter(|v| !v.is_empty())), + _ => (Loader::Vanilla, None), + } +} + +fn cf_folder(class_id: Option) -> &'static str { + match class_id { + Some(12) => "resourcepacks", + Some(6552) => "shaderpacks", + Some(4546) => "config", + _ => "mods", + } +} + +async fn cf_post(state: &AppState, key: &str, path: &str, body: serde_json::Value) -> AppResult { + let resp = state + .http + .post(format!("{CF_API}{path}")) + .header("x-api-key", key) + .json(&body) + .send() + .await + .map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if resp.status().as_u16() == 403 { + return Err(AppError::bad_request("CurseForge rejected the API key")); + } + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status()))); + } + Ok(resp.json::>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data) +} + +async fn cf_get(state: &AppState, key: &str, path: &str) -> AppResult { + let resp = state + .http + .get(format!("{CF_API}{path}")) + .header("x-api-key", key) + .send() + .await + .map_err(|e| AppError::from(anyhow::Error::from(e)))?; + if !resp.status().is_success() { + return Err(AppError::bad_request(format!("CurseForge returned {}", resp.status()))); + } + Ok(resp.json::>().await.map_err(|e| AppError::from(anyhow::Error::from(e)))?.data) +} + +pub async fn cf_raw_get(state: &AppState, path: &str) -> AppResult { + let key = store::curseforge_key(state).await?; + cf_get(state, &key, path).await +} + +async fn resolve_cf_files(state: &AppState, manifest_files: &[CfManifestFile]) -> AppResult> { + let wanted: Vec<&CfManifestFile> = manifest_files.iter().filter(|f| f.required).collect(); + if wanted.is_empty() { + return Ok(vec![]); + } + let key = store::curseforge_key(state).await?; + let mut files: Vec = Vec::new(); + let mut mods: HashMap = HashMap::new(); + for chunk in wanted.chunks(500) { + let ids: Vec = chunk.iter().map(|f| f.file_id).collect(); + files.extend(cf_post::>(state, &key, "/mods/files", json!({ "fileIds": ids })).await?); + let mod_ids: Vec = chunk.iter().map(|f| f.project_id).collect(); + for m in cf_post::>(state, &key, "/mods", json!({ "modIds": mod_ids })).await? { + mods.insert(m.id, m); + } + } + + let mut out = Vec::new(); + for f in files { + let m = mods.get(&f.mod_id); + let folder = cf_folder(m.and_then(|m| m.class_id)); + let sha1 = f.hashes.iter().find(|h| h.algo == 1).map(|h| h.value.clone()).unwrap_or_default(); + let url = f.download_url.clone().unwrap_or_default(); + let note = if url.is_empty() { + let name = m.map(|m| m.name.clone()).unwrap_or_else(|| format!("project {}", f.mod_id)); + let site = m.and_then(|m| m.links.as_ref()).and_then(|l| l.website_url.clone()).unwrap_or_default(); + Some(format!("{name} blocks third-party downloads. Download it from {site}/files/{} and upload it here.", f.id)) + } else { + None + }; + out.push(NewFile { path: format!("{folder}/{}", f.file_name), url, sha1, size: f.file_length, origin: "pack", note }); + } + Ok(out) +} + +pub async fn fetch_curseforge(state: &AppState, mod_id: i64, file_id: i64) -> AppResult<(Vec, String, Option)> { + let key = store::curseforge_key(state).await?; + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Info { + download_url: Option, + display_name: String, + } + #[derive(Deserialize)] + struct Logo { + #[serde(rename = "thumbnailUrl")] + thumbnail_url: Option, + } + #[derive(Deserialize)] + struct ModInfo { + name: String, + logo: Option, + } + let info: Info = cf_get(state, &key, &format!("/mods/{mod_id}/files/{file_id}")).await?; + let project: ModInfo = cf_get(state, &key, &format!("/mods/{mod_id}")).await?; + let url = info.download_url.ok_or_else(|| AppError::bad_request("this modpack can't be downloaded through the API; download the zip and upload it instead"))?; + let bytes = download_bytes(state, &url).await?; + Ok((bytes, format!("CurseForge · {} ({})", project.name, info.display_name), project.logo.and_then(|l| l.thumbnail_url))) +} + +// --------------------------------------------------------------------------- +// Entry point +// --------------------------------------------------------------------------- + +/// Fallback versions for plain zips (which don't declare their own). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct Fallback { + pub mc_version: Option, + pub loader: Option, + pub loader_version: Option, +} + +/// Result of the blocking parse step: a finished pack, or a CurseForge +/// manifest (+ extracted overrides) that still needs API resolution. +type Parsed = (Option<(PackInfo, &'static str)>, Option<(CfManifest, Vec)>); + +/// Import any supported zip for `instance_id`. +pub async fn import_zip(state: &AppState, instance_id: &str, bytes: Vec, fallback: Fallback) -> AppResult<(PackInfo, &'static str)> { + let dest_root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id)); + std::fs::create_dir_all(&dest_root)?; + let id = instance_id.to_string(); + + // Parsing + extraction is CPU/disk bound: keep it off the async workers. + let root = dest_root.clone(); + let (parsed, cf_manifest) = tokio::task::spawn_blocking(move || -> AppResult { + let mut zip = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| AppError::bad_request(format!("not a valid zip: {e}")))?; + if let Some(index) = read_json::(&mut zip, "modrinth.index.json") { + return Ok((Some((import_mrpack(&mut zip, index, &root, &id)?, "modrinth")), None)); + } + if let Some(manifest) = read_json::(&mut zip, "manifest.json") { + let prefix = format!("{}/", manifest.overrides.trim_end_matches('/')); + let overrides = extract_prefix(&mut zip, &prefix, &root, &id, "override", false)?; + return Ok((None, Some((manifest, overrides)))); + } + // Plain instance zip: find the game directory inside it. + let names: Vec = zip.file_names().map(|n| n.replace('\\', "/")).collect(); + let prefix = detect_root(&names); + let files = extract_prefix(&mut zip, &prefix, &root, &id, "override", true)?; + Ok((Some((PackInfo { files, ..Default::default() }, "zip")), None)) + }) + .await + .map_err(|e| AppError::bad_request(format!("import crashed: {e}")))??; + + if let Some((manifest, overrides)) = cf_manifest { + let mc = manifest.minecraft.version.clone(); + let primary = manifest.minecraft.mod_loaders.iter().find(|l| l.primary).or(manifest.minecraft.mod_loaders.first()); + let (loader, loader_version) = primary.map(|l| parse_cf_loader(&mc, &l.id)).unwrap_or((Loader::Vanilla, None)); + let mut files = resolve_cf_files(state, &manifest.files).await?; + merge_files(&mut files, overrides); + return Ok((PackInfo { mc_version: mc, loader, loader_version, name: manifest.name, version: manifest.version, files }, "curseforge")); + } + + let (mut info, kind) = parsed.expect("parsed"); + if kind == "zip" { + info.mc_version = fallback.mc_version.filter(|v| !v.is_empty()).ok_or_else(|| { + AppError::bad_request("this zip isn't a Modrinth or CurseForge pack — pick the Minecraft version and loader for it") + })?; + info.loader = fallback.loader.unwrap_or_default(); + info.loader_version = fallback.loader_version.filter(|v| !v.is_empty()); + } + Ok((info, kind)) +} + +/// Find the game directory inside a zip: a `.minecraft/` folder, or a +/// single top-level folder wrapping everything. +pub fn detect_root(names: &[String]) -> String { + if let Some(pos) = names.iter().filter_map(|n| n.find(".minecraft/").map(|i| &n[..i + ".minecraft/".len()])).min_by_key(|p| p.len()) { + return pos.to_string(); + } + let markers = ["mods/", "config/", "resourcepacks/", "shaderpacks/", "options.txt"]; + if names.iter().any(|n| markers.iter().any(|m| n.starts_with(m))) { + return String::new(); + } + let tops: std::collections::HashSet<&str> = names.iter().filter_map(|n| n.split_once('/').map(|(t, _)| t)).collect(); + if tops.len() == 1 && names.iter().all(|n| n.contains('/')) { + return format!("{}/", tops.into_iter().next().unwrap()); + } + String::new() +} + +/// Replace the instance's pack/override files with `info.files`, keep files +/// the admin uploaded by hand, update versions and bump the revision. +pub async fn apply(state: &AppState, instance_id: &str, info: &PackInfo, kind: &str, label: &str, source_ref: serde_json::Value) -> AppResult<()> { + let mut tx = state.db.begin().await?; + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND origin != 'upload'").bind(instance_id).execute(&mut *tx).await?; + for f in &info.files { + sqlx::query( + "INSERT INTO instance_files (instance_id, path, url, sha1, size, origin, note) VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = excluded.origin, note = excluded.note", + ) + .bind(instance_id) + .bind(&f.path) + .bind(&f.url) + .bind(&f.sha1) + .bind(f.size as i64) + .bind(f.origin) + .bind(&f.note) + .execute(&mut *tx) + .await?; + } + sqlx::query( + "UPDATE instances SET mc_version = ?, loader = ?, loader_version = ?, source_kind = ?, source_label = ?, source_ref = ?, + revision = revision + 1, updated_at = ? WHERE id = ?", + ) + .bind(&info.mc_version) + .bind(info.loader.as_str()) + .bind(&info.loader_version) + .bind(kind) + .bind(label) + .bind(source_ref.to_string()) + .bind(crate::db::now()) + .bind(instance_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + gc_files(state, instance_id).await?; + Ok(()) +} + +/// Delete stored files no longer referenced by the instance. +pub async fn gc_files(state: &AppState, instance_id: &str) -> AppResult<()> { + let root = state.cfg.files_dir().join(scopenet_core::paths::sanitize_id(instance_id)); + let referenced: std::collections::HashSet = store::instance_files(state, instance_id) + .await? + .into_iter() + .filter(|f| f.url.starts_with("/files/")) + .map(|f| f.path) + .collect(); + tokio::task::spawn_blocking(move || { + fn walk(dir: &Path, root: &Path, keep: &std::collections::HashSet) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for e in entries.flatten() { + let p = e.path(); + if p.is_dir() { + walk(&p, root, keep); + std::fs::remove_dir(&p).ok(); // only succeeds when empty + } else if let Ok(rel) = p.strip_prefix(root) { + let rel = rel.to_string_lossy().replace('\\', "/"); + if !keep.contains(&rel) { + std::fs::remove_file(&p).ok(); + } + } + } + } + walk(&root, &root, &referenced); + }) + .await + .ok(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cf_loader_ids() { + assert_eq!(parse_cf_loader("1.20.1", "forge-47.2.0"), (Loader::Forge, Some("1.20.1-47.2.0".into()))); + assert_eq!(parse_cf_loader("1.21.1", "neoforge-21.1.77"), (Loader::NeoForge, Some("21.1.77".into()))); + assert_eq!(parse_cf_loader("1.21.1", "fabric-0.16.9"), (Loader::Fabric, Some("0.16.9".into()))); + } + + #[test] + fn detects_zip_roots() { + let v = |xs: &[&str]| xs.iter().map(|s| s.to_string()).collect::>(); + assert_eq!(detect_root(&v(&["mods/a.jar", "config/b.toml"])), ""); + assert_eq!(detect_root(&v(&["Pack/mods/a.jar", "Pack/config/b.toml"])), "Pack/"); + assert_eq!(detect_root(&v(&["x/.minecraft/mods/a.jar", "x/instance.cfg"])), "x/.minecraft/"); + } + + #[test] + fn encodes_file_urls() { + assert_eq!(files_url("smp", "mods/My Mod+1.jar"), "/files/smp/mods/My%20Mod%2B1.jar"); + } + + #[test] + fn junk_is_skipped() { + assert!(is_junk("logs/latest.log")); + assert!(is_junk("usercache.json")); + assert!(!is_junk("mods/sodium.jar")); + } +} diff --git a/panel/server/src/routes/admin.rs b/panel/server/src/routes/admin.rs new file mode 100644 index 0000000..c82d991 --- /dev/null +++ b/panel/server/src/routes/admin.rs @@ -0,0 +1,623 @@ +//! Admin API used by the panel web UI. + +use crate::auth::{self, AdminUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::packs::{self, Fallback}; +use crate::state::AppState; +use crate::store::{self, AdminInstance, Settings}; +use axum::extract::{Multipart, Path, Query, State}; +use axum::Json; +use scopenet_core::paths::{safe_join, sanitize_id}; +use scopenet_shared::{valid_username, Branding, Loader, MemoryDefaults, ServerEntry}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; + +// --------------------------------------------------------------------------- +// Dashboard +// --------------------------------------------------------------------------- + +pub async fn stats(_: AdminUser, State(state): State) -> AppResult> { + let users: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users").fetch_one(&state.db).await?; + let pending: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM users WHERE status = 'pending'").fetch_one(&state.db).await?; + let instances: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM instances").fetch_one(&state.db).await?; + let since = (chrono::Utc::now() - chrono::Duration::days(13)).format("%Y-%m-%d").to_string(); + let daily: Vec<(String, i64)> = sqlx::query_as( + "SELECT substr(created_at, 1, 10) AS day, COUNT(*) FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY day ORDER BY day", + ) + .bind(&since) + .fetch_all(&state.db) + .await?; + let top: Vec<(Option, i64)> = sqlx::query_as( + "SELECT instance_id, COUNT(*) AS c FROM events WHERE kind = 'launch' AND created_at >= ? GROUP BY instance_id ORDER BY c DESC LIMIT 5", + ) + .bind(&since) + .fetch_all(&state.db) + .await?; + let recent: Vec<(Option, Option, String)> = + sqlx::query_as("SELECT instance_id, username, created_at FROM events ORDER BY id DESC LIMIT 12").fetch_all(&state.db).await?; + let players_7d: i64 = sqlx::query_scalar( + "SELECT COUNT(DISTINCT username) FROM events WHERE created_at >= ?", + ) + .bind((chrono::Utc::now() - chrono::Duration::days(7)).to_rfc3339()) + .fetch_one(&state.db) + .await?; + + // Fill in empty days so the chart is continuous. + let mut days = Vec::new(); + for i in (0..14).rev() { + let d = (chrono::Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string(); + let count = daily.iter().find(|(day, _)| *day == d).map(|(_, c)| *c).unwrap_or(0); + days.push(json!({ "day": d, "launches": count })); + } + let settings = store::settings(&state).await?; + Ok(Json(json!({ + "users": users, + "pending": pending, + "instances": instances, + "players_7d": players_7d, + "launches": days, + "top_instances": top.into_iter().map(|(id, c)| json!({"instance_id": id, "launches": c})).collect::>(), + "recent": recent.into_iter().map(|(i, u, t)| json!({"instance_id": i, "username": u, "at": t})).collect::>(), + "launcher_download_url": settings.launcher_download_url, + "version": env!("CARGO_PKG_VERSION"), + }))) +} + +// --------------------------------------------------------------------------- +// Users & groups +// --------------------------------------------------------------------------- + +#[derive(Serialize)] +pub struct AdminUserView { + #[serde(flatten)] + user: UserRow, + uuid: String, + groups: Vec, +} + +async fn view(state: &AppState, user: UserRow) -> AppResult { + let groups = auth::user_groups(state, user.id).await?; + Ok(AdminUserView { uuid: scopenet_shared::offline_uuid(&user.username), groups, user }) +} + +pub async fn list_users(_: AdminUser, State(state): State) -> AppResult>> { + let users: Vec = sqlx::query_as("SELECT * FROM users ORDER BY status = 'pending' DESC, username COLLATE NOCASE").fetch_all(&state.db).await?; + let mut out = Vec::with_capacity(users.len()); + for u in users { + out.push(view(&state, u).await?); + } + Ok(Json(out)) +} + +#[derive(Deserialize)] +pub struct UserInput { + username: Option, + password: Option, + email: Option, + role: Option, + status: Option, + groups: Option>, +} + +fn check_role(role: &str) -> AppResult<()> { + if !matches!(role, "admin" | "player") { + return Err(AppError::bad_request("role must be admin or player")); + } + Ok(()) +} + +fn check_status(status: &str) -> AppResult<()> { + if !matches!(status, "active" | "pending" | "disabled") { + return Err(AppError::bad_request("status must be active, pending or disabled")); + } + Ok(()) +} + +async fn set_groups(state: &AppState, user_id: i64, groups: &[String]) -> AppResult<()> { + sqlx::query("DELETE FROM user_groups WHERE user_id = ?").bind(user_id).execute(&state.db).await?; + for g in groups { + sqlx::query("INSERT OR IGNORE INTO user_groups (user_id, group_id) SELECT ?, id FROM groups WHERE name = ?") + .bind(user_id) + .bind(g) + .execute(&state.db) + .await?; + } + Ok(()) +} + +pub async fn create_user(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { + let username = input.username.as_deref().map(str::trim).unwrap_or_default(); + if !valid_username(username) { + return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores")); + } + let password = input.password.unwrap_or_default(); + auth::validate_password(&password)?; + let role = input.role.unwrap_or_else(|| "player".into()); + check_role(&role)?; + let status = input.status.unwrap_or_else(|| "active".into()); + check_status(&status)?; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, ?, ?, ?) RETURNING id", + ) + .bind(username) + .bind(auth::hash_password(&password)?) + .bind(input.email.filter(|e| !e.trim().is_empty())) + .bind(&role) + .bind(&status) + .bind(crate::db::now()) + .fetch_one(&state.db) + .await + .map_err(|e| match e { + sqlx::Error::Database(d) if d.message().contains("UNIQUE") => AppError::conflict("that username is taken"), + e => e.into(), + })?; + if let Some(groups) = input.groups { + set_groups(&state, id, &groups).await?; + } + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +pub async fn update_user(AdminUser(me): AdminUser, State(state): State, Path(id): Path, Json(input): Json) -> AppResult> { + let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::not_found("user not found"))?; + if let Some(password) = input.password.filter(|p| !p.is_empty()) { + auth::validate_password(&password)?; + sqlx::query("UPDATE users SET password_hash = ? WHERE id = ?").bind(auth::hash_password(&password)?).bind(id).execute(&state.db).await?; + } + if let Some(email) = input.email { + sqlx::query("UPDATE users SET email = ? WHERE id = ?").bind(Some(email.trim()).filter(|e| !e.is_empty())).bind(id).execute(&state.db).await?; + } + if let Some(role) = input.role { + check_role(&role)?; + if me.id == id && role != "admin" { + return Err(AppError::bad_request("you can't remove your own admin role")); + } + sqlx::query("UPDATE users SET role = ? WHERE id = ?").bind(role).bind(id).execute(&state.db).await?; + } + if let Some(status) = input.status { + check_status(&status)?; + if me.id == id && status != "active" { + return Err(AppError::bad_request("you can't disable your own account")); + } + sqlx::query("UPDATE users SET status = ? WHERE id = ?").bind(status).bind(id).execute(&state.db).await?; + } + if let Some(groups) = input.groups { + set_groups(&state, user.id, &groups).await?; + } + let user = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + Ok(Json(view(&state, user).await?)) +} + +pub async fn delete_user(AdminUser(me): AdminUser, State(state): State, Path(id): Path) -> AppResult> { + if me.id == id { + return Err(AppError::bad_request("you can't delete your own account")); + } + sqlx::query("DELETE FROM users WHERE id = ?").bind(id).execute(&state.db).await?; + Ok(Json(json!({ "ok": true }))) +} + +#[derive(Serialize, Deserialize, sqlx::FromRow)] +pub struct Group { + #[serde(default)] + id: i64, + name: String, + #[serde(default = "default_color")] + color: String, + #[serde(default)] + #[sqlx(default)] + members: i64, +} +fn default_color() -> String { + "#7c5cff".into() +} + +pub async fn list_groups(_: AdminUser, State(state): State) -> AppResult>> { + Ok(Json( + sqlx::query_as( + "SELECT g.id, g.name, g.color, (SELECT COUNT(*) FROM user_groups ug WHERE ug.group_id = g.id) AS members FROM groups g ORDER BY g.name", + ) + .fetch_all(&state.db) + .await?, + )) +} + +pub async fn create_group(_: AdminUser, State(state): State, Json(g): Json) -> AppResult> { + let name = g.name.trim(); + if name.is_empty() || name.len() > 32 { + return Err(AppError::bad_request("group names are 1-32 characters")); + } + sqlx::query("INSERT INTO groups (name, color) VALUES (?, ?)") + .bind(name) + .bind(&g.color) + .execute(&state.db) + .await + .map_err(|_| AppError::conflict("a group with that name exists"))?; + Ok(Json(json!({ "ok": true }))) +} + +pub async fn delete_group(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + sqlx::query("DELETE FROM groups WHERE id = ?").bind(id).execute(&state.db).await?; + Ok(Json(json!({ "ok": true }))) +} + +// --------------------------------------------------------------------------- +// Branding & settings +// --------------------------------------------------------------------------- + +pub async fn get_branding(_: AdminUser, State(state): State) -> AppResult> { + Ok(Json(store::branding(&state).await?)) +} + +pub async fn put_branding(_: AdminUser, State(state): State, Json(b): Json) -> AppResult> { + if b.name.trim().is_empty() { + return Err(AppError::bad_request("the launcher needs a name")); + } + store::kv_set(&state, "branding", &b).await?; + Ok(Json(b)) +} + +#[derive(Serialize)] +pub struct SettingsView { + #[serde(flatten)] + settings: Settings, + curseforge_key_set: bool, + curseforge_key_from_env: bool, +} + +pub async fn get_settings(_: AdminUser, State(state): State) -> AppResult> { + settings_view(&state).await +} + +async fn settings_view(state: &AppState) -> AppResult> { + let mut s = store::settings(state).await?; + let set = s.curseforge_api_key.as_deref().is_some_and(|k| !k.is_empty()); + s.curseforge_api_key = None; // never send secrets back to the browser + Ok(Json(SettingsView { settings: s, curseforge_key_set: set, curseforge_key_from_env: state.cfg.curseforge_api_key.is_some() })) +} + +pub async fn put_settings(_: AdminUser, State(state): State, Json(mut s): Json) -> AppResult> { + let old = store::settings(&state).await?; + // An empty key means "keep the current one"; "-" clears it. + s.curseforge_api_key = match s.curseforge_api_key.as_deref().map(str::trim) { + None | Some("") => old.curseforge_api_key, + Some("-") => None, + Some(k) => Some(k.to_string()), + }; + if s.auth.microsoft && s.auth.microsoft_client_id.as_deref().map(str::trim).unwrap_or("").is_empty() { + return Err(AppError::bad_request("Microsoft sign-in needs an Azure client ID")); + } + store::kv_set(&state, "settings", &s).await?; + settings_view(&state).await +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +pub async fn list_instances(_: AdminUser, State(state): State) -> AppResult>> { + let mut out = Vec::new(); + for row in store::list_instances(&state).await? { + let stats = store::file_stats(&state, &row.id).await?; + out.push(row.to_admin(stats)); + } + Ok(Json(out)) +} + +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct InstanceInput { + name: String, + description: String, + icon_url: Option, + banner_url: Option, + mc_version: String, + loader: Loader, + loader_version: Option, + visibility: Option, + allowed_groups: Vec, + memory: Option, + jvm_args: String, + server: Option, + featured: bool, + enabled: Option, + sort: i64, +} + +async fn validated(state: &AppState, mut input: InstanceInput) -> AppResult { + input.name = input.name.trim().to_string(); + if input.name.is_empty() { + return Err(AppError::bad_request("give the instance a name")); + } + if input.mc_version.trim().is_empty() { + return Err(AppError::bad_request("pick a Minecraft version")); + } + let vis = input.visibility.clone().unwrap_or_else(|| "public".into()); + if !matches!(vis.as_str(), "public" | "members" | "groups") { + return Err(AppError::bad_request("visibility must be public, members or groups")); + } + input.visibility = Some(vis); + let mem = input.memory.unwrap_or_default(); + if mem.max_mb < 512 || mem.min_mb > mem.max_mb { + return Err(AppError::bad_request("memory: max must be at least 512 MB and not below min")); + } + // Pin "latest" to a concrete loader version when we can reach the meta + // servers; otherwise the launcher resolves it at install time. + if input.loader == Loader::Vanilla { + input.loader_version = None; + } else { + let requested = input.loader_version.clone(); + match scopenet_core::meta::resolve_loader_version(&state.http, input.loader, &input.mc_version, requested.as_deref()).await { + Ok(v) => input.loader_version = v, + Err(e) => tracing::warn!("couldn't resolve loader version: {e:#}"), + } + } + Ok(input) +} + +pub async fn create_instance(_: AdminUser, State(state): State, Json(input): Json) -> AppResult> { + let input = validated(&state, input).await?; + let id = store::unique_slug(&state, &input.name).await?; + let now = crate::db::now(); + let mem = input.memory.unwrap_or_default(); + sqlx::query( + "INSERT INTO instances (id, name, description, icon_url, banner_url, mc_version, loader, loader_version, source_kind, source_label, + visibility, allowed_groups, memory_min, memory_max, jvm_args, server, featured, enabled, sort, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'vanilla', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(&id) + .bind(&input.name) + .bind(&input.description) + .bind(&input.icon_url) + .bind(&input.banner_url) + .bind(&input.mc_version) + .bind(input.loader.as_str()) + .bind(&input.loader_version) + .bind(format!("Minecraft {}", input.mc_version)) + .bind(input.visibility.as_deref().unwrap_or("public")) + .bind(serde_json::to_string(&input.allowed_groups)?) + .bind(mem.min_mb as i64) + .bind(mem.max_mb as i64) + .bind(&input.jvm_args) + .bind(input.server.as_ref().map(serde_json::to_string).transpose()?) + .bind(input.featured) + .bind(input.enabled.unwrap_or(true)) + .bind(input.sort) + .bind(&now) + .bind(&now) + .execute(&state.db) + .await?; + detail(&state, id).await.map(|Json(v)| Json(v.instance)) +} + +#[derive(Serialize)] +pub struct InstanceDetail { + instance: AdminInstance, + files: Vec, +} + +pub async fn get_instance(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + detail(&state, id).await +} + +async fn detail(state: &AppState, id: String) -> AppResult> { + let row = store::get_instance(state, &id).await?; + let stats = store::file_stats(state, &id).await?; + Ok(Json(InstanceDetail { instance: row.to_admin(stats), files: store::instance_files(state, &id).await? })) +} + +pub async fn update_instance(_: AdminUser, State(state): State, Path(id): Path, Json(input): Json) -> AppResult> { + let existing = store::get_instance(&state, &id).await?; + let input = validated(&state, input).await?; + let mem = input.memory.unwrap_or_default(); + // Modpack instances keep the versions the pack declared unless the + // admin explicitly changes them here. + let label = if existing.source_kind == "vanilla" { format!("Minecraft {}", input.mc_version) } else { existing.source_label.clone() }; + sqlx::query( + "UPDATE instances SET name = ?, description = ?, icon_url = ?, banner_url = ?, mc_version = ?, loader = ?, loader_version = ?, + source_label = ?, visibility = ?, allowed_groups = ?, memory_min = ?, memory_max = ?, jvm_args = ?, server = ?, featured = ?, + enabled = ?, sort = ?, revision = revision + 1, updated_at = ? WHERE id = ?", + ) + .bind(&input.name) + .bind(&input.description) + .bind(input.icon_url.filter(|s| !s.is_empty())) + .bind(input.banner_url.filter(|s| !s.is_empty())) + .bind(&input.mc_version) + .bind(input.loader.as_str()) + .bind(&input.loader_version) + .bind(label) + .bind(input.visibility.as_deref().unwrap_or("public")) + .bind(serde_json::to_string(&input.allowed_groups)?) + .bind(mem.min_mb as i64) + .bind(mem.max_mb as i64) + .bind(&input.jvm_args) + .bind(input.server.as_ref().map(serde_json::to_string).transpose()?) + .bind(input.featured) + .bind(input.enabled.unwrap_or(true)) + .bind(input.sort) + .bind(crate::db::now()) + .bind(&id) + .execute(&state.db) + .await?; + detail(&state, id).await +} + +pub async fn delete_instance(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + store::get_instance(&state, &id).await?; + sqlx::query("DELETE FROM instances WHERE id = ?").bind(&id).execute(&state.db).await?; + let dir = state.cfg.files_dir().join(sanitize_id(&id)); + tokio::fs::remove_dir_all(dir).await.ok(); + Ok(Json(json!({ "ok": true }))) +} + +/// Drop the modpack, keeping only hand-uploaded files. +pub async fn reset_source(_: AdminUser, State(state): State, Path(id): Path) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let info = packs::PackInfo { + mc_version: row.mc_version.clone(), + loader: Loader::parse(&row.loader).unwrap_or_default(), + loader_version: row.loader_version.clone(), + ..Default::default() + }; + packs::apply(&state, &id, &info, "vanilla", &format!("Minecraft {}", row.mc_version), json!({})).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct ModrinthImport { + version_id: String, + #[serde(default)] + project_id: Option, +} + +pub async fn import_modrinth(_: AdminUser, State(state): State, Path(id): Path, Json(req): Json) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let (bytes, label, icon) = packs::fetch_modrinth(&state, &req.version_id).await?; + let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; + packs::apply(&state, &id, &info, "modrinth", &label, json!({ "version_id": req.version_id, "project_id": req.project_id })).await?; + set_icon_if_missing(&state, &row, icon).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct CurseForgeImport { + mod_id: i64, + file_id: i64, +} + +pub async fn import_curseforge(_: AdminUser, State(state): State, Path(id): Path, Json(req): Json) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let (bytes, label, icon) = packs::fetch_curseforge(&state, req.mod_id, req.file_id).await?; + let (info, _) = packs::import_zip(&state, &id, bytes, Fallback::default()).await?; + packs::apply(&state, &id, &info, "curseforge", &label, json!({ "mod_id": req.mod_id, "file_id": req.file_id })).await?; + set_icon_if_missing(&state, &row, icon).await?; + detail(&state, id).await +} + +async fn set_icon_if_missing(state: &AppState, row: &store::InstanceRow, icon: Option) -> AppResult<()> { + if row.icon_url.is_none() { + if let Some(icon) = icon { + sqlx::query("UPDATE instances SET icon_url = ? WHERE id = ?").bind(icon).bind(&row.id).execute(&state.db).await?; + } + } + Ok(()) +} + +/// Upload a .mrpack / CurseForge zip / plain instance zip. +pub async fn import_upload(_: AdminUser, State(state): State, Path(id): Path, mut form: Multipart) -> AppResult> { + store::get_instance(&state, &id).await?; + let mut bytes = None; + let mut filename = String::from("upload.zip"); + let mut fallback = Fallback::default(); + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "file" => { + filename = field.file_name().unwrap_or("upload.zip").to_string(); + bytes = Some(field.bytes().await?.to_vec()); + } + "mc_version" => fallback.mc_version = Some(field.text().await?), + "loader" => fallback.loader = Loader::parse(&field.text().await?), + "loader_version" => fallback.loader_version = Some(field.text().await?), + _ => {} + } + } + let bytes = bytes.ok_or_else(|| AppError::bad_request("no file uploaded"))?; + let (mut info, kind) = packs::import_zip(&state, &id, bytes, fallback).await?; + if kind == "zip" && info.loader != Loader::Vanilla { + info.loader_version = + scopenet_core::meta::resolve_loader_version(&state.http, info.loader, &info.mc_version, info.loader_version.as_deref()).await.ok().flatten(); + } + let label = match kind { + "modrinth" | "curseforge" if !info.name.is_empty() => format!("{} {}", info.name, info.version).trim().to_string(), + _ => format!("Uploaded · {filename}"), + }; + packs::apply(&state, &id, &info, kind, &label, json!({ "filename": filename })).await?; + detail(&state, id).await +} + +/// Add individual files (extra mods, configs, or a CurseForge mod that +/// can't be downloaded automatically). +pub async fn upload_files(_: AdminUser, State(state): State, Path(id): Path, mut form: Multipart) -> AppResult> { + store::get_instance(&state, &id).await?; + let root = state.cfg.files_dir().join(sanitize_id(&id)); + let mut folder = String::from("mods"); + let mut added = 0; + while let Some(field) = form.next_field().await? { + match field.name().unwrap_or_default() { + "folder" => folder = field.text().await?.trim().trim_matches('/').to_string(), + "file" => { + let name = field.file_name().unwrap_or("file").rsplit(['/', '\\']).next().unwrap_or("file").to_string(); + let rel = if folder.is_empty() { name.clone() } else { format!("{folder}/{name}") }; + let dest = safe_join(&root, &rel).ok_or_else(|| AppError::bad_request("invalid path"))?; + let data = field.bytes().await?; + tokio::fs::create_dir_all(dest.parent().unwrap()).await?; + tokio::fs::write(&dest, &data).await?; + let sha1 = scopenet_core::http::sha1_bytes(&data); + // Uploading a file with the same name as a "missing" CurseForge + // entry replaces it. + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND url = '' AND path LIKE ?") + .bind(&id) + .bind(format!("%/{name}")) + .execute(&state.db) + .await?; + sqlx::query( + "INSERT INTO instance_files (instance_id, path, url, sha1, size, origin) VALUES (?, ?, ?, ?, ?, 'upload') + ON CONFLICT(instance_id, path) DO UPDATE SET url = excluded.url, sha1 = excluded.sha1, size = excluded.size, origin = 'upload', note = NULL", + ) + .bind(&id) + .bind(&rel) + .bind(packs::files_url(&id, &rel)) + .bind(sha1) + .bind(data.len() as i64) + .execute(&state.db) + .await?; + added += 1; + } + _ => {} + } + } + if added == 0 { + return Err(AppError::bad_request("no files uploaded")); + } + store::bump_revision(&state, &id).await?; + detail(&state, id).await +} + +#[derive(Deserialize)] +pub struct PathQuery { + path: String, +} + +pub async fn delete_file(_: AdminUser, State(state): State, Path(id): Path, Query(q): Query) -> AppResult> { + sqlx::query("DELETE FROM instance_files WHERE instance_id = ? AND path = ?").bind(&id).bind(&q.path).execute(&state.db).await?; + store::bump_revision(&state, &id).await?; + packs::gc_files(&state, &id).await?; + detail(&state, id).await +} + +// --------------------------------------------------------------------------- +// Media uploads (logos, backgrounds, icons) +// --------------------------------------------------------------------------- + +pub async fn upload_media(_: AdminUser, State(state): State, mut form: Multipart) -> AppResult> { + while let Some(field) = form.next_field().await? { + if field.name() != Some("file") { + continue; + } + let name = field.file_name().unwrap_or_default().to_lowercase(); + let ext = name.rsplit('.').next().unwrap_or_default().to_string(); + // SVG is excluded on purpose: it can carry scripts. + if !matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp" | "ico" | "mp4" | "webm" | "avif") { + return Err(AppError::bad_request("supported: png, jpg, gif, webp, avif, ico, mp4, webm")); + } + let data = field.bytes().await?; + let file = format!("{}.{ext}", uuid::Uuid::new_v4().simple()); + tokio::fs::create_dir_all(state.cfg.uploads_dir()).await?; + tokio::fs::write(state.cfg.uploads_dir().join(&file), &data).await?; + return Ok(Json(json!({ "url": format!("/uploads/{file}") }))); + } + Err(AppError::bad_request("no file uploaded")) +} diff --git a/panel/server/src/routes/meta.rs b/panel/server/src/routes/meta.rs new file mode 100644 index 0000000..a45df78 --- /dev/null +++ b/panel/server/src/routes/meta.rs @@ -0,0 +1,79 @@ +//! Version pickers and modpack search, proxied so the browser never needs +//! API keys. + +use crate::auth::AdminUser; +use crate::error::AppResult; +use crate::packs; +use crate::state::AppState; +use axum::extract::{Path, Query, State}; +use axum::Json; +use scopenet_core::meta::{self, LoaderVersion}; +use scopenet_shared::Loader; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct McVersion { + id: String, + kind: String, + released: Option, +} + +pub async fn minecraft(_: AdminUser, State(state): State) -> AppResult> { + let m = meta::mojang_manifest(&state.http).await?; + let versions: Vec = m + .versions + .into_iter() + .map(|v| McVersion { id: v.id, kind: v.kind, released: v.release_time }) + .collect(); + Ok(Json(serde_json::json!({ "latest": m.latest, "versions": versions }))) +} + +#[derive(Deserialize)] +pub struct LoaderQuery { + mc: String, +} + +pub async fn loaders(_: AdminUser, State(state): State, Path(loader): Path, Query(q): Query) -> AppResult>> { + let loader = Loader::parse(&loader).unwrap_or_default(); + Ok(Json(meta::loader_versions(&state.http, loader, &q.mc).await?)) +} + +#[derive(Deserialize)] +pub struct SearchQuery { + #[serde(default)] + q: String, +} + +pub async fn modrinth_search(_: AdminUser, State(state): State, Query(q): Query) -> AppResult> { + let resp = state + .http + .get("https://api.modrinth.com/v2/search") + .query(&[("query", q.q.as_str()), ("facets", r#"[["project_type:modpack"]]"#), ("limit", "24"), ("index", "relevance")]) + .send() + .await + .map_err(anyhow::Error::from)? + .error_for_status() + .map_err(anyhow::Error::from)?; + Ok(Json(resp.json().await.map_err(anyhow::Error::from)?)) +} + +pub async fn modrinth_versions(_: AdminUser, State(state): State, Path(project): Path) -> AppResult>> { + let url = format!("https://api.modrinth.com/v2/project/{}/version", urlencode(&project)); + Ok(Json(scopenet_core::http::get_json(&state.http, &url).await?)) +} + +pub async fn curseforge_search(_: AdminUser, State(state): State, Query(q): Query) -> AppResult> { + let path = format!( + "/mods/search?gameId=432&classId=4471&pageSize=24&sortField=2&sortOrder=desc&searchFilter={}", + urlencode(&q.q) + ); + Ok(Json(packs::cf_raw_get(&state, &path).await?)) +} + +pub async fn curseforge_files(_: AdminUser, State(state): State, Path(mod_id): Path) -> AppResult> { + Ok(Json(packs::cf_raw_get(&state, &format!("/mods/{mod_id}/files?pageSize=40")).await?)) +} + +fn urlencode(s: &str) -> String { + percent_encoding::utf8_percent_encode(s, percent_encoding::NON_ALPHANUMERIC).to_string() +} diff --git a/panel/server/src/routes/mod.rs b/panel/server/src/routes/mod.rs new file mode 100644 index 0000000..f866887 --- /dev/null +++ b/panel/server/src/routes/mod.rs @@ -0,0 +1,46 @@ +pub mod admin; +pub mod meta; +pub mod public; + +use crate::state::AppState; +use axum::extract::DefaultBodyLimit; +use axum::routing::{delete, get, post}; +use axum::Router; + +pub fn api(state: &AppState) -> Router { + let upload_limit = state.cfg.max_upload_mb * 1024 * 1024; + + let launcher = Router::new() + .route("/launcher/manifest", get(public::manifest)) + .route("/launcher/instances/{id}", get(public::instance_manifest)) + .route("/launcher/events", post(public::event)) + .route("/auth/login", post(public::login)) + .route("/auth/register", post(public::register)) + .route("/auth/me", get(public::me)); + + let admin = Router::new() + .route("/stats", get(admin::stats)) + .route("/users", get(admin::list_users).post(admin::create_user)) + .route("/users/{id}", axum::routing::patch(admin::update_user).delete(admin::delete_user)) + .route("/groups", get(admin::list_groups).post(admin::create_group)) + .route("/groups/{id}", delete(admin::delete_group)) + .route("/branding", get(admin::get_branding).put(admin::put_branding)) + .route("/settings", get(admin::get_settings).put(admin::put_settings)) + .route("/instances", get(admin::list_instances).post(admin::create_instance)) + .route("/instances/{id}", get(admin::get_instance).put(admin::update_instance).delete(admin::delete_instance)) + .route("/instances/{id}/reset", post(admin::reset_source)) + .route("/instances/{id}/import/modrinth", post(admin::import_modrinth)) + .route("/instances/{id}/import/curseforge", post(admin::import_curseforge)) + .route("/instances/{id}/import/upload", post(admin::import_upload)) + .route("/instances/{id}/files", post(admin::upload_files).delete(admin::delete_file)) + .route("/uploads", post(admin::upload_media)) + .route("/meta/minecraft", get(meta::minecraft)) + .route("/meta/loaders/{loader}", get(meta::loaders)) + .route("/modrinth/search", get(meta::modrinth_search)) + .route("/modrinth/project/{id}/versions", get(meta::modrinth_versions)) + .route("/curseforge/search", get(meta::curseforge_search)) + .route("/curseforge/mod/{id}/files", get(meta::curseforge_files)) + .layer(DefaultBodyLimit::max(upload_limit)); + + Router::new().nest("/api/v1", launcher).nest("/api/admin", admin) +} diff --git a/panel/server/src/routes/public.rs b/panel/server/src/routes/public.rs new file mode 100644 index 0000000..84f4507 --- /dev/null +++ b/panel/server/src/routes/public.rs @@ -0,0 +1,134 @@ +//! Endpoints the launcher talks to. + +use crate::auth::{self, AuthUser, MaybeUser, UserRow}; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use crate::store; +use axum::extract::{Path, State}; +use axum::Json; +use scopenet_shared::*; + +pub async fn health() -> &'static str { + "ok" +} + +pub async fn manifest(State(state): State, MaybeUser(user): MaybeUser) -> AppResult> { + let settings = store::settings(&state).await?; + let groups = match &user { + Some(u) => auth::user_groups(&state, u.id).await?, + None => vec![], + }; + let mut instances = Vec::new(); + for row in store::list_instances(&state).await? { + if row.visible_to(user.as_ref(), &groups) { + let stats = store::file_stats(&state, &row.id).await?; + instances.push(row.to_summary(stats)); + } + } + let public_user = match &user { + Some(u) => Some(auth::public_user(&state, u).await?), + None => None, + }; + let mut auth_cfg = settings.auth; + if !auth_cfg.microsoft { + auth_cfg.microsoft_client_id = None; + } + Ok(Json(LauncherManifest { + api_version: API_VERSION, + panel_version: env!("CARGO_PKG_VERSION").into(), + branding: store::branding(&state).await?, + auth: auth_cfg, + instances, + user: public_user, + })) +} + +pub async fn instance_manifest(State(state): State, MaybeUser(user): MaybeUser, Path(id): Path) -> AppResult> { + let row = store::get_instance(&state, &id).await?; + let groups = match &user { + Some(u) => auth::user_groups(&state, u.id).await?, + None => vec![], + }; + if !row.visible_to(user.as_ref(), &groups) { + return Err(AppError::not_found("instance not found")); + } + let stats = store::file_stats(&state, &id).await?; + let files = store::to_entries(store::instance_files(&state, &id).await?); + Ok(Json(InstanceManifest { instance: row.to_summary(stats), files })) +} + +async fn find_user(state: &AppState, username: &str) -> AppResult> { + Ok(sqlx::query_as("SELECT * FROM users WHERE username = ?").bind(username.trim()).fetch_optional(&state.db).await?) +} + +pub async fn login(State(state): State, Json(req): Json) -> AppResult> { + let username = req.username.trim().to_string(); + state.login_guard.check(&username)?; + let settings = store::settings(&state).await?; + let user = find_user(&state, &username).await?; + let Some(user) = user.filter(|u| auth::verify_password(&req.password, &u.password_hash)) else { + state.login_guard.fail(&username); + return Err(AppError::unauthorized("wrong username or password")); + }; + state.login_guard.succeed(&username); + match user.status.as_str() { + "pending" => return Err(AppError::forbidden("your account is waiting for an admin to approve it")), + "disabled" => return Err(AppError::forbidden("this account has been disabled")), + _ => {} + } + if !settings.auth.panel_accounts && !user.is_admin() { + return Err(AppError::forbidden("account sign-in is currently disabled")); + } + sqlx::query("UPDATE users SET last_login = ? WHERE id = ?").bind(crate::db::now()).bind(user.id).execute(&state.db).await?; + Ok(Json(AuthResponse { token: state.keys.issue(&user)?, user: auth::public_user(&state, &user).await?, pending: false })) +} + +pub async fn register(State(state): State, Json(req): Json) -> AppResult> { + let settings = store::settings(&state).await?; + if !settings.auth.panel_accounts || settings.auth.registration == RegistrationMode::Closed { + return Err(AppError::forbidden("sign-ups are closed — ask an admin for an account")); + } + let username = req.username.trim(); + if !valid_username(username) { + return Err(AppError::bad_request("usernames are 3-16 letters, numbers or underscores")); + } + auth::validate_password(&req.password)?; + if find_user(&state, username).await?.is_some() { + return Err(AppError::conflict("that username is taken")); + } + let status = if settings.auth.registration == RegistrationMode::Approval { "pending" } else { "active" }; + let id: i64 = sqlx::query_scalar( + "INSERT INTO users (username, password_hash, email, role, status, created_at) VALUES (?, ?, ?, 'player', ?, ?) RETURNING id", + ) + .bind(username) + .bind(auth::hash_password(&req.password)?) + .bind(req.email.as_deref().map(str::trim).filter(|e| !e.is_empty())) + .bind(status) + .bind(crate::db::now()) + .fetch_one(&state.db) + .await?; + let user: UserRow = sqlx::query_as("SELECT * FROM users WHERE id = ?").bind(id).fetch_one(&state.db).await?; + let pending = status == "pending"; + Ok(Json(AuthResponse { + token: if pending { String::new() } else { state.keys.issue(&user)? }, + user: auth::public_user(&state, &user).await?, + pending, + })) +} + +pub async fn me(State(state): State, AuthUser(user): AuthUser) -> AppResult> { + Ok(Json(auth::public_user(&state, &user).await?)) +} + +pub async fn event(State(state): State, MaybeUser(user): MaybeUser, Json(ev): Json) -> AppResult> { + let kind = if ev.kind == "launch" { "launch" } else { "other" }; + let name = user.map(|u| u.username).or(ev.username).map(|n| n.chars().take(32).collect::()); + sqlx::query("INSERT INTO events (instance_id, username, kind, created_at) VALUES (?, ?, ?, ?)") + .bind(ev.instance_id.chars().take(64).collect::()) + .bind(name) + .bind(kind) + .bind(crate::db::now()) + .execute(&state.db) + .await?; + Ok(Json(serde_json::json!({ "ok": true }))) +} diff --git a/panel/server/src/state.rs b/panel/server/src/state.rs new file mode 100644 index 0000000..aeb078c --- /dev/null +++ b/panel/server/src/state.rs @@ -0,0 +1,13 @@ +use crate::auth::{Keys, LoginGuard}; +use crate::config::Config; +use sqlx::SqlitePool; +use std::sync::Arc; + +#[derive(Clone)] +pub struct AppState { + pub db: SqlitePool, + pub cfg: Arc, + pub keys: Arc, + pub http: reqwest::Client, + pub login_guard: Arc, +} diff --git a/panel/server/src/store.rs b/panel/server/src/store.rs new file mode 100644 index 0000000..b504632 --- /dev/null +++ b/panel/server/src/store.rs @@ -0,0 +1,273 @@ +//! Persistence helpers: key/value settings and instances. + +use crate::auth::UserRow; +use crate::error::{AppError, AppResult}; +use crate::state::AppState; +use scopenet_shared::{AuthConfig, Branding, FileEntry, InstanceSummary, Loader, MemoryDefaults, ServerEntry}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +pub async fn kv_get(state: &AppState, key: &str) -> AppResult { + let raw: Option = sqlx::query_scalar("SELECT value FROM kv WHERE key = ?").bind(key).fetch_optional(&state.db).await?; + Ok(raw.and_then(|r| serde_json::from_str(&r).ok()).unwrap_or_default()) +} + +pub async fn kv_set(state: &AppState, key: &str, value: &T) -> AppResult<()> { + sqlx::query("INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value") + .bind(key) + .bind(serde_json::to_string(value)?) + .execute(&state.db) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct Settings { + pub auth: AuthConfig, + pub curseforge_api_key: Option, + /// Where players can download the launcher (shown on the dashboard). + pub launcher_download_url: Option, +} + +pub async fn settings(state: &AppState) -> AppResult { + kv_get(state, "settings").await +} + +pub async fn branding(state: &AppState) -> AppResult { + kv_get(state, "branding").await +} + +/// Environment variable wins over the value saved in the panel. +pub async fn curseforge_key(state: &AppState) -> AppResult { + if let Some(k) = &state.cfg.curseforge_api_key { + return Ok(k.clone()); + } + settings(state) + .await? + .curseforge_api_key + .filter(|k| !k.is_empty()) + .ok_or_else(|| AppError::bad_request("add a CurseForge API key in Settings first (get one at console.curseforge.com)")) +} + +// --------------------------------------------------------------------------- +// Instances +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct InstanceRow { + pub id: String, + pub name: String, + pub description: String, + pub icon_url: Option, + pub banner_url: Option, + pub mc_version: String, + pub loader: String, + pub loader_version: Option, + pub source_kind: String, + pub source_label: String, + pub source_ref: String, + pub visibility: String, + pub allowed_groups: String, + pub memory_min: i64, + pub memory_max: i64, + pub jvm_args: String, + pub server: Option, + pub featured: bool, + pub enabled: bool, + pub sort: i64, + pub revision: i64, + pub created_at: String, + pub updated_at: String, +} + +/// Full instance as seen by admins. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct AdminInstance { + pub id: String, + pub name: String, + pub description: String, + pub icon_url: Option, + pub banner_url: Option, + pub mc_version: String, + pub loader: Loader, + pub loader_version: Option, + pub source_kind: String, + pub source_label: String, + pub source_ref: serde_json::Value, + /// "public" | "members" | "groups" + pub visibility: String, + pub allowed_groups: Vec, + pub memory: MemoryDefaults, + pub jvm_args: String, + pub server: Option, + pub featured: bool, + pub enabled: bool, + pub sort: i64, + pub revision: i64, + pub created_at: String, + pub updated_at: String, + pub file_count: u32, + pub total_size: u64, + pub missing_count: u32, +} + +impl InstanceRow { + pub fn to_admin(&self, stats: FileStats) -> AdminInstance { + AdminInstance { + id: self.id.clone(), + name: self.name.clone(), + description: self.description.clone(), + icon_url: self.icon_url.clone(), + banner_url: self.banner_url.clone(), + mc_version: self.mc_version.clone(), + loader: Loader::parse(&self.loader).unwrap_or_default(), + loader_version: self.loader_version.clone(), + source_kind: self.source_kind.clone(), + source_label: self.source_label.clone(), + source_ref: serde_json::from_str(&self.source_ref).unwrap_or_default(), + visibility: self.visibility.clone(), + allowed_groups: serde_json::from_str(&self.allowed_groups).unwrap_or_default(), + memory: MemoryDefaults { min_mb: self.memory_min as u32, max_mb: self.memory_max as u32 }, + jvm_args: self.jvm_args.clone(), + server: self.server.as_deref().and_then(|s| serde_json::from_str(s).ok()), + featured: self.featured, + enabled: self.enabled, + sort: self.sort, + revision: self.revision, + created_at: self.created_at.clone(), + updated_at: self.updated_at.clone(), + file_count: stats.count, + total_size: stats.size, + missing_count: stats.missing, + } + } + + pub fn to_summary(&self, stats: FileStats) -> InstanceSummary { + let a = self.to_admin(stats); + InstanceSummary { + id: a.id, + name: a.name, + description: a.description, + icon_url: a.icon_url, + banner_url: a.banner_url, + mc_version: a.mc_version, + loader: a.loader, + loader_version: a.loader_version, + revision: a.revision, + server: a.server.filter(|s| !s.address.trim().is_empty()), + memory: a.memory, + jvm_args: a.jvm_args, + featured: a.featured, + source_label: a.source_label, + file_count: a.file_count, + total_size: a.total_size, + } + } + + /// Can this (possibly anonymous) user see the instance? + pub fn visible_to(&self, user: Option<&UserRow>, groups: &[String]) -> bool { + if !self.enabled { + return false; + } + if user.is_some_and(|u| u.is_admin()) { + return true; + } + match self.visibility.as_str() { + "public" => true, + "members" => user.is_some(), + "groups" => { + let allowed: Vec = serde_json::from_str(&self.allowed_groups).unwrap_or_default(); + user.is_some() && allowed.iter().any(|g| groups.iter().any(|x| x.eq_ignore_ascii_case(g))) + } + _ => false, + } + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct FileStats { + pub count: u32, + pub size: u64, + pub missing: u32, +} + +pub async fn file_stats(state: &AppState, instance_id: &str) -> AppResult { + let (count, size, missing): (i64, Option, Option) = sqlx::query_as( + "SELECT COUNT(*), SUM(size), SUM(CASE WHEN url = '' THEN 1 ELSE 0 END) FROM instance_files WHERE instance_id = ?", + ) + .bind(instance_id) + .fetch_one(&state.db) + .await?; + Ok(FileStats { count: count as u32, size: size.unwrap_or(0) as u64, missing: missing.unwrap_or(0) as u32 }) +} + +pub async fn get_instance(state: &AppState, id: &str) -> AppResult { + sqlx::query_as("SELECT * FROM instances WHERE id = ?") + .bind(id) + .fetch_optional(&state.db) + .await? + .ok_or_else(|| AppError::not_found("instance not found")) +} + +pub async fn list_instances(state: &AppState) -> AppResult> { + Ok(sqlx::query_as("SELECT * FROM instances ORDER BY featured DESC, sort ASC, name COLLATE NOCASE ASC").fetch_all(&state.db).await?) +} + +pub async fn bump_revision(state: &AppState, id: &str) -> AppResult<()> { + sqlx::query("UPDATE instances SET revision = revision + 1, updated_at = ? WHERE id = ?") + .bind(crate::db::now()) + .bind(id) + .execute(&state.db) + .await?; + Ok(()) +} + +#[derive(Debug, Clone, sqlx::FromRow, Serialize)] +pub struct FileRow { + pub path: String, + pub url: String, + pub sha1: String, + pub size: i64, + pub origin: String, + pub note: Option, +} + +pub async fn instance_files(state: &AppState, id: &str) -> AppResult> { + Ok(sqlx::query_as("SELECT path, url, sha1, size, origin, note FROM instance_files WHERE instance_id = ? ORDER BY path") + .bind(id) + .fetch_all(&state.db) + .await?) +} + +pub fn to_entries(files: Vec) -> Vec { + files + .into_iter() + .filter(|f| !f.url.is_empty()) + .map(|f| FileEntry { path: f.path, url: f.url, sha1: f.sha1, size: f.size as u64 }) + .collect() +} + +/// URL-safe, human-readable id from a name, made unique. +pub async fn unique_slug(state: &AppState, name: &str) -> AppResult { + let mut base: String = name + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + base.truncate(40); + if base.is_empty() { + base = "instance".into(); + } + let mut slug = base.clone(); + let mut n = 2; + while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM instances WHERE id = ?").bind(&slug).fetch_one(&state.db).await? > 0 { + slug = format!("{base}-{n}"); + n += 1; + } + Ok(slug) +} diff --git a/panel/server/tests/api.rs b/panel/server/tests/api.rs new file mode 100644 index 0000000..f008af4 --- /dev/null +++ b/panel/server/tests/api.rs @@ -0,0 +1,287 @@ +//! End-to-end tests against the real router with an in-memory database. + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use scopenet_panel::{app, bootstrap_admin, build_state, config::Config, db}; +use serde_json::{json, Value}; +use std::io::Write; +use tower::ServiceExt; + +struct TestApp { + router: axum::Router, + _dir: tempfile::TempDir, +} + +async fn setup() -> TestApp { + let dir = tempfile::tempdir().unwrap(); + let cfg = Config { + bind: "127.0.0.1:0".into(), + data_dir: dir.path().to_path_buf(), + web_dir: dir.path().join("web"), + admin_username: "admin".into(), + admin_password: Some("supersecret".into()), + jwt_secret: Some("test-secret-test-secret-test-secret".into()), + curseforge_api_key: None, + max_upload_mb: 64, + }; + let pool = db::connect_memory().await.unwrap(); + let state = build_state(cfg, pool).await.unwrap(); + bootstrap_admin(&state).await.unwrap(); + TestApp { router: app(state), _dir: dir } +} + +impl TestApp { + async fn call(&self, method: &str, uri: &str, token: Option<&str>, body: Option) -> (StatusCode, Value) { + let mut req = Request::builder().method(method).uri(uri); + if let Some(t) = token { + req = req.header("authorization", format!("Bearer {t}")); + } + let req = match body { + Some(b) => req.header("content-type", "application/json").body(Body::from(b.to_string())).unwrap(), + None => req.body(Body::empty()).unwrap(), + }; + self.send(req).await + } + + async fn send(&self, req: Request) -> (StatusCode, Value) { + let resp = self.router.clone().oneshot(req).await.unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice(&bytes).unwrap_or(Value::String(String::from_utf8_lossy(&bytes).into()))) + } + + async fn login(&self, user: &str, pass: &str) -> String { + let (s, v) = self.call("POST", "/api/v1/auth/login", None, Some(json!({"username": user, "password": pass}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + v["token"].as_str().unwrap().to_string() + } +} + +fn multipart(fields: &[(&str, &str)], file: (&str, &[u8])) -> (String, Vec) { + let boundary = "----scopenettest"; + let mut body = Vec::new(); + for (k, v) in fields { + write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"{k}\"\r\n\r\n{v}\r\n").unwrap(); + } + write!(body, "--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n", file.0).unwrap(); + body.extend_from_slice(file.1); + write!(body, "\r\n--{boundary}--\r\n").unwrap(); + (format!("multipart/form-data; boundary={boundary}"), body) +} + +fn zip_bytes(entries: &[(&str, &[u8])]) -> Vec { + let mut buf = std::io::Cursor::new(Vec::new()); + { + let mut z = zip::ZipWriter::new(&mut buf); + let opts = zip::write::SimpleFileOptions::default(); + for (name, data) in entries { + z.start_file(*name, opts).unwrap(); + z.write_all(data).unwrap(); + } + z.finish().unwrap(); + } + buf.into_inner() +} + +#[tokio::test] +async fn health_and_default_manifest() { + let t = setup().await; + let (s, v) = t.call("GET", "/healthz", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v, "ok"); + let (s, v) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["branding"]["name"], "ScopeNet"); + assert_eq!(v["api_version"], 1); + assert!(v["user"].is_null()); +} + +#[tokio::test] +async fn admin_only_routes_are_guarded() { + let t = setup().await; + let (s, _) = t.call("GET", "/api/admin/users", None, None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + let (s, _) = t.call("GET", "/api/admin/users", Some("garbage"), None).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + + let admin = t.login("admin", "supersecret").await; + let (s, v) = t + .call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Steve", "password": "password123"}))) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["uuid"], scopenet_shared::offline_uuid("Steve")); + + let player = t.login("steve", "password123").await; // usernames are case-insensitive + let (s, _) = t.call("GET", "/api/admin/users", Some(&player), None).await; + assert_eq!(s, StatusCode::FORBIDDEN); + let (s, v) = t.call("GET", "/api/v1/auth/me", Some(&player), None).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["username"], "Steve"); +} + +#[tokio::test] +async fn wrong_password_and_lockout() { + let t = setup().await; + for _ in 0..10 { + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "nope"}))).await; + assert_eq!(s, StatusCode::UNAUTHORIZED); + } + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "admin", "password": "supersecret"}))).await; + assert_eq!(s, StatusCode::TOO_MANY_REQUESTS); +} + +#[tokio::test] +async fn registration_modes() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let reg = json!({"username": "Alex", "password": "password123"}); + let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await; + assert_eq!(s, StatusCode::FORBIDDEN, "closed by default"); + + let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"registration": "approval"}}))).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let (s, v) = t.call("POST", "/api/v1/auth/register", None, Some(reg.clone())).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["pending"], true); + let (s, _) = t.call("POST", "/api/v1/auth/login", None, Some(json!({"username": "Alex", "password": "password123"}))).await; + assert_eq!(s, StatusCode::FORBIDDEN, "pending accounts can't sign in"); + + let (s, _) = t.call("POST", "/api/v1/auth/register", None, Some(reg)).await; + assert_eq!(s, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn instance_visibility_and_zip_upload() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, _) = t.call("POST", "/api/admin/groups", Some(&admin), Some(json!({"name": "VIP"}))).await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Vip1", "password": "password123", "groups": ["VIP"]}))).await; + t.call("POST", "/api/admin/users", Some(&admin), Some(json!({"username": "Pleb", "password": "password123"}))).await; + + let (s, v) = t + .call( + "POST", + "/api/admin/instances", + Some(&admin), + Some(json!({"name": "VIP Survival!", "mc_version": "1.21.1", "visibility": "groups", "allowed_groups": ["VIP"], + "server": {"name": "SMP", "address": "play.example.net", "port": 25565, "auto_join": true, "inject": true}})), + ) + .await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["id"], "vip-survival"); + let (_, v) = t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Public", "mc_version": "1.20.1"}))).await; + assert_eq!(v["id"], "public"); + + let names = |v: &Value| v["instances"].as_array().unwrap().iter().map(|i| i["id"].as_str().unwrap().to_string()).collect::>(); + let (_, anon) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(names(&anon), vec!["public"]); + let vip = t.login("Vip1", "password123").await; + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&vip), None).await; + assert_eq!(names(&m).len(), 2); + assert_eq!(m["user"]["groups"][0], "VIP"); + let pleb = t.login("Pleb", "password123").await; + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", Some(&pleb), None).await; + assert_eq!(names(&m), vec!["public"]); + let (s, _) = t.call("GET", "/api/v1/launcher/instances/vip-survival", Some(&pleb), None).await; + assert_eq!(s, StatusCode::NOT_FOUND); + + // Plain zip without version info → needs the fallback fields. + let zip = zip_bytes(&[("MyPack/mods/cool.jar", b"jarjar"), ("MyPack/config/x.toml", b"a=1"), ("MyPack/logs/latest.log", b"junk")]); + let (ct, body) = multipart(&[], ("pack.zip", &zip)); + let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "{v}"); + + let (ct, body) = multipart(&[("mc_version", "1.20.1"), ("loader", "vanilla")], ("pack.zip", &zip)); + let req = Request::post("/api/admin/instances/public/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{v}"); + let paths: Vec<&str> = v["files"].as_array().unwrap().iter().map(|f| f["path"].as_str().unwrap()).collect(); + assert_eq!(paths, vec!["config/x.toml", "mods/cool.jar"]); + assert_eq!(v["instance"]["revision"], 2); + + // Launcher sees the files and can download them. + let (_, im) = t.call("GET", "/api/v1/launcher/instances/public", None, None).await; + let url = im["files"][1]["url"].as_str().unwrap().to_string(); + assert_eq!(url, "/files/public/mods/cool.jar"); + assert_eq!(im["files"][1]["sha1"], scopenet_core::http::sha1_bytes(b"jarjar")); + let resp = t.router.clone().oneshot(Request::get(&url).body(Body::empty()).unwrap()).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX).await.unwrap(); + assert_eq!(&bytes[..], b"jarjar"); +} + +#[tokio::test] +async fn mrpack_upload_sets_versions_and_overrides() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + t.call("POST", "/api/admin/instances", Some(&admin), Some(json!({"name": "Pack", "mc_version": "1.20.1"}))).await; + let index = json!({ + "formatVersion": 1, "game": "minecraft", "versionId": "6.2.0", "name": "Fabulous", + "files": [ + {"path": "mods/sodium.jar", "hashes": {"sha1": "aaa", "sha512": "x"}, "downloads": ["https://cdn.modrinth.com/sodium.jar"], "fileSize": 10}, + {"path": "mods/server-only.jar", "hashes": {"sha1": "bbb"}, "env": {"client": "unsupported", "server": "required"}, "downloads": ["https://cdn.modrinth.com/s.jar"], "fileSize": 5}, + {"path": "../escape.jar", "hashes": {"sha1": "ccc"}, "downloads": ["https://x/e.jar"], "fileSize": 1} + ], + "dependencies": {"minecraft": "1.21.1", "fabric-loader": "0.16.9"} + }); + let zip = zip_bytes(&[ + ("modrinth.index.json", index.to_string().as_bytes()), + ("overrides/options.txt", b"fov:0.5"), + ("overrides/config/a.json", b"{}"), + ("client-overrides/config/a.json", b"{\"client\":1}"), + ]); + let (ct, body) = multipart(&[], ("fab.mrpack", &zip)); + let req = Request::post("/api/admin/instances/pack/import/upload").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK, "{v}"); + assert_eq!(v["instance"]["mc_version"], "1.21.1"); + assert_eq!(v["instance"]["loader"], "fabric"); + assert_eq!(v["instance"]["loader_version"], "0.16.9"); + assert_eq!(v["instance"]["source_kind"], "modrinth"); + assert_eq!(v["instance"]["source_label"], "Fabulous 6.2.0"); + let files = v["files"].as_array().unwrap(); + let paths: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + assert_eq!(paths, vec!["config/a.json", "mods/sodium.jar", "options.txt"]); + let cfg = files.iter().find(|f| f["path"] == "config/a.json").unwrap(); + assert_eq!(cfg["sha1"], scopenet_core::http::sha1_bytes(b"{\"client\":1}"), "client-overrides win"); +} + +#[tokio::test] +async fn branding_roundtrip_and_media_validation() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (_, mut b) = t.call("GET", "/api/admin/branding", Some(&admin), None).await; + b["name"] = json!("Craftopia"); + b["colors"]["accent"] = json!("#ff5500"); + let (s, _) = t.call("PUT", "/api/admin/branding", Some(&admin), Some(b)).await; + assert_eq!(s, StatusCode::OK); + let (_, m) = t.call("GET", "/api/v1/launcher/manifest", None, None).await; + assert_eq!(m["branding"]["name"], "Craftopia"); + assert_eq!(m["branding"]["colors"]["accent"], "#ff5500"); + + let (ct, body) = multipart(&[], ("evil.svg", b"")); + let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let (s, _) = t.send(req).await; + assert_eq!(s, StatusCode::BAD_REQUEST); + let (ct, body) = multipart(&[], ("logo.png", b"\x89PNG")); + let req = Request::post("/api/admin/uploads").header("authorization", format!("Bearer {admin}")).header("content-type", &ct).body(Body::from(body)).unwrap(); + let (s, v) = t.send(req).await; + assert_eq!(s, StatusCode::OK); + assert!(v["url"].as_str().unwrap().starts_with("/uploads/")); +} + +#[tokio::test] +async fn settings_never_leak_curseforge_key() { + let t = setup().await; + let admin = t.login("admin", "supersecret").await; + let (s, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": "secret-key"}))).await; + assert_eq!(s, StatusCode::OK); + assert_eq!(v["curseforge_key_set"], true); + assert!(v["curseforge_api_key"].is_null()); + // Saving again with an empty key keeps it. + let (_, v) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"curseforge_api_key": ""}))).await; + assert_eq!(v["curseforge_key_set"], true); + let (s, _) = t.call("PUT", "/api/admin/settings", Some(&admin), Some(json!({"auth": {"microsoft": true}}))).await; + assert_eq!(s, StatusCode::BAD_REQUEST, "MS needs a client id"); +}