From bba448e3294f00c27db481fec797438b690457c1 Mon Sep 17 00:00:00 2001 From: Wind-Explorer Date: Mon, 10 Aug 2026 21:55:52 +0800 Subject: [PATCH] init --- .dockerignore | 5 + .gitignore | 5 + Cargo.lock | 1718 +++++++++++++++++ Cargo.toml | 40 + Dockerfile | 23 + PRD.md | 1931 +++++++++++++++++++ README.md | 42 + compose.example.yml | 46 + fixtures/join_sequences.log | 10 + frontend/dist/assets/index-A-Ec5phf.css | 1 + frontend/dist/assets/index-CFRqyw3R.js | 2 + frontend/dist/index.html | 3 + frontend/index.html | 1 + frontend/package-lock.json | 2326 +++++++++++++++++++++++ frontend/package.json | 1 + frontend/src/App.svelte | 12 + frontend/src/lib.test.ts | 3 + frontend/src/lib.ts | 4 + frontend/src/main.ts | 1 + frontend/src/style.css | 1 + frontend/tsconfig.json | 1 + frontend/vite.config.ts | 3 + frontend/vitest.config.ts | 3 + src/auth/client_ip.rs | 84 + src/auth/join_tracker.rs | 109 ++ src/auth/mod.rs | 169 ++ src/auth/whitelist.rs | 75 + src/config.rs | 174 ++ src/frontend.rs | 45 + src/http/mod.rs | 185 ++ src/lib.rs | 5 + src/logs/archive.rs | 122 ++ src/logs/cursor.rs | 46 + src/logs/follower.rs | 366 ++++ src/logs/index.rs | 281 +++ src/logs/mod.rs | 32 + src/logs/redact.rs | 21 + src/main.rs | 73 + tests/deployment_contract.rs | 21 + tests/http_security.rs | 171 ++ tests/parser_fixtures.rs | 12 + 41 files changed, 8173 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 PRD.md create mode 100644 README.md create mode 100644 compose.example.yml create mode 100644 fixtures/join_sequences.log create mode 100644 frontend/dist/assets/index-A-Ec5phf.css create mode 100644 frontend/dist/assets/index-CFRqyw3R.js create mode 100644 frontend/dist/index.html create mode 100644 frontend/index.html create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/App.svelte create mode 100644 frontend/src/lib.test.ts create mode 100644 frontend/src/lib.ts create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/style.css create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 frontend/vitest.config.ts create mode 100644 src/auth/client_ip.rs create mode 100644 src/auth/join_tracker.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/whitelist.rs create mode 100644 src/config.rs create mode 100644 src/frontend.rs create mode 100644 src/http/mod.rs create mode 100644 src/lib.rs create mode 100644 src/logs/archive.rs create mode 100644 src/logs/cursor.rs create mode 100644 src/logs/follower.rs create mode 100644 src/logs/index.rs create mode 100644 src/logs/mod.rs create mode 100644 src/logs/redact.rs create mode 100644 src/main.rs create mode 100644 tests/deployment_contract.rs create mode 100644 tests/http_security.rs create mode 100644 tests/parser_fixtures.rs diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..df7dc99 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +target +frontend/node_modules +.git +.DS_Store +PRD.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c55a007 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +/frontend/node_modules +/frontend/.svelte-kit +.DS_Store + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d53630a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1718 @@ +# 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 = "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 = "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", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite 0.29.0", + "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 = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[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 = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[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 = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[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 = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[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 = "inotify" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags", + "libc", +] + +[[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 = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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 = "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 = "minecraft-log-viewer" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "base64", + "chrono", + "flate2", + "futures-util", + "http", + "ipnet", + "mime_guess", + "notify", + "once_cell", + "regex", + "rust-embed", + "serde", + "serde_json", + "sha2 0.10.9", + "tar", + "tempfile", + "thiserror", + "tokio", + "tokio-tungstenite 0.27.0", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +dependencies = [ + "bitflags", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.60.2", +] + +[[package]] +name = "notify-types" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" +dependencies = [ + "bitflags", +] + +[[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-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_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 = "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 = "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 = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 0.10.7", +] + +[[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 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +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" + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[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.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "489a59b6730eda1b0171fcfda8b121f4bee2b35cba8645ca35c5f7ba3eb736c1" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.27.0", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite 0.29.0", +] + +[[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 = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + +[[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-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "tungstenite" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadc29d668c91fcc564941132e17b28a7ceb2f3ebf0b9dae3e03fd7a6748eb0d" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[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.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +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.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "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.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..49d4570 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "minecraft-log-viewer" +version = "0.1.0" +edition = "2021" +license = "MIT" + +[dependencies] +anyhow = "1" +axum = { version = "0.8", features = ["ws"] } +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +flate2 = "1" +futures-util = "0.3" +http = "1" +ipnet = "2" +mime_guess = "2" +notify = "8" +once_cell = "1" +regex = "1" +rust-embed = "8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.10" +tar = "0.4" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["catch-panic", "limit", "request-id", "set-header", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } +tokio-tungstenite = "0.27" + +[profile.release] +lto = "thin" +strip = true +codegen-units = 1 + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af487df --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1.7 +FROM node:22-alpine AS frontend +WORKDIR /build/frontend +COPY frontend/package.json frontend/package-lock.json* ./ +RUN npm ci --no-audit --no-fund +COPY frontend/ ./ +RUN npm run check && npm test && npm run build + +FROM rust:1.88-bookworm AS backend +WORKDIR /build +COPY Cargo.toml Cargo.lock* ./ +COPY src ./src +COPY --from=frontend /build/frontend/dist ./frontend/dist +RUN cargo test --release && cargo build --release --bin minecraft-log-viewer + +FROM debian:bookworm-slim AS runtime +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* \ + && groupadd --system --gid 10001 viewer && useradd --system --uid 10001 --gid viewer --home-dir /nonexistent --shell /usr/sbin/nologin viewer +COPY --from=backend /build/target/release/minecraft-log-viewer /usr/local/bin/minecraft-log-viewer +USER 10001:10001 +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 CMD ["curl","--fail","--silent","http://127.0.0.1:8080/api/status"] +ENTRYPOINT ["/usr/local/bin/minecraft-log-viewer"] diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..1a4aeac --- /dev/null +++ b/PRD.md @@ -0,0 +1,1931 @@ +# Product Requirements Document +## Minecraft Server Live Log Viewer + +**Status:** Draft for implementation +**Target platform:** Fedora Linux +**Minecraft deployment:** `itzg/docker-minecraft-server` +**Application stack:** Rust + Axum + Tokio backend, Svelte frontend +**Deployment model:** Single application service with embedded static frontend assets +**Reverse proxy:** Existing external Caddy instance +**Primary mode:** Read-only log viewing + +--- + +# 1. Product Summary + +The Minecraft Server Live Log Viewer is a private, read-only web application that allows approved Minecraft players to view current and historical server logs from a browser. + +The application must: + +- stream new Minecraft log lines to connected browsers in real time; +- avoid periodic polling for live log updates; +- expose historical logs through incremental, on-demand loading; +- restrict access using the source IP address associated with successfully joined, whitelisted Minecraft players; +- infer authorization only from successful player joins, not from failed login attempts; +- use the Minecraft whitelist as an additional source of truth; +- access all Minecraft data read-only; +- require no database or separate frontend runtime; +- run efficiently as a small Linux service/container; +- cooperate with an existing Caddy reverse proxy. + +The application is not intended to provide strong identity authentication. IP-based access control is explicitly considered a lightweight privacy barrier suitable for non-critical read-only data. + +--- + +# 2. Goals + +The application should provide a convenient live log viewer for trusted Minecraft players without exposing server logs publicly. + +Primary goals are: + +1. Provide near-real-time display of new lines from `latest.log`. +2. Load historical logs seamlessly when the user scrolls backwards. +3. Use Linux filesystem notifications rather than timer-based polling. +4. Authorize viewers based on IP addresses observed during successful Minecraft joins. +5. Confirm that a player associated with an IP is currently whitelisted. +6. Avoid granting access based on rejected, banned, failed, or otherwise unsuccessful connections. +7. Keep deployment operationally simple. +8. Avoid unnecessary infrastructure such as Redis, SQL databases, Node.js runtime services, or background polling daemons. +9. Prevent the application from modifying Minecraft server state. +10. Remain resilient across Minecraft log rotation, Minecraft restarts, viewer restarts, and archive creation. + +--- + +# 3. Non-Goals + +The first version is not intended to provide: + +- Minecraft server administration; +- RCON access; +- command execution; +- log editing; +- file modification; +- player moderation; +- user accounts; +- password authentication; +- OAuth; +- durable per-user preferences; +- full-text search over an arbitrarily large log corpus; +- analytics; +- metrics dashboards; +- server console input; +- strong proof that a web visitor is the exact Minecraft account associated with an IP; +- public access; +- multi-server aggregation. + +These may be considered separately in future versions. + +--- + +# 4. Threat Model + +## 4.1 Protected Information + +The logs may contain: + +- player usernames; +- UUIDs; +- player IP addresses; +- connection timestamps; +- chat messages; +- server internals; +- plugin/mod information; +- filesystem or diagnostic information emitted by mods; +- administrative actions; +- server errors. + +The information is not considered security-critical, but it should not be exposed indiscriminately to the public Internet. + +## 4.2 Security Objective + +The intended security property is: + +> A random Internet visitor who has not recently connected successfully to the Minecraft server from an IP associated with a currently whitelisted player should not be able to view the log interface. + +This is a privacy barrier, not strong authentication. + +## 4.3 Known Limitations of IP-Based Authorization + +The implementation must explicitly recognize these limitations: + +- multiple people behind the same NAT may share one public IP; +- VPN users may share egress IPs; +- mobile and residential IPs may change; +- a reassigned dynamic IP may later belong to an unrelated party; +- IPv6 privacy addresses may change frequently; +- proxies can obscure true client addresses; +- multiple Minecraft players may legitimately appear under one address; +- possession of an IP address does not prove possession of a Minecraft account. + +The application must therefore avoid presenting this mechanism as account-level authentication. + +--- + +# 5. High-Level Architecture + +The deployment architecture is: + +```text +Browser + │ + │ HTTPS + WebSocket + ▼ +Existing Caddy instance + │ + │ trusted reverse-proxy connection + ▼ +Minecraft Log Viewer + ├── Axum HTTP server + ├── WebSocket endpoint + ├── authorization service + ├── Minecraft log parser + ├── historical log index + ├── live log follower + ├── filesystem watcher + ├── optional in-memory archive cache + └── embedded Svelte static assets + │ + │ read-only + ▼ +Minecraft /data volume + ├── whitelist.json + └── logs/ + ├── latest.log + └── historical archives +``` + +The viewer should run independently of the Minecraft process. + +No communication with Docker itself is required. + +The Docker socket must not be mounted into the viewer container. + +--- + +# 6. Technology Selection + +## 6.1 Backend + +Use: + +- Rust; +- Tokio; +- Axum; +- Tower middleware where appropriate. + +Responsibilities include: + +- HTTP routing; +- WebSocket lifecycle; +- client IP extraction; +- authorization; +- filesystem event handling; +- live file following; +- historical log indexing; +- archive decompression; +- log chunk retrieval; +- frontend static asset serving. + +## 6.2 Frontend + +Use: + +- Svelte; +- TypeScript; +- Vite. + +The frontend should compile to static assets. + +Those static files should be embedded into the Rust binary or otherwise bundled into the final application image so that a separate frontend server is unnecessary. + +## 6.3 Linux Filesystem Events + +Use Rust's `notify` ecosystem or equivalent abstraction backed by Linux `inotify`. + +The application must not periodically poll `latest.log` for new lines. + +Polling may be used only where filesystem event semantics make it unavoidable for exceptional recovery paths, and must not constitute the normal log-following mechanism. + +## 6.4 WebSocket Transport + +Use native Axum WebSocket support. + +The WebSocket is used only for server-to-client live updates and optionally lightweight control messages. + +Historical pagination should use ordinary HTTP requests unless implementation simplicity strongly favors multiplexing both mechanisms. + +--- + +# 7. Filesystem Layout + +The application should support configurable paths rather than hard-coded ones. + +Recommended defaults: + +```text +/data/logs/latest.log +/data/logs/ +/data/whitelist.json +``` + +Environment configuration should permit overrides. + +Example: + +```text +MC_DATA_DIR=/data +MC_LOG_DIR=/data/logs +MC_LATEST_LOG=/data/logs/latest.log +MC_WHITELIST=/data/whitelist.json +``` + +The viewer must work with the Minecraft data directory mounted read-only. + +Example Docker mount: + +```text +minecraft-data:/data:ro +``` + +--- + +# 8. Log Formats and Archive Discovery + +The viewer must not assume that all historical log files use one exact filename extension without configuration or detection. + +The indexer should support at least: + +- plain `.log`; +- `.log.gz`; +- `.gz`; +- `.tar.gz` where applicable. + +The implementation should inspect actual filenames and archive contents before deciding how to read them. + +Archive readers should use streaming decompression rather than extracting archives to disk. + +Temporary extraction into the Minecraft data volume is forbidden. + +If temporary files are ever required, they must reside exclusively within an application-controlled temporary directory and never modify `/data`. + +--- + +# 9. Historical Log Index + +## 9.1 Startup Behavior + +At startup, the viewer should scan the configured log directory and create an in-memory metadata index. + +The startup scan should identify: + +- filename; +- path; +- archive type; +- compressed size; +- modification timestamp; +- ordering relative to other log files; +- best-known log time range where inexpensive to determine. + +The application should not eagerly decompress the complete historical archive corpus into memory. + +## 9.2 Ordering + +Historical logs must be presented as one logical chronological stream. + +The backend must establish a deterministic ordering. + +Where filenames contain timestamps, use those timestamps. + +When filenames are ambiguous, fall back to metadata and/or timestamps parsed from content. + +The implementation should be resilient to overlapping time ranges between rotated files. + +## 9.3 Archive Cache + +An optional in-memory LRU cache may retain recently decompressed historical logs. + +The cache should have explicit resource bounds. + +Recommended configuration: + +```text +ARCHIVE_CACHE_MAX_BYTES +ARCHIVE_CACHE_MAX_FILES +``` + +Eviction should occur automatically. + +The application must remain correct if caching is disabled. + +--- + +# 10. Historical Log Retrieval + +Historical logs must be loaded incrementally. + +The browser should initially receive only a bounded recent window. + +Recommended initial target: + +```text +500–2,000 lines +``` + +The exact default should be configurable. + +When the user scrolls toward the top, the frontend requests older data. + +Conceptual API: + +```http +GET /api/logs/history?before=&limit=1000 +``` + +Response: + +```json +{ + "lines": [ + { + "id": "...", + "timestamp": "...", + "text": "...", + "source": "..." + } + ], + "next_before": "...", + "has_more": true +} +``` + +Cursor-based pagination is preferred over page numbers. + +A cursor should encode a stable logical position rather than exposing arbitrary filesystem paths. + +Clients must not be allowed to request arbitrary files. + +--- + +# 11. Live Log Following + +## 11.1 Core Requirement + +New lines written to `latest.log` must appear in connected browsers without periodic polling. + +Expected flow: + +```text +filesystem modification + │ + ▼ +inotify event + │ + ▼ +read latest.log from previous byte offset + │ + ▼ +buffer incomplete trailing line if necessary + │ + ▼ +emit completed lines + │ + ├── update authorization state if relevant + │ + └── broadcast to WebSocket clients +``` + +## 11.2 Byte Offset Tracking + +The live follower must maintain: + +- currently opened file identity; +- current byte offset; +- partial-line buffer; +- relevant file metadata. + +Only newly appended bytes should normally be read. + +The file must not be reread from the beginning on every modification. + +## 11.3 Partial Writes + +A filesystem modification event does not guarantee that the last byte is a newline. + +The implementation must retain incomplete line fragments until more bytes arrive. + +No partial log line should be emitted to clients as a complete line. + +## 11.4 Directory Watching + +Watch the containing log directory rather than relying exclusively on an open watch against `latest.log`. + +This is required because Minecraft may rotate, rename, replace, delete, or recreate the file. + +The watcher should react to events including: + +- modify; +- create; +- move/rename; +- remove. + +## 11.5 Log Rotation + +When `latest.log` is replaced: + +1. detect identity change; +2. finish reading any remaining bytes from the old file where possible; +3. discover the newly created `latest.log`; +4. reset the live follower to the appropriate offset; +5. refresh the historical index; +6. continue emitting lines without requiring process restart. + +Duplicate line emission should be avoided. + +--- + +# 12. WebSocket Protocol + +Recommended endpoint: + +```text +GET /api/live +Upgrade: websocket +``` + +The connection must pass authorization before upgrade. + +The application should use structured messages. + +Example: + +```json +{ + "type": "log_lines", + "lines": [ + { + "id": "...", + "timestamp": "...", + "text": "..." + } + ] +} +``` + +Possible additional message types: + +```text +hello +log_lines +rotation +history_changed +error +server_restart +``` + +A minimal v1 can implement only what is required. + +## 12.1 Batching + +If Minecraft emits many lines in one filesystem event, the backend should send them as a batch rather than one WebSocket frame per line. + +## 12.2 Backpressure + +A slow browser must not block the global log follower. + +Recommended architecture: + +```text +log follower + │ + ▼ +Tokio broadcast channel + │ + ├── WS client A + ├── WS client B + └── WS client C +``` + +Each client should have bounded buffering. + +If a client falls too far behind: + +- disconnect it; or +- send a resynchronization-required signal. + +Unbounded per-client queues are forbidden. + +## 12.3 Reconnection + +The frontend should reconnect after unexpected WebSocket termination. + +Use bounded exponential backoff. + +On reconnection, the client should request recent lines over HTTP to fill any gap before resuming live updates. + +The correctness of the log display must not depend on every WebSocket frame being received. + +--- + +# 13. Authorization Model + +## 13.1 Authorization Principle + +A requesting IP is allowed if the backend can establish that: + +1. the IP appeared in a successful Minecraft player login/join; +2. the associated player identity is currently present in the Minecraft whitelist. + +A mere connection attempt is insufficient. + +## 13.2 Sources of Truth + +Use two independent data sources: + +### Minecraft logs + +Used to establish: + +```text +IP ↔ successfully joined player +``` + +### `whitelist.json` + +Used to establish: + +```text +player ↔ currently whitelisted +``` + +Authorization requires both conditions. + +--- + +# 14. Whitelist Parsing + +`whitelist.json` should be parsed structurally as JSON, never with regular expressions. + +The parser should extract at least: + +- player UUID; +- player name. + +Maintain an in-memory whitelist snapshot. + +The application may watch `whitelist.json` for filesystem modifications so that additions and removals become effective without restart. + +If the whitelist file cannot be parsed: + +- log an application error; +- retain the last valid whitelist snapshot if one exists; +- fail closed for newly evaluated identities if no valid whitelist information exists. + +A malformed whitelist must never result in all clients being allowed. + +--- + +# 15. Successful Join Detection + +## 15.1 General Rule + +Do not authorize merely because a line contains: + +- an IP address; +- a username; +- a handshake; +- a connection attempt. + +Authorization should be created only after the parser observes a definitive successful join sequence. + +Minecraft versions, proxies, mod loaders, and plugins may change exact log text, so parsing must be isolated in a dedicated module. + +## 15.2 Parser State + +The parser should ideally correlate connection events. + +Conceptually: + +```text +connection observed + │ + ▼ +candidate { + player, + uuid, + ip +} + │ + ├── rejected → discard + │ + └── successful login/join → authorize candidate +``` + +Depending on the actual Minecraft log format, the application may infer the association from nearby deterministic lines. + +## 15.3 Failed Attempts + +These must not authorize an IP: + +- whitelist rejection; +- ban rejection; +- authentication failure; +- invalid session; +- malformed packet; +- incompatible version; +- server full; +- duplicate login; +- timeout before joining; +- connection closed before successful login; +- status ping/query; +- bot/scanner traffic. + +## 15.4 Parser Tests + +The parser should have fixture-based automated tests containing realistic log sequences for: + +- normal successful join; +- non-whitelisted player; +- banned player; +- disconnect during login; +- successful join followed by disconnect; +- multiple players behind one IP; +- IPv4; +- IPv6; +- reconnect; +- username changes where UUID is available; +- log rotation between relevant events. + +Security-sensitive parsing logic should not rely exclusively on ad hoc regex testing. + +--- + +# 16. Authorization State + +Recommended in-memory representation: + +```text +IP address + ↓ +one or more observed player identities +``` + +Each successful association should include: + +- IP; +- player name; +- UUID when available; +- successful join timestamp; +- source log position. + +Example conceptual record: + +```json +{ + "ip": "203.0.113.42", + "players": [ + { + "name": "ExamplePlayer", + "uuid": "...", + "joined_at": "..." + } + ] +} +``` + +If any associated player remains currently whitelisted, the address may be authorized. + +Prefer UUID comparison over username comparison when UUID information is available. + +--- + +# 17. Authorization Cache and Rescan Behavior + +The requested behavior is: + +1. obtain incoming request IP; +2. check current in-memory allowed-IP set; +3. if present, allow; +4. if absent, rescan only `latest.log`; +5. update successful-join state; +6. reevaluate whitelist membership; +7. if now authorized, allow; +8. otherwise reject. + +Historical archives must not be scanned to authorize a new incoming request. + +This ensures that access reflects players observed in the current active log period rather than indefinitely trusting IP addresses seen months earlier. + +## 17.1 Concurrency + +Multiple simultaneous authorization misses from the same or different IP addresses must not trigger redundant full rescans. + +Use a synchronization mechanism such as: + +- Tokio mutex around rescan; +- singleflight/coalescing logic; +- generation-based cache rebuild. + +After one task refreshes the auth state, waiting requests should reevaluate against the new generation. + +--- + +# 18. Authorization Lifetime + +An IP association inferred from `latest.log` naturally persists until the active log is rotated/replaced or the viewer restarts and reconstructs state. + +This behavior should be explicitly documented. + +Recommended v1 behavior: + +- successful joins contained in current `latest.log` are eligible; +- successful joins present only in archived logs are not eligible; +- whitelist removal invalidates authorization as soon as the updated whitelist is observed; +- restarting the log viewer reconstructs state by scanning current `latest.log`. + +A future version may add configurable TTLs, but v1 does not require them. + +--- + +# 19. Client IP Extraction + +Correct client IP extraction is security-sensitive. + +The app must support operation behind the existing Caddy reverse proxy. + +It must not blindly trust arbitrary `X-Forwarded-For` or similar headers. + +Recommended model: + +```text +Internet + │ + ▼ +Caddy + │ + ▼ +viewer bound only to trusted/internal interface +``` + +The viewer should accept forwarded client-IP headers only when the direct TCP peer is a configured trusted proxy. + +Configuration should include something conceptually similar to: + +```text +TRUST_PROXY=true +TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128, +CLIENT_IP_HEADER=X-Forwarded-For +``` + +The exact header may be chosen to match the existing Caddy configuration. + +## 19.1 Validation Rules + +The backend should: + +1. inspect the immediate TCP peer; +2. determine whether that peer is trusted; +3. only then inspect the forwarded address; +4. parse the forwarded value as a real IP address; +5. normalize IPv4/IPv6 representation; +6. reject malformed values. + +If a connection does not originate from a trusted proxy, forwarded headers must be ignored. + +--- + +# 20. Reverse Proxy Contract + +Caddy itself is outside this project's deployment scope. + +The viewer nevertheless requires the reverse proxy to provide: + +- HTTPS externally; +- WebSocket upgrade forwarding; +- original client IP forwarding; +- no public bypass route to the viewer backend; +- appropriate request-size and connection behavior. + +The application documentation should include a small example Caddy configuration, clearly labelled as an integration example rather than managed infrastructure. + +--- + +# 21. Log Privacy and Redaction + +The source logs may include player IP addresses. + +The backend should support output redaction before sending lines to browsers. + +Recommended default: + +```text +REDACT_PLAYER_IPS=true +``` + +Example source: + +```text +PlayerName[/203.0.113.42:51324] logged in +``` + +Rendered: + +```text +PlayerName/[IP REDACTED] logged in +``` + +Authorization parsing must occur against the original unredacted text. + +Redaction occurs only in the presentation path. + +If administrators intentionally want players to see addresses, the feature may be disabled through configuration. + +The frontend must never receive raw addresses when redaction is enabled. + +--- + +# 22. HTTP API + +A minimal API might expose: + +```text +GET /api/status +GET /api/logs/recent +GET /api/logs/history +GET /api/live +``` + +All log-related endpoints require authorization. + +## 22.1 Status Endpoint + +A private or minimally revealing health endpoint may return: + +```json +{ + "status": "ok" +} +``` + +Operational health checks should not expose logs, player names, addresses, filesystem paths, or other sensitive server information. + +A separate internal readiness endpoint may expose more detail if bound appropriately. + +## 22.2 Recent Logs + +Example: + +```http +GET /api/logs/recent?limit=1000 +``` + +Returns a recent bounded snapshot. + +## 22.3 Historical Logs + +Example: + +```http +GET /api/logs/history?before=&limit=1000 +``` + +The backend should clamp `limit` to a configured maximum. + +--- + +# 23. Frontend UX + +The primary interface is a vertically scrollable terminal-like log viewer. + +Required behavior: + +- newest lines appear at the bottom; +- new lines appear live; +- scrolling upward loads older history; +- user position should not jump unexpectedly; +- if the user is already at the bottom, new lines should keep the view pinned to the bottom; +- if the user has scrolled upward, incoming lines should not force the viewport downward; +- show a "new lines" indicator when live data arrives while the user is reading older content; +- clicking the indicator returns to the live tail; +- connection state should be visible; +- authorization failure should show a concise access-denied screen. + +Possible connection states: + +```text +Live +Reconnecting +Disconnected +Access denied +``` + +--- + +# 24. Frontend Performance + +The browser must not retain an unlimited number of DOM nodes. + +Use one of: + +- virtual scrolling; +- bounded rendered window; +- chunked log rendering. + +The application may retain more log lines in JavaScript memory than are mounted in the DOM, but memory growth should remain bounded for long-running sessions. + +For example: + +```text +rendered DOM lines: ~1,000–5,000 +client retained lines: configurable +``` + +Exact values should be established through testing. + +--- + +# 25. Live/History Merge Semantics + +The frontend will receive historical lines via HTTP and live lines via WebSocket. + +Each logical line should have a stable identifier or cursor sufficient to deduplicate overlapping ranges. + +For example, an internal identifier may derive from: + +```text +source-file-generation + byte-offset +``` + +Do not rely solely on timestamp + text because duplicate lines may legitimately exist. + +After reconnecting: + +1. fetch recent snapshot; +2. reconcile with existing line IDs; +3. remove duplicates; +4. resume WebSocket tailing. + +--- + +# 26. Application Startup Sequence + +Recommended startup sequence: + +```text +1. parse configuration +2. validate filesystem paths +3. load whitelist +4. build historical log index +5. scan latest.log for authorization state +6. establish current latest.log offset +7. start filesystem watcher +8. start live follower +9. initialize broadcast channel +10. start HTTP/WebSocket server +``` + +The server should not report itself ready before critical initialization succeeds. + +Historical index failure may be treated as degraded operation if live logs remain available. + +Authorization initialization failure should fail closed. + +--- + +# 27. Race-Free Startup + +A naïve startup sequence can lose lines written between initial EOF detection and watcher registration. + +The implementation should explicitly handle this. + +A robust sequence may: + +1. open `latest.log`; +2. note current file identity and offset; +3. register directory watch; +4. reread from the recorded offset; +5. process any events that occurred during initialization. + +The exact implementation may differ, but no silent live-log gap should be introduced by startup races. + +--- + +# 28. File Identity + +Path equality is insufficient for rotation detection. + +On Linux, use filesystem metadata capable of distinguishing replaced files, such as inode/device identity where available. + +Store enough metadata to determine whether: + +```text +/data/logs/latest.log +``` + +now refers to a different underlying file. + +--- + +# 29. Error Handling + +The application should distinguish recoverable and fatal errors. + +## Recoverable + +Examples: + +- temporary `latest.log` disappearance during rotation; +- malformed individual log line; +- archive failing to decompress; +- WebSocket disconnect; +- historical file removed between indexing and read; +- transient filesystem event loss. + +These should be logged and recovered from where possible. + +## Fatal + +Examples: + +- configured Minecraft data directory missing at startup; +- unreadable whitelist with no valid snapshot; +- inability to bind HTTP server; +- invalid security-critical proxy configuration. + +Fatal initialization errors should terminate with a non-zero exit status so the service manager can surface the failure. + +--- + +# 30. Filesystem Watch Overflow and Recovery + +Linux filesystem notification queues can overflow. + +The watcher must not assume it receives every event forever. + +If the backend detects an overflow or uncertain watcher state: + +1. rescan directory metadata; +2. compare current file identity and size; +3. recover unread bytes from `latest.log`; +4. rebuild the historical index if necessary; +5. continue normal operation. + +This is an event-loss recovery mechanism, not normal polling. + +--- + +# 31. Truncation Handling + +If `latest.log` shrinks without being replaced, the follower should detect: + +```text +current size < previous offset +``` + +This indicates truncation. + +The reader should reset safely and avoid attempting to seek beyond EOF. + +Authorization state may need rebuilding from the newly truncated `latest.log`. + +--- + +# 32. Character Encoding + +Minecraft logs are generally textual UTF-8-compatible content, but malformed bytes should not crash the service. + +Use tolerant decoding where appropriate. + +Invalid sequences may be represented using replacement characters. + +The original raw bytes do not need to be exposed. + +--- + +# 33. Rate Limiting and Abuse Resistance + +Although the app is private, unauthenticated requests can still reach Caddy. + +The backend should guard expensive miss-path behavior. + +Authorization rescans must be coalesced as described earlier. + +Additionally, consider: + +- small request body limits; +- historical pagination limits; +- maximum concurrent WebSocket connections; +- bounded decompression work; +- maximum archive size accepted; +- bounded decompressed output; +- request timeouts. + +Do not trust compressed file size as a guarantee of decompressed size. + +Archive decompression should guard against unexpectedly large/corrupt files. + +--- + +# 34. Resource Limits + +All long-lived or potentially expanding queues should be bounded. + +This includes: + +- WebSocket outbound queues; +- archive cache; +- decompression buffers; +- historical response sizes; +- frontend retained history; +- parser candidate login state. + +The application should be safe to leave running indefinitely. + +--- + +# 35. Observability + +Use structured application logging. + +Recommended fields include: + +```text +event +level +client_ip +path +archive +error +connection_id +duration_ms +``` + +Avoid emitting sensitive information unnecessarily. + +Particularly, application logs should not duplicate raw Minecraft player IP information more than needed. + +Suggested operational events: + +- application startup; +- historical index built; +- whitelist loaded; +- whitelist changed; +- live file opened; +- rotation detected; +- archive read failure; +- watcher recovery; +- WebSocket connected; +- WebSocket disconnected; +- authorization denied; +- authorization refreshed. + +Do not log entire HTTP headers. + +--- + +# 36. Application Metrics + +Metrics are optional for v1. + +If implemented, useful counters include: + +```text +connected_websocket_clients +authorization_allow_total +authorization_deny_total +authorization_rescan_total +live_lines_broadcast_total +archive_reads_total +archive_cache_hits_total +archive_cache_misses_total +watcher_recovery_total +``` + +A Prometheus dependency is not required for initial implementation. + +--- + +# 37. Configuration + +All deployment-specific values should be configurable through environment variables or a simple config file. + +Suggested settings: + +```text +LISTEN_ADDR +MC_DATA_DIR +MC_LOG_DIR +MC_LATEST_LOG +MC_WHITELIST + +TRUST_PROXY +TRUSTED_PROXY_CIDRS +CLIENT_IP_HEADER + +INITIAL_LOG_LINES +MAX_HISTORY_LINES_PER_REQUEST + +ARCHIVE_CACHE_MAX_BYTES +ARCHIVE_CACHE_MAX_FILES + +WS_CLIENT_QUEUE_CAPACITY +MAX_WS_CONNECTIONS + +REDACT_PLAYER_IPS + +RUST_LOG +``` + +Configuration must be validated before starting the HTTP listener. + +Invalid CIDR ranges, filesystem paths, and numeric bounds should fail startup with clear errors. + +--- + +# 38. Container Deployment + +Recommended Compose concept: + +```yaml +services: + minecraft: + image: itzg/minecraft-server + volumes: + - minecraft-data:/data + + log-viewer: + image: minecraft-log-viewer + restart: unless-stopped + volumes: + - minecraft-data:/data:ro + environment: + MC_DATA_DIR: /data + MC_LOG_DIR: /data/logs + MC_LATEST_LOG: /data/logs/latest.log + MC_WHITELIST: /data/whitelist.json +``` + +The exact networking should reflect the existing Caddy deployment. + +The viewer should not expose a host port if Caddy can reach it through an internal Docker network. + +--- + +# 39. Container Security + +Recommended runtime hardening where practical: + +```text +read_only: true +no-new-privileges +drop unnecessary Linux capabilities +non-root UID +read-only Minecraft volume +tmpfs for temporary application files +``` + +The application requires filesystem read access but should not require privileged capabilities. + +It must not run as root unless the deployment environment makes that unavoidable. + +--- + +# 40. Single-Binary Packaging + +The preferred build output is a Rust executable containing the compiled Svelte frontend. + +Possible implementation pattern: + +```text +npm/pnpm/bun only during build + │ + ▼ +Vite compiles Svelte + │ + ▼ +dist/ + │ + ▼ +Rust build embeds dist/ + │ + ▼ +single runtime binary +``` + +A Node/Bun runtime is not required in the production container. + +A multi-stage Docker build may include frontend tooling only in the builder stage. + +--- + +# 41. Suggested Backend Module Structure + +A reasonable source layout: + +```text +src/ +├── main.rs +├── config.rs +├── http/ +│ ├── mod.rs +│ ├── history.rs +│ ├── recent.rs +│ └── websocket.rs +├── auth/ +│ ├── mod.rs +│ ├── client_ip.rs +│ ├── join_tracker.rs +│ └── whitelist.rs +├── logs/ +│ ├── mod.rs +│ ├── index.rs +│ ├── parser.rs +│ ├── follower.rs +│ ├── watcher.rs +│ ├── archive.rs +│ └── cursor.rs +└── frontend.rs +``` + +Exact naming is implementation-dependent. + +--- + +# 42. Suggested Runtime State + +Shared application state may conceptually include: + +```rust +AppState { + config, + whitelist, + authorization_state, + historical_index, + archive_cache, + live_broadcast_tx, + log_generation, +} +``` + +Use immutable or read-optimized structures where possible. + +Do not place a single global mutex around all application state. + +Use independent synchronization domains for: + +- whitelist; +- authorization; +- historical index; +- cache. + +This prevents archive reads from blocking live broadcasting or authorization. + +--- + +# 43. Concurrency Model + +Suggested task ownership: + +```text +main Axum runtime +│ +├── filesystem watcher task +├── latest.log follower task +├── optional index refresh worker +├── authorization refresh coordination +└── one task per WebSocket connection +``` + +Blocking archive decompression should not monopolize Tokio's async worker threads. + +Use: + +```text +tokio::task::spawn_blocking +``` + +or an equivalent bounded blocking worker mechanism for CPU/blocking filesystem work where appropriate. + +--- + +# 44. Historical Index Refresh + +The index should update when: + +- a log file is created; +- a file is renamed during rotation; +- an archive appears; +- an existing archive disappears. + +Directory filesystem events should trigger targeted index refreshes. + +A complete rescan is acceptable after infrequent rotation events because the number of archived logs is expected to remain modest. + +No fixed recurring rescan interval is required. + +--- + +# 45. Browser Security + +The application should ship reasonable browser security headers, unless Caddy centrally provides them. + +Relevant policies may include: + +```text +Content-Security-Policy +X-Content-Type-Options +Referrer-Policy +frame-ancestors +``` + +No third-party scripts are required. + +Avoid external CDNs. + +The app should be self-contained. + +This reduces privacy exposure and simplifies CSP. + +--- + +# 46. WebSocket Origin Validation + +Because authorization is based on source IP rather than a browser credential, a malicious website opened by an authorized user could potentially attempt cross-site WebSocket/API access from that user's browser. + +Therefore the backend should validate the HTTP `Origin` header for browser-originated API and WebSocket connections. + +The configured public application origin should be allowlisted. + +Example: + +```text +PUBLIC_ORIGIN=https://logs.example.com +``` + +Requests carrying an unexpected browser Origin should be rejected. + +This is an important complement to IP-based authorization. + +--- + +# 47. CORS + +Cross-origin API access is not required. + +CORS should therefore be disabled or configured to allow only the application's own origin. + +Do not use: + +```text +Access-Control-Allow-Origin: * +``` + +for protected log APIs. + +--- + +# 48. DNS-Rebinding and Host Validation + +The service should optionally validate expected `Host`/forwarded host information when operating behind Caddy. + +The application should not rely on host validation as its primary security mechanism, but checking the expected public host reduces accidental exposure through unexpected proxy routes. + +--- + +# 49. Authorization Endpoint Behavior + +Unauthorized requests should return: + +```http +403 Forbidden +``` + +Do not reveal: + +- whether an IP was previously seen; +- which usernames are associated with the IP; +- whitelist contents; +- parsed player identities. + +The response should remain generic. + +Example: + +```json +{ + "error": "access_denied" +} +``` + +--- + +# 50. Privacy-Preserving Denial + +The access-denied frontend should not reveal server log snippets. + +It may say: + +```text +Access denied. + +This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server. +``` + +No diagnostic IP data should be exposed unless intentionally enabled for troubleshooting. + +--- + +# 51. Testing Strategy + +## 51.1 Unit Tests + +Required areas: + +- Minecraft log parsing; +- IPv4 parsing; +- IPv6 parsing; +- forwarded-header handling; +- trusted proxy validation; +- whitelist parsing; +- cursor encode/decode; +- log redaction; +- archive filename ordering; +- line buffering. + +## 51.2 Integration Tests + +Create temporary filesystem fixtures simulating: + +- append to `latest.log`; +- partial-line append; +- multiple-line append; +- file truncation; +- file replacement; +- log rotation; +- archive creation; +- whitelist modification; +- concurrent authorization misses. + +## 51.3 WebSocket Tests + +Verify: + +- unauthorized upgrade rejected; +- authorized upgrade accepted; +- appended lines delivered; +- batches preserve ordering; +- slow clients do not block others; +- client queue overflow is handled; +- reconnection can recover missing lines. + +## 51.4 Security Tests + +Verify: + +- arbitrary `X-Forwarded-For` from an untrusted peer is ignored; +- spoofed forwarded IP cannot grant access; +- failed Minecraft joins cannot grant access; +- removed whitelist entry revokes authorization; +- malformed whitelist fails closed; +- cross-origin WebSocket connection is rejected; +- history endpoint cannot perform filesystem traversal; +- archive filenames cannot escape configured directory. + +--- + +# 52. Performance Targets + +Given the intended small private server deployment, the application should favor correctness and bounded resource consumption over extreme throughput. + +Reasonable targets: + +- idle CPU usage effectively negligible; +- no recurring file polling workload; +- new log lines visible in browser typically within hundreds of milliseconds of write/event delivery; +- application memory stable during indefinite operation; +- historical page retrieval responsive for small compressed files; +- dozens of simultaneous WebSocket viewers supported comfortably. + +The architecture should not assume only one viewer. + +--- + +# 53. Failure Scenarios + +## Minecraft server stopped + +The log viewer remains available. + +It should continue serving existing history and display that no new lines are arriving. + +## `latest.log` temporarily missing + +The watcher waits for recreation and resumes. + +## Minecraft restarts + +The viewer should detect the resulting log changes/rotation and continue. + +## Viewer restarts + +It rebuilds whitelist, authorization state, and history index from disk. + +No separate persistence is required. + +## Historical archive corrupted + +Return a controlled error for the affected range while preserving access to other logs. + +## Browser loses network + +Frontend reconnects and fills the gap. + +## Caddy restarts + +WebSockets disconnect; clients reconnect automatically. + +--- + +# 54. Data Persistence + +The application itself should not require persistent writable storage for v1. + +All authoritative data exists in: + +```text +Minecraft logs +whitelist.json +``` + +Caches and indexes may be reconstructed on startup. + +This simplifies backup and deployment. + +--- + +# 55. Log Search + +Full-text search is outside initial scope. + +However, the backend design should avoid making future search unnecessarily difficult. + +Historical log retrieval APIs should preserve structured logical positions. + +A later version could add: + +```text +GET /api/logs/search?q=... +``` + +without changing the live-follow architecture. + +--- + +# 56. Accessibility + +The viewer should: + +- remain keyboard-scrollable; +- use sufficient contrast; +- not rely on color alone for connection state; +- expose connection state textually; +- respect browser font scaling; +- avoid forced animations; +- allow log text selection and copying. + +--- + +# 57. Responsive Design + +Desktop is the primary environment, but the interface should remain usable on mobile. + +Minimum behavior on narrow screens: + +- log text horizontally scrollable or sensibly wrapped according to user preference; +- controls do not obscure the log; +- connection state remains visible. + +A wrap/no-wrap toggle may be useful but is not required for initial implementation. + +--- + +# 58. Recommended MVP + +The first production-worthy milestone should include: + +1. Rust/Axum HTTP server. +2. Embedded Svelte frontend. +3. Read-only Minecraft data mount. +4. Current whitelist parser. +5. Successful-join/IP parser. +6. Authorization cache. +7. Rescan-current-log-on-auth-miss. +8. Trusted reverse proxy client-IP extraction. +9. Same-origin/Origin validation. +10. `inotify`-driven live follower. +11. directory-level log rotation handling. +12. WebSocket broadcasting. +13. latest/recent log endpoint. +14. historical log index. +15. lazy archive decompression. +16. backwards cursor pagination. +17. frontend infinite upward scrolling. +18. WebSocket reconnect and gap recovery. +19. bounded queues and caches. +20. IP redaction enabled by default. +21. automated parser/security tests. +22. container image suitable for existing Caddy infrastructure. + +--- + +# 59. Deferred Enhancements + +Potential later features include: + +- text search; +- regex filtering; +- log-level filtering; +- player-name filtering; +- client-side highlighting; +- URL-addressable timestamps; +- downloadable selected log ranges; +- administrator authentication mode; +- multiple Minecraft servers; +- configurable authorization TTL; +- audit trail; +- persistent decompressed index; +- structured parsing of chat/player events; +- metrics endpoint. + +None are required for initial launch. + +--- + +# 60. Implementation Priorities + +Development should proceed in this order: + +### Phase 1 — Files and parsing + +Implement: + +- configuration; +- whitelist reader; +- log reader; +- successful join parser; +- authorization reconstruction; +- parser fixtures and tests. + +### Phase 2 — Live backend + +Implement: + +- directory watcher; +- append follower; +- rotation handling; +- broadcast channel; +- WebSocket transport. + +### Phase 3 — Security boundary + +Implement: + +- trusted proxy logic; +- client IP normalization; +- authorization middleware; +- same-origin checks; +- history path isolation; +- redaction. + +### Phase 4 — Historical backend + +Implement: + +- archive discovery; +- chronological index; +- lazy decompression; +- cursor pagination; +- bounded cache. + +### Phase 5 — Frontend + +Implement: + +- recent snapshot; +- live WebSocket; +- incremental history; +- scroll anchoring; +- reconnect recovery; +- connection indicators; +- access denied state; +- virtualization/bounded DOM. + +### Phase 6 — Packaging + +Implement: + +- Vite production build; +- frontend embedding; +- multi-stage container build; +- health/readiness behavior; +- non-root runtime; +- read-only filesystem where possible. + +### Phase 7 — Hardening + +Exercise: + +- log rotations; +- server restarts; +- viewer restarts; +- whitelist changes; +- malformed archives; +- IPv6; +- proxy spoofing attempts; +- slow WebSocket clients; +- notification overflow recovery. + +--- + +# 61. Acceptance Criteria + +The implementation is considered production-ready for the intended environment when all of the following are true. + +### Live logs + +- Appending a complete line to `latest.log` causes that line to appear in authorized connected browsers without timer-based polling. +- Partial writes do not generate malformed lines. +- File replacement and rotation do not require restarting the viewer. +- A slow WebSocket client cannot stall the global live-log pipeline. + +### Historical logs + +- Archived logs can be viewed without preloading the full archive corpus into RAM. +- Scrolling upward retrieves older logs incrementally. +- Chronological ordering remains correct across file boundaries. +- Browser DOM/memory behavior remains bounded during extended use. + +### Authorization + +- A currently whitelisted player who successfully joined from an IP can access the viewer from that address. +- A whitelist-rejected connection cannot gain access. +- A banned/failed login cannot gain access. +- An unknown IP causes the current `latest.log` authorization state to be refreshed before denial. +- Historical archives are not used to grant access. +- Removing a player from `whitelist.json` invalidates authorization associated solely with that player. +- Spoofing `X-Forwarded-For` from outside the trusted proxy path does not grant access. + +### Privacy + +- Raw player IPs are not sent to browsers when redaction is enabled. +- Unauthorized responses reveal no player or whitelist information. +- Cross-origin browser access is rejected. + +### Deployment + +- The viewer runs without a Node/Bun runtime. +- The frontend is served by the Rust application. +- Minecraft data is mounted read-only. +- No database is required. +- No Docker socket is required. +- Existing Caddy can proxy normal HTTP and WebSocket traffic to the application. + +### Resilience + +- Minecraft restart does not permanently interrupt live log viewing. +- Viewer restart reconstructs required state from Minecraft files. +- Filesystem notification overflow has a defined recovery path. +- A corrupt archive does not crash the whole service. +- All potentially growing queues and caches have explicit bounds. + +--- + +# 62. Final Architecture Decision + +The selected implementation is: + +```text +Rust +├── Axum +├── Tokio +├── notify/inotify +├── WebSockets +├── streaming archive readers +├── whitelist + log authorization parser +└── embedded Svelte frontend +``` + +Deployment: + +```text +existing Caddy + │ + ▼ +single log-viewer service + │ + ▼ +read-only Minecraft /data +``` + +No application database, Redis instance, Node runtime, polling loop, Docker API access, or Minecraft administrative interface is required. + +The design intentionally favors reconstructable in-memory state, event-driven IO, bounded resource usage, and a narrow read-only security boundary. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..37fa1ea --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Minecraft Server Live Log Viewer + +A read-only Axum + Svelte sidecar for `itzg/docker-minecraft-server`. It streams `latest.log` using filesystem events, paginates plain/gzip/tar-gzip history, and permits an IP only after a currently whitelisted player successfully joined from that address. + +## Run as a Docker sidecar + +1. Copy `compose.example.yml` beside the Minecraft Compose project. +2. Replace `PUBLIC_ORIGIN`, `TRUSTED_PROXY_CIDRS`, and the external `caddy` network name. +3. Run `docker compose -f compose.example.yml up -d --build`. +4. Point Caddy at `log-viewer:8080`; do not publish the viewer port to the host. + +The container runs as UID/GID `10001`, uses a read-only root filesystem, drops every capability, does not mount the Docker socket, and mounts the shared Minecraft volume at `/data:ro`. + +Example Caddy integration: + +```caddyfile +logs.example.com { + reverse_proxy log-viewer:8080 +} +``` + +Caddy supplies `X-Forwarded-For`; the service reads it only when the immediate peer belongs to `TRUSTED_PROXY_CIDRS`. Set this CIDR to the actual Caddy Docker network. Direct clients cannot spoof authorization using that header. + +## Authorization lifetime + +Only successful joins in the current `latest.log` count. Failed, banned, rejected, and archived attempts do not. Associations survive disconnects until the current log is replaced/truncated or the viewer restarts. A whitelist removal revokes access after the watched JSON file is reloaded. This is a lightweight IP-based privacy barrier, not account authentication; NATs, VPNs, dynamic addresses, and IPv6 privacy addresses limit its precision. + +## Configuration + +Required: `PUBLIC_ORIGIN`. Paths default to `/data`, `/data/logs`, `/data/logs/latest.log`, and `/data/whitelist.json`. The Compose example documents proxy and redaction settings. Resource limits are configurable with `INITIAL_LOG_LINES`, `MAX_HISTORY_LINES_PER_REQUEST`, `ARCHIVE_CACHE_MAX_BYTES`, `ARCHIVE_CACHE_MAX_FILES`, `MAX_ARCHIVE_DECOMPRESSED_BYTES`, `WS_CLIENT_QUEUE_CAPACITY`, and `MAX_WS_CONNECTIONS`. + +Malformed startup configuration or an unreadable initial whitelist fails closed. Later malformed whitelist updates retain the last valid snapshot. + +## Development and verification + +```sh +cargo test +cd frontend && npm install && npm test && npm run check && npm run build +docker build -t minecraft-log-viewer . +``` + +No Node runtime, database, Redis service, recurring file poller, Docker API, RCON, or writable Minecraft mount is used in production. diff --git a/compose.example.yml b/compose.example.yml new file mode 100644 index 0000000..9566532 --- /dev/null +++ b/compose.example.yml @@ -0,0 +1,46 @@ +services: + minecraft: + image: itzg/minecraft-server:latest + restart: unless-stopped + environment: + EULA: "TRUE" + ENABLE_WHITELIST: "TRUE" + volumes: + - minecraft-data:/data + + log-viewer: + build: . + restart: unless-stopped + depends_on: + minecraft: + condition: service_started + read_only: true + cap_drop: [ALL] + security_opt: + - no-new-privileges:true + tmpfs: + - /tmp:size=16m,mode=1777 + volumes: + - minecraft-data:/data:ro + environment: + LISTEN_ADDR: 0.0.0.0:8080 + MC_DATA_DIR: /data + MC_LOG_DIR: /data/logs + MC_LATEST_LOG: /data/logs/latest.log + MC_WHITELIST: /data/whitelist.json + PUBLIC_ORIGIN: https://logs.example.com + TRUST_PROXY: "true" + TRUSTED_PROXY_CIDRS: 172.20.0.0/16 + CLIENT_IP_HEADER: X-Forwarded-For + REDACT_PLAYER_IPS: "true" + RUST_LOG: minecraft_log_viewer=info,tower_http=info + networks: + - default + - caddy + +volumes: + minecraft-data: + +networks: + caddy: + external: true diff --git a/fixtures/join_sequences.log b/fixtures/join_sequences.log new file mode 100644 index 0000000..b969f39 --- /dev/null +++ b/fixtures/join_sequences.log @@ -0,0 +1,10 @@ +# success_ipv4 +[12:00:00] [User Authenticator #1/INFO]: UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000 +[12:00:01] [Server thread/INFO]: Alex[/203.0.113.4:51234] logged in with entity id 1 +# rejected +[12:01:00] [Server thread/INFO]: Disconnecting Eve (/198.51.100.9:4000): You are not whitelisted on this server! +# banned +[12:02:00] [Server thread/INFO]: Disconnecting Mallory (/198.51.100.10:4001): You are banned from this server! +# success_ipv6 +[12:03:00] [User Authenticator #2/INFO]: UUID of player Steve is 223e4567-e89b-12d3-a456-426614174000 +[12:03:01] [Server thread/INFO]: Steve[/[2001:db8::5]:51234] logged in with entity id 2 diff --git a/frontend/dist/assets/index-A-Ec5phf.css b/frontend/dist/assets/index-A-Ec5phf.css new file mode 100644 index 0000000..88585a4 --- /dev/null +++ b/frontend/dist/assets/index-A-Ec5phf.css @@ -0,0 +1 @@ +:root{color:#dce5df;font-synthesis:none;background:#0b0e0c;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{background:radial-gradient(circle at 80% -20%,#193226 0,#0000 38%),#0b0e0c;flex-direction:column;height:100dvh;display:flex}header{color:#8e9c93;border-bottom:1px solid #26322b;justify-content:space-between;align-items:center;height:52px;padding:0 18px;font-size:12px;display:flex}header div{color:#dce5df;align-items:center;gap:9px;display:flex}.dot{background:#b07847;border-radius:50%;width:8px;height:8px;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{scrollbar-color:#344139 transparent;flex:1;padding:12px 0;overflow:auto}.line{white-space:pre;grid-template-columns:88px minmax(max-content,1fr);gap:14px;min-height:22px;padding:2px 18px;font-size:13px;line-height:18px;display:grid}.line:hover{background:#ffffff08}.time{color:#60766a;-webkit-user-select:none;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{color:#dce5df;font:inherit;cursor:pointer;background:#193226;border:1px solid #537962;border-radius:18px;padding:9px 14px;position:fixed;bottom:22px;right:22px}.denied{text-align:center;justify-content:center;align-items:center;padding:24px}.denied h1{margin:0 0 12px;font:500 28px system-ui}.denied p{color:#9ba9a0;max-width:580px;font:14px/1.6 system-ui}@media (width<=600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}} diff --git a/frontend/dist/assets/index-CFRqyw3R.js b/frontend/dist/assets/index-CFRqyw3R.js new file mode 100644 index 0000000..6a64bbd --- /dev/null +++ b/frontend/dist/assets/index-CFRqyw3R.js @@ -0,0 +1,2 @@ +(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),t.credentials=e.crossOrigin===`use-credentials`?`include`:e.crossOrigin===`anonymous`?`omit`:`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})(),typeof window<`u`&&((window.__svelte??={}).v??=new Set).add(`5`);var e=!1;function t(){e=!0}t();var n={},r=Symbol(`uninitialized`),i=Array.isArray,a=Array.prototype.indexOf,o=Array.prototype.includes,s=Array.from,c=Object.defineProperty,l=Object.getOwnPropertyDescriptor,u=Object.getOwnPropertyDescriptors,d=Object.prototype,f=Array.prototype,p=Object.getPrototypeOf,m=()=>{};function h(e){return e()}function g(e){for(var t=0;t{e=n,t=r}),resolve:e,reject:t}}var v=1024,y=2048,b=4096,x=8192,S=16384,ee=32768,C=1<<25,te=65536,ne=1<<19,re=1<<20,ie=1<<25,ae=65536,oe=1<<21,se=1<<22,ce=1<<23,le=Symbol(`$state`),ue=Symbol(`class`),de=Symbol(`text`),fe=new class extends Error{name=`StaleReactionError`;message="The reaction that called `getAbortSignal()` was re-run or destroyed"};globalThis.document?.contentType;function pe(e){throw Error(`https://svelte.dev/e/lifecycle_outside_component`)}function me(){throw Error(`https://svelte.dev/e/async_derived_orphan`)}function he(e,t,n){throw Error(`https://svelte.dev/e/each_key_duplicate`)}function ge(e){throw Error(`https://svelte.dev/e/effect_in_teardown`)}function _e(){throw Error(`https://svelte.dev/e/effect_in_unowned_derived`)}function ve(e){throw Error(`https://svelte.dev/e/effect_orphan`)}function ye(){throw Error(`https://svelte.dev/e/effect_update_depth_exceeded`)}function be(){throw Error(`https://svelte.dev/e/state_descriptors_fixed`)}function xe(){throw Error(`https://svelte.dev/e/state_prototype_fixed`)}function Se(){throw Error(`https://svelte.dev/e/state_unsafe_mutation`)}function Ce(){console.warn(`https://svelte.dev/e/derived_inert`)}function we(e){console.warn(`https://svelte.dev/e/hydration_mismatch`)}var w=!1;function Te(e){w=e}var T;function E(e){if(e===null)throw we(),n;return T=e}function Ee(){return E(L(T))}function D(e){if(w){if(L(T)!==null)throw we(),n;T=e}}function De(e=!0){for(var t=0,n=T;;){if(n.nodeType===8){var r=n.data;if(r===`]`){if(t===0)return n;--t}else(r===`[`||r===`[!`||r[0]===`[`&&!isNaN(Number(r.slice(1))))&&(t+=1)}var i=L(n);e&&n.remove(),n=i}}function Oe(e){if(!e||e.nodeType!==8)throw we(),n;return e.data}function ke(e){return e===this.v}function Ae(e,t){return e==e?e!==t||typeof e==`object`&&!!e||typeof e==`function`:t==t}function je(e){return!Ae(e,this.v)}var O=null;function Me(e){O=e}function Ne(t,n=!1,r){O={p:O,i:!1,c:null,e:null,s:t,x:null,r:G,l:e&&!n?{s:null,u:null,$:[]}:null}}function Pe(e){var t=O,n=t.e;if(n!==null){t.e=null;for(var r of n)Gt(r)}return e!==void 0&&(t.x=e),t.i=!0,O=t.p,e??{}}function Fe(){return!e||O!==null&&O.l===null}var Ie=[];function Le(){var e=Ie;Ie=[],g(e)}function Re(e){if(Ie.length===0&&!ct){var t=Ie;queueMicrotask(()=>{t===Ie&&Le()})}Ie.push(e)}function ze(e){var t=G;if(t===null)return H.f|=ce,e;if(!(t.f&32768)&&!(t.f&4))throw e;Be(e,t)}function Be(e,t){if(!(t!==null&&t.f&16384)){for(;t!==null;){if(t.f&128){if(!(t.f&32768))throw e;try{t.b.error(e);return}catch(t){e=t}}t=t.parent}throw e}}var Ve=~(y|b|v);function k(e,t){e.f=e.f&Ve|t}function He(e){e.f&512||e.deps===null?k(e,v):k(e,b)}function Ue(e){if(e!==null)for(let t of e)!(t.f&2)||!(t.f&65536)||(t.f^=ae,Ue(t.deps))}function We(e,t,n){e.f&2048?t.add(e):e.f&4096&&n.add(e),Ue(e.deps),k(e,v)}function Ge(e){var t=H,n=G;W(null),K(null);try{return e()}finally{W(t),K(n)}}te|ne;function Ke(e,t,n,r){let i=Fe()?Xe:$e;var a=e.filter(e=>!e.settled),o=t.map(i);if(n.length===0&&a.length===0){r(o);return}var s=G,c=qe(),l=a.length===1?a[0].promise:a.length>1?Promise.all(a.map(e=>e.promise)):null;function u(e){if(!(s.f&16384)){c();try{r([...o,...e])}catch(e){Be(e,s)}Je()}}var d=Ye();if(n.length===0){l.then(()=>u([])).finally(d);return}function f(){Promise.all(n.map(e=>Qe(e))).then(u).catch(e=>Be(e,s)).finally(d)}l?l.then(()=>{c(),f(),Je()}):f()}function qe(){var e=G,t=H,n=O,r=A;return function(i=!0){K(e),W(t),Me(n),i&&!(e.f&16384)&&(r?.activate(),r?.apply())}}function Je(e=!0){K(null),W(null),Me(null),e&&A?.deactivate()}function Ye(){var e=G,t=e.b,n=A,r=!!t?.is_rendered();return t?.update_pending_count(1,n),n.increment(r,e),()=>{t?.update_pending_count(-1,n),n.decrement(r,e)}}function Xe(e){var t=2|y;return G!==null&&(G.f|=ne),{ctx:O,deps:null,effects:null,equals:ke,f:t,fn:e,reactions:null,rv:0,v:r,wv:0,parent:G,ac:null}}var Ze=Symbol(`obsolete`);function Qe(e,t,n){let i=G;i===null&&me();var a=void 0,o=St(r),s=!H,c=new Set;return Jt(()=>{var t=G,n=_();a=n.promise;try{Promise.resolve(e()).then(n.resolve,e=>{e!==fe&&n.reject(e)}).finally(Je)}catch(e){n.reject(e),Je()}var r=A;if(s){if(t.f&32768)var l=Ye();if(i.b?.is_rendered())r.async_deriveds.get(t)?.reject(Ze);else for(let e of c.values())e.reject(Ze);c.add(n),r.async_deriveds.set(t,n)}let u=(e,t=void 0)=>{l?.(),c.delete(n),t!==Ze&&(r.activate(),t?(o.f|=ce,Tt(o,t)):(o.f&8388608&&(o.f^=ce),Tt(o,e)),r.deactivate())};n.promise.then(u,e=>u(null,e||`unknown`))}),Ut(()=>{for(let e of c)e.reject(Ze)}),new Promise(e=>{function t(n){function r(){n===a?e(o):t(a)}n.then(r,r)}t(a)})}function $e(e){let t=Xe(e);return t.equals=je,t}function et(e){var t=e.effects;if(t!==null){e.effects=null;for(var n=0;n{t.ac.abort(fe),t.ac=null}),t.fn!==null&&(t.teardown=m),Cn(t,0),en(t))}function it(e){if(e.effects!==null)for(let t of e.effects)t.teardown&&t.fn!==null&&wn(t)}var at=null,A=null,ot=null,j=null,st=null,ct=!1,lt=!1,ut=null,dt=null,ft=0,pt=1,mt=class e{id=pt++;#e=!1;linked=!0;#t=null;#n=null;async_deriveds=new Map;current=new Map;previous=new Map;#r=new Set;#i=new Set;#a=0;#o=new Map;#s=null;#c=[];#l=[];#u=new Set;#d=new Set;#f=new Map;#p=new Set;is_fork=!1;#m=!1;constructor(){at===null?at=this:(at.#n=this,this.#t=at),at=this}#h(){if(this.is_fork)return!0;for(let n of this.#o.keys()){for(var e=n,t=!1;e.parent!==null;){if(this.#f.has(e)){t=!0;break}e=e.parent}if(!t)return!0}return!1}skip_effect(e){this.#f.has(e)||this.#f.set(e,{d:[],m:[]}),this.#p.delete(e)}unskip_effect(e,t=e=>this.schedule(e)){var n=this.#f.get(e);if(n){this.#f.delete(e);for(var r of n.d)k(r,y),t(r);for(r of n.m)k(r,b),t(r)}this.#p.add(e)}#g(){this.#e=!0,ft++>1e3&&(this.#x(),ht());for(let e of this.#u)this.#d.delete(e),k(e,y),this.schedule(e);for(let e of this.#d)k(e,b),this.schedule(e);let t=this.#c;this.#c=[],this.apply();var n=ut=[],r=[],i=dt=[];for(let e of t)try{this.#_(e,n,r)}catch(t){throw yt(e),this.#h()||this.discard(),t}if(A=null,i.length>0){var a=e.ensure();for(let e of i)a.schedule(e)}if(ut=null,dt=null,this.#h()){this.#b(r),this.#b(n);for(let[e,t]of this.#f)vt(e,t);i.length>0&&A.#g();return}let o=this.#v();if(o){this.#b(r),this.#b(n),o.#y(this);return}this.#u.clear(),this.#d.clear();for(let e of this.#r)e(this);this.#r.clear(),ot=this,gt(r),gt(n),ot=null,this.#s?.resolve();var s=A;if(this.#a===0&&(this.#c.length===0||s!==null)&&this.#x(),this.#c.length>0){if(s!==null){let e=s;e.#c.push(...this.#c.filter(t=>!e.#c.includes(t)))}else s=this}s!==null&&s.#g()}#_(e,t,n){e.f^=v;for(var r=e.first;r!==null;){var i=r.f,a=!!(i&96);if(!(a&&i&1024||i&8192||this.#f.has(r))&&r.fn!==null){a?r.f^=v:i&4?t.push(r):yn(r)&&(i&16&&this.#d.add(r),wn(r));var o=r.first;if(o!==null){r=o;continue}}for(;r!==null;){var s=r.next;if(s!==null){r=s;break}r=r.parent}}}#v(){for(var e=this.#t;e!==null;){if(!e.is_fork){for(let[t,[,n]]of this.current)if(e.current.has(t)&&!n)return e}e=e.#t}return null}#y(e){for(let[t,n]of e.current)!this.previous.has(t)&&e.previous.has(t)&&this.previous.set(t,e.previous.get(t)),this.current.set(t,n);for(let[t,n]of e.async_deriveds){let e=this.async_deriveds.get(t);e&&n.promise.then(e.resolve).catch(e.reject)}e.async_deriveds.clear(),this.transfer_effects(e.#u,e.#d);let t=e=>{var n=e.reactions;if(n!==null&&!(e.f&2&&!(e.f&6144)))for(let e of n){var r=e.f;if(r&2)t(e);else{var i=e;r&4194320&&!this.async_deriveds.has(i)&&(this.#d.delete(i),k(i,y),this.schedule(i))}}};for(let e of this.current.keys())t(e);this.oncommit(()=>e.discard()),e.#x(),A=this,this.#g()}#b(e){for(var t=0;t{this.#m=!1,this.linked&&this.flush()}))}transfer_effects(e,t){for(let t of e)this.#u.add(t);for(let e of t)this.#d.add(e);e.clear(),t.clear()}oncommit(e){this.#r.add(e)}ondiscard(e){this.#i.add(e)}settled(){return(this.#s??=_()).promise}static ensure(){if(A===null){let t=A=new e;!lt&&Re(()=>{t.#e||t.flush()})}return A}apply(){j=null}schedule(e){if(st=e,e.b?.is_pending&&e.f&16777228&&!(e.f&32768)){e.b.defer_effect(e);return}for(var t=e;t.parent!==null;){t=t.parent;var n=t.f;if(ut!==null&&t===G&&(H===null||!(H.f&2)))return;if(n&96){if(!(n&1024))return;t.f^=v}}this.#c.push(t)}#x(){if(this.linked){var e=this.#t,t=this.#n;e===null||(e.#n=t),t===null?at=e:t.#t=e,this.linked=!1}}};function ht(){try{ye()}catch(e){Be(e,st)}}var M=null;function gt(e){var t=e.length;if(t!==0){for(var n=0;n0)){N.clear();for(let e of M){if(e.f&24576)continue;let t=[e],n=e.parent;for(;n!==null;)M.has(n)&&(M.delete(n),t.push(n)),n=n.parent;for(let e=t.length-1;e>=0;e--){let n=t[e];n.f&24576||wn(n)}}M.clear()}}M=null}}function _t(e){A.schedule(e)}function vt(e,t){if(!(e.f&32&&e.f&1024)){e.f&2048?t.d.push(e):e.f&4096&&t.m.push(e),k(e,v);for(var n=e.first;n!==null;)vt(n,t),n=n.next}}function yt(e){k(e,v);for(var t=e.first;t!==null;)yt(t),t=t.next}var bt=new Set,N=new Map,xt=!1;function St(e,t){return{f:0,v:e,reactions:null,equals:ke,rv:0,wv:0}}function P(e,t){let n=St(e,t);return pn(n),n}function Ct(t,n=!1,r=!0){let i=St(t);return n||(i.equals=je),e&&r&&O!==null&&O.l!==null&&(O.l.s??=[]).push(i),i}function wt(e,t){return F(e,Dn(()=>Q(e))),t}function F(e,t,n=!1){return H!==null&&(!U||H.f&131072)&&Fe()&&H.f&4325394&&(q===null||!q.has(e))&&Se(),Tt(e,n?kt(t):t,dt)}function Tt(e,t,n=null){if(!e.equals(t)){N.set(e,V?t:e.v);var r=mt.ensure();if(r.capture(e,t),e.f&2){let t=e;e.f&2048&&tt(t),j===null&&He(t)}e.wv=vn(),Ot(e,y,n),Fe()&&G!==null&&G.f&1024&&!(G.f&96)&&(X===null?mn([e]):X.push(e)),!r.is_fork&&bt.size>0&&!xt&&Et()}return t}function Et(){xt=!1;for(let e of bt){e.f&1024&&k(e,b);let t;try{t=yn(e)}catch{t=!0}t&&wn(e)}bt.clear()}function Dt(e){F(e,e.v+1)}function Ot(e,t,n){var r=e.reactions;if(r!==null)for(var i=Fe(),a=r.length,o=0;o{if(gn===c)return e();var t=H,n=gn;W(null),_n(c);var r=e();return W(t),_n(n),r};return a&&n.set(`length`,P(e.length,s)),new Proxy(e,{defineProperty(e,t,r){(!(`value`in r)||r.configurable===!1||r.enumerable===!1||r.writable===!1)&&be();var i=n.get(t);return i===void 0?u(()=>{var e=P(r.value,s);return n.set(t,e),e}):F(i,r.value,!0),!0},deleteProperty(e,t){var i=n.get(t);if(i===void 0){if(t in e){let e=u(()=>P(r,s));n.set(t,e),Dt(o)}}else F(i,r),Dt(o);return!0},get(t,i,a){if(i===le)return e;var o=n.get(i),c=i in t;if(o===void 0&&(!c||l(t,i)?.writable)&&(o=u(()=>P(kt(c?t[i]:r),s)),n.set(i,o)),o!==void 0){var d=Q(o);return d===r?void 0:d}return Reflect.get(t,i,a)},getOwnPropertyDescriptor(e,t){var i=Reflect.getOwnPropertyDescriptor(e,t);if(i&&`value`in i){var a=n.get(t);a&&(i.value=Q(a))}else if(i===void 0){var o=n.get(t),s=o?.v;if(o!==void 0&&s!==r)return{enumerable:!0,configurable:!0,value:s,writable:!0}}return i},has(e,t){if(t===le)return!0;var i=n.get(t),a=i!==void 0&&i.v!==r||Reflect.has(e,t);return(i!==void 0||G!==null&&(!a||l(e,t)?.writable))&&(i===void 0&&(i=u(()=>P(a?kt(e[t]):r,s)),n.set(t,i)),Q(i)===r)?!1:a},set(e,t,i,c){var d=n.get(t),f=t in e;if(a&&t===`length`)for(var p=i;pP(r,s)),n.set(p+``,m)):F(m,r)}if(d===void 0)(!f||l(e,t)?.writable)&&(d=u(()=>P(void 0,s)),F(d,kt(i)),n.set(t,d));else{f=d.v!==r;var h=u(()=>kt(i));F(d,h)}var g=Reflect.getOwnPropertyDescriptor(e,t);if(g?.set&&g.set.call(c,i),!f){if(a&&typeof t==`string`){var _=n.get(`length`),v=Number(t);Number.isInteger(v)&&v>=_.v&&F(_,v+1)}Dt(o)}return!0},ownKeys(e){Q(o);var t=Reflect.ownKeys(e).filter(e=>{var t=n.get(e);return t===void 0||t.v!==r});for(var[i,a]of n)a.v!==r&&!(i in e)&&t.push(i);return t},setPrototypeOf(){xe()}})}var At,jt,Mt;function I(e=``){return document.createTextNode(e)}function Nt(e){return jt.call(e)}function L(e){return Mt.call(e)}function R(e,t){if(!w)return Nt(e);var n=Nt(T);if(n===null)n=T.appendChild(I());else if(t&&n.nodeType!==3){var r=I();return n?.before(r),E(r),r}return t&&zt(n),E(n),n}function Pt(e,t=!1){if(!w){var n=Nt(e);return n instanceof Comment&&n.data===``?L(n):n}if(t){if(T?.nodeType!==3){var r=I();return T?.before(r),E(r),r}zt(T)}return T}function Ft(e,t=1,n=!1){let r=w?T:e;for(var i;t--;)i=r,r=L(r);if(!w)return r;if(n){if(r?.nodeType!==3){var a=I();return r===null?i?.after(a):r.before(a),E(a),a}zt(r)}return E(r),r}function It(e){e.textContent=``}function Lt(){return!1}function Rt(e,t,n){return t==null||t===`http://www.w3.org/1999/xhtml`?n?document.createElement(e,{is:n}):document.createElement(e):n?document.createElementNS(t,e,{is:n}):document.createElementNS(t,e)}function zt(e){if(e.nodeValue.length<65536)return;let t=e.nextSibling;for(;t!==null&&t.nodeType===3;)t.remove(),e.nodeValue+=t.nodeValue,t=e.nextSibling}function Bt(e){G===null&&(H===null&&ve(e),_e()),V&&ge(e)}function Vt(e,t){var n=t.last;n===null?t.last=t.first=e:(n.next=e,e.prev=n,t.last=e)}function z(e,t){var n=G;n!==null&&n.f&8192&&(e|=x);var r={ctx:O,deps:null,nodes:null,f:e|y|512,first:null,fn:t,last:null,next:null,parent:n,b:n&&n.b,prev:null,teardown:null,wv:0,ac:null};A?.register_created_effect(r);var i=r;if(e&4)ut===null?mt.ensure().schedule(r):ut.push(r);else if(t!==null){try{wn(r)}catch(e){throw B(r),e}i.deps===null&&i.teardown===null&&i.nodes===null&&i.first===i.last&&!(i.f&524288)&&(i=i.first,e&16&&e&65536&&i!==null&&(i.f|=te))}if(i!==null&&(i.parent=n,n!==null&&Vt(i,n),H!==null&&H.f&2&&!(e&64))){var a=H;(a.effects??=[]).push(i)}return r}function Ht(){return H!==null&&!U}function Ut(e){let t=z(8,null);return k(t,v),t.teardown=e,t}function Wt(e){Bt(`$effect`);var t=G.f;if(!H&&t&32&&O!==null&&!O.i){var n=O;(n.e??=[]).push(e)}else return Gt(e)}function Gt(e){return z(4|re,e)}function Kt(e){return Bt(`$effect.pre`),z(8|re,e)}function qt(e){return z(4,e)}function Jt(e){return z(se|ne,e)}function Yt(e,t=0){return z(8|t,e)}function Xt(e,t=[],n=[],r=[]){Ke(r,t,n,t=>{z(8,()=>{e(...t.map(Q))})})}function Zt(e,t=0){return z(16|t,e)}function Qt(e){return z(32|ne,e)}function $t(e){var t=e.teardown;if(t!==null){let e=V,n=H;fn(!0),W(null);try{t.call(null)}finally{fn(e),W(n)}}}function en(e,t=!1){var n=e.first;for(e.first=e.last=null;n!==null;){let e=n.ac;e!==null&&Ge(()=>{e.abort(fe)});var r=n.next;n.f&64?n.parent=null:B(n,t),n=r}}function tn(e){for(var t=e.first;t!==null;){var n=t.next;t.f&32||B(t),t=n}}function B(e,t=!0){var n=!1;(t||e.f&262144)&&e.nodes!==null&&e.nodes.end!==null&&(nn(e.nodes.start,e.nodes.end),n=!0),e.f|=C,en(e,t&&!n),Cn(e,0);var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)e.stop();$t(e),e.f^=C,e.f|=S;var i=e.parent;i!==null&&i.first!==null&&rn(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=e.b=null}function nn(e,t){for(;e!==null;){var n=e===t?null:L(e);e.remove(),e=n}}function rn(e){var t=e.parent,n=e.prev,r=e.next;n!==null&&(n.next=r),r!==null&&(r.prev=n),t!==null&&(t.first===e&&(t.first=r),t.last===e&&(t.last=n))}function an(e,t,n=!0){var r=[];on(e,r,!0);var i=()=>{n&&B(e),t&&t()},a=r.length;if(a>0){var o=()=>--a||i();for(var s of r)s.out(o)}else i()}function on(e,t,n){if(!(e.f&8192)){e.f^=x;var r=e.nodes&&e.nodes.t;if(r!==null)for(let e of r)(e.is_global||n)&&t.push(e);for(var i=e.first;i!==null;){var a=i.next;if(!(i.f&64)){var o=!!(i.f&65536)||!!(i.f&32)&&!!(e.f&16);on(i,t,o?n:!1)}i=a}}}function sn(e){cn(e,!0)}function cn(e,t){if(e.f&8192){e.f^=x,e.f&1024||(k(e,y),mt.ensure().schedule(e));for(var n=e.first;n!==null;){var r=n.next,i=!!(n.f&65536)||!!(n.f&32);cn(n,i?t:!1),n=r}var a=e.nodes&&e.nodes.t;if(a!==null)for(let e of a)(e.is_global||t)&&e.in()}}function ln(e,t){if(e.nodes)for(var n=e.nodes.start,r=e.nodes.end;n!==null;){var i=n===r?null:L(n);t.append(n),n=i}}var un=null,dn=!1,V=!1;function fn(e){V=e}var H=null,U=!1;function W(e){H=e}var G=null;function K(e){G=e}var q=null;function pn(e){H!==null&&(q??=new Set).add(e)}var J=null,Y=0,X=null;function mn(e){X=e}var hn=1,Z=0,gn=Z;function _n(e){gn=e}function vn(){return++hn}function yn(e){var t=e.f;if(t&2048)return!0;if(t&2&&(e.f&=~ae),t&4096){for(var n=e.deps,r=n.length,i=0;ie.wv)return!0}t&512&&j===null&&k(e,v)}return!1}function bn(e,t,n=!0){var r=e.reactions;if(r!==null&&!(q!==null&&q.has(e)))for(var i=0;i{e.ac.abort(fe)}),e.ac=null);try{e.f|=oe;var u=e.fn,d=u();e.f|=ee;var f=e.deps,p=A?.is_fork;if(J!==null){var m;if(p||Cn(e,Y),f!==null&&Y>0)for(f.length=Y+J.length,m=0;m{c.ac.abort(fe),c.ac=null,k(c,y)}),rt(c),Cn(c,0)}}function Cn(e,t){var n=e.deps;if(n!==null)for(var r=t;rn?.call(this,e))}return e.startsWith(`pointer`)||e.startsWith(`touch`)||e===`wheel`?Re(()=>{t.addEventListener(e,i,r)}):t.addEventListener(e,i,r),i}function Mn(e,t,n,r,i){var a={capture:r,passive:i},o=jn(e,t,n,a);(t===document.body||t===window||t===document||t instanceof HTMLMediaElement)&&Ut(()=>{t.removeEventListener(e,o,a)})}var Nn=null;function Pn(e){var t=this,n=t.ownerDocument,r=e.type,i=e.composedPath?.()||[],a=i[0]||e.target;Nn=e;var o=0,s=Nn===e&&e[An];if(s){var l=i.indexOf(s);if(l!==-1&&(t===document||t===window)){e[An]=t;return}var u=i.indexOf(t);if(u===-1)return;l<=u&&(o=l)}if(a=i[o]||e.target,a!==t){c(e,`currentTarget`,{configurable:!0,get(){return a||n}});var d=H,f=G;W(null),K(null);try{for(var p,m=[];a!==null&&a!==t;){try{var h=a[An]?.[r];h!=null&&(!a.disabled||e.target===a)&&h.call(a,e)}catch(e){p?m.push(e):p=e}if(e.cancelBubble)break;o++,a=o{throw e});throw p}}finally{e[An]=t,delete e.currentTarget,W(d),K(f)}}}var Fn=globalThis?.window?.trustedTypes&&globalThis.window.trustedTypes.createPolicy(`svelte-trusted-html`,{createHTML:e=>e});function In(e){return Fn?.createHTML(e)??e}function Ln(e){var t=Rt(`template`);return t.innerHTML=In(e.replaceAll(``,``)),t.content}function Rn(e,t){var n=G;n.nodes===null&&(n.nodes={start:e,end:t,a:null,t:null})}function zn(e,t){var n=!!(t&1),r=!!(t&2),i,a=!e.startsWith(``);return()=>{if(w)return Rn(T,null),T;i===void 0&&(i=Ln(a?e:``+e),n||(i=Nt(i)));var t=r||At?document.importNode(i,!0):i.cloneNode(!0);if(n){var o=Nt(t),s=t.lastChild;Rn(o,s)}else Rn(t,t);return t}}function Bn(){if(w)return Rn(T,null),T;var e=document.createDocumentFragment(),t=document.createComment(``),n=I();return e.append(t,n),Rn(t,n),e}function Vn(e,t){if(w){var n=G;(!(n.f&32768)||n.nodes.end===null)&&(n.nodes.end=T),Ee();return}e!==null&&e.before(t)}[...`allowfullscreen.async.autofocus.autoplay.checked.controls.default.disabled.formnovalidate.indeterminate.inert.ismap.loop.multiple.muted.nomodule.novalidate.open.playsinline.readonly.required.reversed.seamless.selected.webkitdirectory.defer.disablepictureinpicture.disableremoteplayback`.split(`.`)];function Hn(e,t){var n=t==null?``:typeof t==`object`?`${t}`:t;n!==(e[de]??=e.nodeValue)&&(e[de]=n,e.nodeValue=`${n}`)}var Un=class{anchor;#e=new Map;#t=new Map;#n=new Map;#r=new Set;#i=!0;constructor(e,t=!0){this.anchor=e,this.#i=t}#a=e=>{if(this.#e.has(e)){var t=this.#e.get(e),n=this.#t.get(t);if(n)sn(n),this.#r.delete(t);else{var r=this.#n.get(t);r&&(sn(r.effect),this.#t.set(t,r.effect),this.#n.delete(t),r.fragment.lastChild.remove(),this.anchor.before(r.fragment),n=r.effect)}for(let[t,n]of this.#e){if(this.#e.delete(t),t===e)break;let r=this.#n.get(n);r&&(B(r.effect),this.#n.delete(n))}for(let[e,r]of this.#t){if(e===t||this.#r.has(e))continue;let i=()=>{if(Array.from(this.#e.values()).includes(e)){var t=document.createDocumentFragment();ln(r,t),t.append(I()),this.#n.set(e,{effect:r,fragment:t})}else B(r);this.#r.delete(e),this.#t.delete(e)};this.#i||!n?(this.#r.add(e),an(r,i,!1)):i()}}};#o=e=>{this.#e.delete(e);let t=Array.from(this.#e.values());for(let[e,n]of this.#n)t.includes(e)||(B(n.effect),this.#n.delete(e))};ensure(e,t){var n=A,r=Lt();if(t&&!this.#t.has(e)&&!this.#n.has(e)){if(r){var i=document.createDocumentFragment(),a=I();i.append(a),this.#n.set(e,{effect:Qt(()=>t(a)),fragment:i})}else this.#t.set(e,Qt(()=>t(this.anchor)))}if(this.#e.set(n,e),r){for(let[t,r]of this.#t)t===e?n.unskip_effect(r):n.skip_effect(r);for(let[t,r]of this.#n)t===e?n.unskip_effect(r.effect):n.skip_effect(r.effect);n.oncommit(this.#a),n.ondiscard(this.#o)}else w&&(this.anchor=T),this.#a(n)}};function Wn(t){O===null&&pe(`onMount`),e&&O.l!==null?Gn(O).m.push(t):Wt(()=>{let e=Dn(t);if(typeof e==`function`)return e})}function Gn(e){var t=e.l;return t.u??={a:[],b:[],m:[]}}function Kn(e,t,n=!1){var r;w&&(r=T,Ee());var i=new Un(e),a=n?te:0;function o(e,t){if(w){var n=Oe(r);if(e!==parseInt(n.substring(1))){var a=De();E(a),i.anchor=a,Te(!1),i.ensure(e,t),Te(!0);return}}i.ensure(e,t)}Zt(()=>{var e=!1;t((t,n=0)=>{e=!0,o(n,t)}),e||o(-1,null)},a)}function qn(e,t,n){for(var r=[],i=t.length,a,o=t.length,c=0;c{if(a){if(a.pending.delete(n),a.done.add(n),a.pending.size===0){var t=e.outrogroups;Jn(e,s(a.done)),t.delete(a),t.size===0&&(e.outrogroups=null)}}else--o},!1)}if(o===0){var l=r.length===0&&n!==null;if(l){var u=n,d=u.parentNode;It(d),d.append(u),e.items.clear()}Jn(e,t,!l)}else a={pending:new Set(t),done:new Set},(e.outrogroups??=new Set).add(a)}function Jn(e,t,n=!0){var r;if(e.pending.size>0){r=new Set;for(let t of e.pending.values())for(let n of t)r.add(e.items.get(n).e)}for(var i=0;i{var e=n();return i(e)?e:e==null?[]:s(e)}),p,m=new Map,h=!0;function g(e){v.effect.f&16384||(v.pending.delete(e),v.fallback=d,Qn(v,p,c,t,r),d!==null&&(p.length===0?d.f&33554432?(d.f^=ie,er(d,null,c)):sn(d):an(d,()=>{d=null})))}function _(e){v.pending.delete(e)}var v={effect:Zt(()=>{p=Q(f);var e=p.length;let i=!1;w&&Oe(c)===`[!`!=(e===0)&&(c=De(),E(c),Te(!1),i=!0);for(var s=new Set,u=A,v=Lt(),y=0;yo(c)):(d=Qt(()=>o(Yn??=I())),d.f|=ie)),e>s.size&&he(``,``,``),w&&e>0&&E(De()),!h){if(m.set(u,s),v){for(let[e,t]of l)s.has(e)||u.skip_effect(t.e);u.oncommit(g),u.ondiscard(_)}else g(u)}i&&Te(!0),Q(f)}),flags:t,items:l,pending:m,outrogroups:null,fallback:d};h=!1,w&&(c=T)}function Zn(e){for(;e!==null&&!(e.f&32);)e=e.next;return e}function Qn(e,t,n,r,i){var a=!!(r&8),o=t.length,c=e.items,l=Zn(e.effect.first),u,d=null,f,p=[],m=[],h,g,_,v;if(a)for(v=0;v0){var ne=r&4&&o===0?n:null;if(a){for(v=0;v{if(f!==void 0)for(_ of f)_.nodes?.a?.apply()})}function $n(e,t,n,r,i,a,o,s){var c=o&1?o&16?St(n):Ct(n,!1,!1):null,l=o&2?St(i):null;return{v:c,i:l,e:Qt(()=>(a(t,c??n,l??i,s),()=>{e.delete(r)}))}}function er(e,t,n){if(e.nodes)for(var r=e.nodes.start,i=e.nodes.end,a=t&&!(t.f&33554432)?t.nodes.start:n;r!==null;){var o=L(r);if(a.before(r),r===i)return;r=o}}function $(e,t,n){t===null?e.effect.first=n:t.next=n,n===null?e.effect.last=t:n.prev=t}var tr=[...` +\r\f\xA0\v`];function nr(e,t,n){var r=e==null?``:``+e;if(t&&(r=r?r+` `+t:t),n){for(var i of Object.keys(n))if(n[i])r=r?r+` `+i:i;else if(r.length)for(var a=i.length,o=0;(o=r.indexOf(i,o))>=0;){var s=o+a;(o===0||tr.includes(r[o-1]))&&(s===r.length||tr.includes(r[s]))?r=(o===0?``:r.substring(0,o))+r.substring(s+1):o=s}}return r===``?null:r}function rr(e,t,n,r,i,a){var o=e[ue];if(w||o!==n||o===void 0){var s=nr(n,r,a);(!w||s!==e.getAttribute(`class`))&&(s==null?e.removeAttribute(`class`):t?e.className=s:e.setAttribute(`class`,s)),e[ue]=n}else if(a&&i!==a)for(var c in a){var l=!!a[c];(i==null||l!==!!i[c])&&e.classList.toggle(c,l)}return a}function ir(e,t){return e===t||e?.[le]===t}function ar(e={},t,n,r){var i=O.r,a=G;return qt(()=>{var o,s;return Yt(()=>{o=s,s=r?.()||[],Dn(()=>{ir(n(...s),e)||(t(e,...s),o&&ir(n(...o),e)&&t(null,...o))})}),()=>{let r=a;for(;r!==i&&r.parent!==null&&r.parent.f&33554432;)r=r.parent;let o=()=>{s&&ir(n(...s),e)&&t(null,...s)},c=r.teardown;r.teardown=()=>{o(),c?.()}}}),e}function or(e=!1){let t=O,n=t.l.u;if(!n)return;let r=()=>On(t.s);if(e){let e=0,n={},i=Xe(()=>{let r=!1,i=t.s;for(let e in i)i[e]!==n[e]&&(n[e]=i[e],r=!0);return r&&e++,e});r=()=>Q(i)}n.b.length&&Kt(()=>{sr(t,r),g(n.b)}),Wt(()=>{let e=Dn(()=>n.m.map(h));return()=>{for(let t of e)typeof t==`function`&&t()}}),n.a.length&&Wt(()=>{sr(t,r),g(n.a)})}function sr(e,t){if(e.l.s)for(let t of e.l.s)Q(t);t()}function cr(e,t,n=1e4){let r=new Set(e.map(e=>e.id)),i=[...e,...t.filter(e=>!r.has(e.id))];return i.slice(Math.max(0,i.length-n))}function lr(e){return Math.min(3e4,500*2**Math.min(e,6))}var ur=zn(`

Access denied

This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.

`),dr=zn(`
Loading older logs…
`),fr=zn(`
`),pr=zn(``),mr=zn(`
`);function hr(e,t){Ne(t,!1);let n=Ct([]),r=null,i=!0,a=Ct(`Connecting`),o=Ct(),s=!0,c=Ct(0),l=Ct(!1),u=null,d=!1;async function f(e,t=1e3){let n=e?`/api/logs/history?before=${encodeURIComponent(e)}&limit=${t}`:`/api/logs/recent?limit=${t}`,r=await fetch(n);if(r.status===403)throw F(a,`Access denied`),Error(`denied`);if(!r.ok)throw Error(`history`);return r.json()}async function p(){let e=await f(null);F(n,cr([],e.lines)),r=e.next_before,i=e.has_more,requestAnimationFrame(g)}async function m(){if(Q(l)||!i||!r)return;F(l,!0);let e=Q(o).scrollHeight;try{let t=await f(r),a=new Set(Q(n).map(e=>e.id));F(n,[...t.lines.filter(e=>!a.has(e.id)),...Q(n)]),r=t.next_before,i=t.has_more,requestAnimationFrame(()=>wt(o,Q(o).scrollTop+=Q(o).scrollHeight-e))}finally{F(l,!1)}}function h(e=0){if(d||Q(a)===`Access denied`)return;F(a,e?`Reconnecting`:`Connecting`);let t=location.protocol===`https:`?`wss`:`ws`;u=new WebSocket(`${t}://${location.host}/api/live`),u.onopen=async()=>{F(a,`Live`);try{let e=await f(null,1e3);F(n,cr(Q(n),e.lines)),s&&requestAnimationFrame(g)}catch{}},u.onmessage=e=>{let t=JSON.parse(e.data);t.type===`log_lines`&&(F(n,cr(Q(n),t.lines)),s?requestAnimationFrame(g):F(c,Q(c)+t.lines.length)),t.type===`resync_required`&&u?.close()},u.onclose=()=>{d||Q(a)===`Access denied`||(F(a,`Reconnecting`),setTimeout(()=>h(e+1),lr(e)))},u.onerror=()=>u?.close()}function g(){Q(o)?.scrollTo({top:Q(o).scrollHeight}),F(c,0)}function _(){s=Q(o).scrollHeight-Q(o).scrollTop-Q(o).clientHeight<40,s&&F(c,0),Q(o).scrollTop<200&&m()}Wn(()=>(p().then(()=>h()).catch(()=>{}),()=>{d=!0,u?.close()})),or();var v=Bn(),y=Pt(v),b=e=>{Vn(e,ur())},x=e=>{var t=mr(),r=R(t),i=R(r),s=R(i);let u;var d=Ft(s),f=R(d,!0);D(d),D(i);var p=Ft(i),m=R(p);D(p),D(r);var h=Ft(r),v=R(h),y=e=>{Vn(e,dr())};Kn(v,e=>{Q(l)&&e(y)}),Xn(Ft(v),1,()=>Q(n),e=>e.id,(e,t)=>{var n=fr(),r=R(n),i=R(r,!0);D(r);var a=Ft(r),o=R(a,!0);D(a),D(n),Xt(()=>{Hn(i,Q(t).timestamp??``),Hn(o,Q(t).text)}),Vn(e,n)}),D(h),ar(h,e=>F(o,e),()=>Q(o));var b=Ft(h),x=e=>{var t=pr(),n=R(t);D(t),Xt(()=>Hn(n,`${Q(c)??``} new ${Q(c)===1?`line`:`lines`} ↓`)),Mn(`click`,t,g),Vn(e,t)};Kn(b,e=>{Q(c)&&e(x)}),D(t),Xt(e=>{u=rr(s,1,`dot`,null,u,{live:Q(a)===`Live`}),Hn(f,Q(a)),Hn(m,`${e??``} lines retained`)},[()=>Q(n).length.toLocaleString()]),Mn(`scroll`,h,_),Vn(e,t)};Kn(y,e=>{Q(a)===`Access denied`?e(b):e(x,-1)}),Vn(e,v),Pe()}new hr({target:document.getElementById(`app`)}); \ No newline at end of file diff --git a/frontend/dist/index.html b/frontend/dist/index.html new file mode 100644 index 0000000..4b12d1f --- /dev/null +++ b/frontend/dist/index.html @@ -0,0 +1,3 @@ +Minecraft server logs + +
diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..eb0cded --- /dev/null +++ b/frontend/index.html @@ -0,0 +1 @@ +Minecraft server logs
diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..cbd5432 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2326 @@ +{ + "name": "minecraft-log-viewer-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minecraft-log-viewer-ui", + "version": "0.1.0", + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "latest", + "@testing-library/svelte": "latest", + "@types/node": "latest", + "jsdom": "latest", + "svelte": "latest", + "svelte-check": "latest", + "typescript": "latest", + "vite": "latest", + "vitest": "latest" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.143.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz", + "integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz", + "integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz", + "integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz", + "integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz", + "integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz", + "integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz", + "integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz", + "integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz", + "integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz", + "integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz", + "integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz", + "integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz", + "integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.12.tgz", + "integrity": "sha512-J1jNYG23QWd67UfrQSFHtjhV37r9mVi0gdc12A3MWPldOjRK35Xk+um+qACPVjgw3AleiqoyEAhok8Wm3q46NA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/load-config": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.2.tgz", + "integrity": "sha512-K7dsJDQxBOF+f+epuhMactcjK2VP4MRkLKtwSykNtEI+cKVEyzrmmhQ1pmoxI800m4JKcyJ05L2M4yPwAGiBNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.3.0.tgz", + "integrity": "sha512-QbRoJyD92e9R0ufeQIWRHrCC0ObcqSv/aBDdrQMoU+sypav3cDx5wytdQ6GLdXjEMO6xjrXGzfkUygng8JMv0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "deepmerge": "^4.3.1", + "magic-string": "^1.0.0", + "obug": "^2.1.0", + "vitefu": "^1.1.2" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24" + }, + "peerDependencies": { + "svelte": "^5.46.4", + "vite": "^8.0.0-beta.7 || ^8.0.0" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/svelte": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/svelte/-/svelte-5.4.2.tgz", + "integrity": "sha512-4o31E4HGo5BU5KwPkulNRocEden+7Tt9JYm9uhln5ajF7DULeyFA46BBWVfKJ8Ms9B3JmOFPTIiVamH7n3KpuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@testing-library/dom": "9.x.x || 10.x.x", + "@testing-library/svelte-core": "1.1.3" + }, + "engines": { + "node": ">= 10" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0", + "vite": "*", + "vitest": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + }, + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/svelte-core": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.1.3.tgz", + "integrity": "sha512-KkMAvXeWorxN2Yn0kdC1lfoAItxpoj4uOWzxK5leDrNxonLvS5nwBFvztrroyTszQ0Wf/EU6iLT8JhY5qcn22g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.2.0.tgz", + "integrity": "sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/devalue": { + "version": "5.9.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.0.tgz", + "integrity": "sha512-RWrqdArjvPbsATEhOPUo6Wndc/iWnkWKlhIrdlF3zMMYo/c3CVtoaVAyLtWxz5h8nSlkHzxnzV2uLydPXmtF+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.3.2.tgz", + "integrity": "sha512-40GyiEJevYKXzYTHtZkFqAgTjLOuFcaXMao8TPyOlnWTlkHDlvZ6mPMJaJyOqVwrVCgomEG1WhJd81w0X+IcCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.1.0.tgz", + "integrity": "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", + "integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.143.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.2.3", + "@rolldown/binding-darwin-arm64": "1.2.3", + "@rolldown/binding-darwin-x64": "1.2.3", + "@rolldown/binding-freebsd-x64": "1.2.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.3", + "@rolldown/binding-linux-arm64-gnu": "1.2.3", + "@rolldown/binding-linux-arm64-musl": "1.2.3", + "@rolldown/binding-linux-ppc64-gnu": "1.2.3", + "@rolldown/binding-linux-s390x-gnu": "1.2.3", + "@rolldown/binding-linux-x64-gnu": "1.2.3", + "@rolldown/binding-linux-x64-musl": "1.2.3", + "@rolldown/binding-openharmony-arm64": "1.2.3", + "@rolldown/binding-win32-arm64-msvc": "1.2.3", + "@rolldown/binding-win32-x64-msvc": "1.2.3" + } + }, + "node_modules/sade": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", + "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "mri": "^1.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/svelte": { + "version": "5.56.8", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", + "integrity": "sha512-PY8LOw7xP6c8IOiVqdo0sbbZVYhXRSfklOQLAUyGBKqjTX0wx/z4l/9J+PmBpmlLnxzEb1NqltxQ5/wZme/Cmg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.12", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/svelte-check": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.7.5.tgz", + "integrity": "sha512-NnkHGCTPH6k4ka1E9IpTuNv40uLArHnX52kLEuaHSGqRlPYTnkbFs529jSWG+y+wDy+v+jA2PQ0soN1umVK+OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "@sveltejs/load-config": "^0.2.2", + "chokidar": "^4.0.1", + "fdir": "^6.2.0", + "picocolors": "^1.0.0", + "sade": "^1.7.4" + }, + "bin": { + "svelte-check": "bin/svelte-check" + }, + "engines": { + "node": ">= 18.0.0" + }, + "peerDependencies": { + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.0.0 || ^6.0.0" + } + }, + "node_modules/svelte/node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/svelte/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.1.tgz", + "integrity": "sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.5", + "postcss": "^8.5.25", + "rolldown": "~1.2.1", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.4.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..5f459f3 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1 @@ +{"name":"minecraft-log-viewer-ui","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite","build":"vite build","check":"svelte-check --tsconfig ./tsconfig.json","test":"vitest run"},"devDependencies":{"@sveltejs/vite-plugin-svelte":"latest","@testing-library/svelte":"latest","@types/node":"latest","jsdom":"latest","svelte":"latest","svelte-check":"latest","typescript":"latest","vite":"latest","vitest":"latest"}} diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..239e2c7 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,12 @@ + +{#if state==='Access denied'}

Access denied

This viewer is available only from an IP address associated with a currently whitelisted player who has successfully joined the server.

{:else}
{state}
{lines.length.toLocaleString()} lines retained
{#if loading}
Loading older logs…
{/if}{#each lines as line (line.id)}
{line.timestamp??''}{line.text}
{/each}
{#if unseen}{/if}
{/if} diff --git a/frontend/src/lib.test.ts b/frontend/src/lib.test.ts new file mode 100644 index 0000000..17936ae --- /dev/null +++ b/frontend/src/lib.test.ts @@ -0,0 +1,3 @@ +import {describe,expect,it} from 'vitest';import {backoff,mergeLines,type LogLine} from './lib'; +const line=(id:string):LogLine=>({id,timestamp:null,text:id,source:'latest.log'}); +describe('live/history reconciliation',()=>{it('deduplicates overlapping snapshots',()=>expect(mergeLines([line('a'),line('b')],[line('b'),line('c')]).map(x=>x.id)).toEqual(['a','b','c']));it('bounds retained client history',()=>expect(mergeLines([line('a'),line('b')],[line('c')],2).map(x=>x.id)).toEqual(['b','c']));it('caps reconnect backoff',()=>expect(backoff(99)).toBe(30000));}); diff --git a/frontend/src/lib.ts b/frontend/src/lib.ts new file mode 100644 index 0000000..89c7476 --- /dev/null +++ b/frontend/src/lib.ts @@ -0,0 +1,4 @@ +export type LogLine={id:string;timestamp:string|null;text:string;source:string}; +export type Page={lines:LogLine[];next_before:string|null;has_more:boolean}; +export function mergeLines(current:LogLine[],incoming:LogLine[],max=10000):LogLine[]{const seen=new Set(current.map(l=>l.id));const merged=[...current,...incoming.filter(l=>!seen.has(l.id))];return merged.slice(Math.max(0,merged.length-max));} +export function backoff(attempt:number):number{return Math.min(30000,500*2**Math.min(attempt,6));} diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..3a8d74c --- /dev/null +++ b/frontend/src/main.ts @@ -0,0 +1 @@ +import './style.css';import App from './App.svelte';new App({target:document.getElementById('app')!}); diff --git a/frontend/src/style.css b/frontend/src/style.css new file mode 100644 index 0000000..93d56b0 --- /dev/null +++ b/frontend/src/style.css @@ -0,0 +1 @@ +:root{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#dce5df;background:#0b0e0c;font-synthesis:none}*{box-sizing:border-box}body{margin:0;overflow:hidden}main{height:100dvh;display:flex;flex-direction:column;background:radial-gradient(circle at 80% -20%,#193226 0,transparent 38%),#0b0e0c}header{height:52px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;border-bottom:1px solid #26322b;color:#8e9c93;font-size:12px}header div{display:flex;gap:9px;align-items:center;color:#dce5df}.dot{width:8px;height:8px;border-radius:50%;background:#b07847;box-shadow:0 0 0 3px #b0784722}.dot.live{background:#58d68d;box-shadow:0 0 0 3px #58d68d22}.log{flex:1;overflow:auto;padding:12px 0;scrollbar-color:#344139 transparent}.line{display:grid;grid-template-columns:88px minmax(max-content,1fr);gap:14px;padding:2px 18px;min-height:22px;line-height:18px;font-size:13px;white-space:pre}.line:hover{background:#ffffff08}.time{color:#60766a;user-select:none}.loading{text-align:center;color:#8e9c93;padding:8px}button{position:fixed;right:22px;bottom:22px;border:1px solid #537962;border-radius:18px;background:#193226;color:#dce5df;padding:9px 14px;font:inherit;cursor:pointer}.denied{justify-content:center;align-items:center;padding:24px;text-align:center}.denied h1{font:500 28px system-ui;margin:0 0 12px}.denied p{font:14px/1.6 system-ui;color:#9ba9a0;max-width:580px}@media(max-width:600px){header{padding:0 10px}.line{grid-template-columns:0 minmax(max-content,1fr);gap:0;padding:2px 10px}.time{visibility:hidden}} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..e4067a9 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1 @@ +{"compilerOptions":{"target":"ESNext","lib":["ESNext","DOM"],"module":"ESNext","moduleResolution":"bundler","strict":true,"types":["vite/client","node"]},"include":["src/**/*.ts","src/**/*.svelte","vite.config.ts","vitest.config.ts"]} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..acf5b73 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +export default defineConfig({ plugins: [svelte()], build: { outDir: 'dist', emptyOutDir: true } }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..b8e11f4 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +export default defineConfig({ plugins: [svelte()], test: { environment: 'jsdom' } }); diff --git a/src/auth/client_ip.rs b/src/auth/client_ip.rs new file mode 100644 index 0000000..25fe07b --- /dev/null +++ b/src/auth/client_ip.rs @@ -0,0 +1,84 @@ +use http::HeaderMap; +use ipnet::IpNet; +use std::net::IpAddr; +use thiserror::Error; + +#[derive(Debug, Error, PartialEq)] +pub enum ClientIpError { + #[error("forwarded client IP is malformed")] + MalformedForwardedIp, + #[error("forwarded client IP header is not valid text")] + InvalidHeader, +} + +#[derive(Debug, Clone)] +pub struct ClientIpPolicy { + pub trust_proxy: bool, + pub trusted_proxies: Vec, + pub header: String, +} + +impl ClientIpPolicy { + pub fn extract(&self, peer: IpAddr, headers: &HeaderMap) -> Result { + if !self.trust_proxy || !self.trusted_proxies.iter().any(|net| net.contains(&peer)) { + return Ok(normalize(peer)); + } + let Some(value) = headers.get(&self.header) else { + return Ok(normalize(peer)); + }; + let value = value.to_str().map_err(|_| ClientIpError::InvalidHeader)?; + let first = value.split(',').next().unwrap_or("").trim(); + first + .parse() + .map(normalize) + .map_err(|_| ClientIpError::MalformedForwardedIp) + } +} +fn normalize(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => v6 + .to_ipv4_mapped() + .map(IpAddr::V4) + .unwrap_or(IpAddr::V6(v6)), + other => other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn policy() -> ClientIpPolicy { + ClientIpPolicy { + trust_proxy: true, + trusted_proxies: vec!["10.0.0.0/8".parse().unwrap()], + header: "x-forwarded-for".into(), + } + } + #[test] + fn trusts_first_forwarded_ip_only_from_proxy() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "203.0.113.1, 10.0.0.2".parse().unwrap()); + assert_eq!( + policy().extract("10.1.2.3".parse().unwrap(), &h).unwrap(), + "203.0.113.1".parse::().unwrap() + ); + } + #[test] + fn ignores_spoofed_header_from_untrusted_peer() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "203.0.113.1".parse().unwrap()); + assert_eq!( + policy().extract("192.0.2.5".parse().unwrap(), &h).unwrap(), + "192.0.2.5".parse::().unwrap() + ); + } + #[test] + fn rejects_malformed_trusted_header() { + let mut h = HeaderMap::new(); + h.insert("x-forwarded-for", "not-ip".parse().unwrap()); + assert_eq!( + policy().extract("10.1.2.3".parse().unwrap(), &h), + Err(ClientIpError::MalformedForwardedIp) + ); + } +} diff --git a/src/auth/join_tracker.rs b/src/auth/join_tracker.rs new file mode 100644 index 0000000..ac7a64d --- /dev/null +++ b/src/auth/join_tracker.rs @@ -0,0 +1,109 @@ +use chrono::{DateTime, Utc}; +use once_cell::sync::Lazy; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use std::{ + collections::HashMap, + net::{IpAddr, SocketAddr}, +}; + +static UUID: Lazy = Lazy::new(|| { + Regex::new(r"UUID of player (?P[A-Za-z0-9_]{1,16}) is (?P[0-9a-fA-F-]{32,36})") + .unwrap() +}); +static JOIN: Lazy = Lazy::new(|| { + Regex::new(r"(?P[A-Za-z0-9_]{1,16})\[(?P
.+)\] logged in(?: with entity id)?") + .unwrap() +}); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct JoinRecord { + pub ip: IpAddr, + pub name: String, + pub uuid: Option, + pub joined_at: DateTime, +} + +#[derive(Debug, Default)] +pub struct JoinTracker { + pending_uuids: HashMap, +} + +impl JoinTracker { + pub fn push(&mut self, line: &str) -> Option { + if let Some(c) = UUID.captures(line) { + self.pending_uuids + .insert(c["name"].to_ascii_lowercase(), c["uuid"].to_owned()); + return None; + } + let c = JOIN.captures(line)?; + let name = c["name"].to_owned(); + let ip = parse_log_address(&c["address"])?; + let uuid = self.pending_uuids.remove(&name.to_ascii_lowercase()); + Some(JoinRecord { + ip, + name, + uuid, + joined_at: Utc::now(), + }) + } + pub fn clear(&mut self) { + self.pending_uuids.clear(); + } +} + +pub fn parse_log_address(raw: &str) -> Option { + let raw = raw.trim().trim_start_matches('/'); + raw.parse::() + .map(|s| s.ip()) + .ok() + .or_else(|| raw.parse().ok()) + .or_else(|| { + let (ip, port) = raw.rsplit_once(':')?; + port.parse::().ok()?; + ip.parse().ok() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn successful_join_correlates_uuid() { + let mut p = JoinTracker::default(); + assert!(p + .push("UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000") + .is_none()); + let r = p + .push("Alex[/203.0.113.4:51234] logged in with entity id 1") + .unwrap(); + assert_eq!(r.ip, "203.0.113.4".parse::().unwrap()); + assert!(r.uuid.is_some()); + } + #[test] + fn rejected_and_banned_lines_never_authorize() { + let mut p = JoinTracker::default(); + assert!(p + .push("Alex (/203.0.113.4:1) lost connection: You are not whitelisted") + .is_none()); + assert!(p.push("Disconnecting Alex: You are banned").is_none()); + } + #[test] + fn supports_ipv6() { + assert_eq!( + parse_log_address("/[2001:db8::1]:25565").unwrap(), + "2001:db8::1".parse::().unwrap() + ); + } + #[test] + fn rotation_discards_candidate_uuid() { + let mut p = JoinTracker::default(); + p.push("UUID of player Alex is 123e4567-e89b-12d3-a456-426614174000"); + p.clear(); + assert!(p + .push("Alex[/203.0.113.4:1] logged in") + .unwrap() + .uuid + .is_none()); + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..a17deb1 --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,169 @@ +pub mod client_ip; +pub mod join_tracker; +pub mod whitelist; + +use crate::auth::{ + join_tracker::{JoinRecord, JoinTracker}, + whitelist::Whitelist, +}; +use std::{ + collections::HashMap, + net::IpAddr, + path::Path, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, + }, +}; +use tokio::sync::{Mutex, RwLock}; + +#[derive(Debug, Default, Clone)] +pub struct AuthorizationSnapshot { + by_ip: HashMap>, +} + +impl AuthorizationSnapshot { + pub fn observe(&mut self, record: JoinRecord) { + self.by_ip.entry(record.ip).or_default().push(record); + } + pub fn is_allowed(&self, ip: IpAddr, whitelist: &Whitelist) -> bool { + self.by_ip.get(&ip).is_some_and(|records| { + records + .iter() + .any(|r| whitelist.contains(r.uuid.as_deref(), &r.name)) + }) + } + pub fn len(&self) -> usize { + self.by_ip.len() + } + pub fn is_empty(&self) -> bool { + self.by_ip.is_empty() + } +} + +#[derive(Clone)] +pub struct AuthorizationService { + latest_log: Arc, + whitelist: Arc>, + snapshot: Arc>, + tracker: Arc>, + rescan: Arc>, + generation: Arc, + rescan_count: Arc, +} + +impl AuthorizationService { + pub fn new(latest_log: std::path::PathBuf, whitelist: Whitelist) -> Self { + Self { + latest_log: Arc::new(latest_log), + whitelist: Arc::new(RwLock::new(whitelist)), + snapshot: Default::default(), + tracker: Arc::new(Mutex::new(JoinTracker::default())), + rescan: Default::default(), + generation: Default::default(), + rescan_count: Default::default(), + } + } + pub async fn replace_whitelist(&self, value: Whitelist) { + *self.whitelist.write().await = value; + } + pub async fn observe_line(&self, line: &str) { + if let Some(record) = self.tracker.lock().await.push(line) { + self.snapshot.write().await.observe(record); + } + } + pub async fn clear_for_rotation(&self) { + *self.snapshot.write().await = AuthorizationSnapshot::default(); + self.tracker.lock().await.clear(); + } + pub async fn is_allowed(&self, ip: IpAddr) -> bool { + if self.check(ip).await { + return true; + } + let observed_generation = self.generation.load(Ordering::Acquire); + let _guard = self.rescan.lock().await; + if self.check(ip).await { + return true; + } + if self.generation.load(Ordering::Acquire) != observed_generation { + return false; + } + let latest_log = self.latest_log.clone(); + if let Ok(Ok(rebuilt)) = tokio::task::spawn_blocking(move || scan_latest(&latest_log)).await + { + *self.snapshot.write().await = rebuilt; + self.rescan_count.fetch_add(1, Ordering::Relaxed); + self.generation.fetch_add(1, Ordering::Release); + } + self.check(ip).await + } + pub async fn rebuild(&self) -> std::io::Result<()> { + let latest_log = self.latest_log.clone(); + let rebuilt = tokio::task::spawn_blocking(move || scan_latest(&latest_log)) + .await + .map_err(std::io::Error::other)??; + *self.snapshot.write().await = rebuilt; + self.rescan_count.fetch_add(1, Ordering::Relaxed); + self.generation.fetch_add(1, Ordering::Release); + Ok(()) + } + pub fn rescan_count(&self) -> u64 { + self.rescan_count.load(Ordering::Relaxed) + } + async fn check(&self, ip: IpAddr) -> bool { + let snapshot = self.snapshot.read().await; + let whitelist = self.whitelist.read().await; + snapshot.is_allowed(ip, &whitelist) + } +} + +pub fn scan_latest(path: &Path) -> std::io::Result { + use std::io::BufRead; + let file = std::fs::File::open(path)?; + let mut tracker = JoinTracker::default(); + let mut snapshot = AuthorizationSnapshot::default(); + for line in std::io::BufReader::new(file).lines() { + if let Some(record) = tracker.push(&line?) { + snapshot.observe(record); + } + } + Ok(snapshot) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn archives_are_never_scanned_for_auth() { + let dir = tempfile::tempdir().unwrap(); + let latest = dir.path().join("latest.log"); + std::fs::write( + &latest, + "[12:00:00] [Server thread/INFO]: Alex[/203.0.113.4:1234] logged in\n", + ) + .unwrap(); + let s = scan_latest(&latest).unwrap(); + assert_eq!(s.len(), 1); + } + #[tokio::test] + async fn concurrent_misses_share_one_rescan() { + let dir = tempfile::tempdir().unwrap(); + let latest = dir.path().join("latest.log"); + std::fs::write(&latest, "unrelated\n").unwrap(); + let service = AuthorizationService::new(latest, Whitelist::default()); + let before = service.rescan_count(); + let mut tasks = Vec::new(); + for suffix in 1..=16 { + let service = service.clone(); + tasks.push(tokio::spawn(async move { + service + .is_allowed(format!("192.0.2.{suffix}").parse().unwrap()) + .await + })); + } + for task in tasks { + assert!(!task.await.unwrap()); + } + assert_eq!(service.rescan_count() - before, 1); + } +} diff --git a/src/auth/whitelist.rs b/src/auth/whitelist.rs new file mode 100644 index 0000000..0826e59 --- /dev/null +++ b/src/auth/whitelist.rs @@ -0,0 +1,75 @@ +use serde::Deserialize; +use std::{collections::HashSet, path::Path}; +use thiserror::Error; + +#[derive(Debug, Clone, Default)] +pub struct Whitelist { + uuids: HashSet, + names: HashSet, +} + +#[derive(Debug, Deserialize)] +struct Entry { + uuid: String, + name: String, +} + +#[derive(Debug, Error)] +pub enum WhitelistError { + #[error("cannot read whitelist: {0}")] + Io(#[from] std::io::Error), + #[error("invalid whitelist JSON: {0}")] + Json(#[from] serde_json::Error), +} + +impl Whitelist { + pub fn parse(bytes: &[u8]) -> Result { + let entries: Vec = serde_json::from_slice(bytes)?; + Ok(Self { + uuids: entries.iter().map(|e| normalize_uuid(&e.uuid)).collect(), + names: entries + .into_iter() + .map(|e| e.name.to_ascii_lowercase()) + .collect(), + }) + } + pub fn load(path: &Path) -> Result { + Self::parse(&std::fs::read(path)?) + } + pub fn contains(&self, uuid: Option<&str>, name: &str) -> bool { + uuid.map(|u| self.uuids.contains(&normalize_uuid(u))) + .unwrap_or_else(|| self.names.contains(&name.to_ascii_lowercase())) + } + pub fn len(&self) -> usize { + self.uuids.len() + } + pub fn is_empty(&self) -> bool { + self.uuids.is_empty() + } +} +fn normalize_uuid(value: &str) -> String { + value.replace('-', "").to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parses_structural_json_and_matches_uuid() { + let w = + Whitelist::parse(br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#) + .unwrap(); + assert!(w.contains(Some("123e4567e89b12d3a456426614174000"), "renamed")); + } + #[test] + fn uuid_takes_precedence_over_name() { + let w = + Whitelist::parse(br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#) + .unwrap(); + assert!(!w.contains(Some("00000000-0000-0000-0000-000000000000"), "Alex")); + } + #[test] + fn malformed_json_fails_closed() { + assert!(Whitelist::parse(b"not json").is_err()); + } +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..b2934b4 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,174 @@ +use ipnet::IpNet; +use std::{env, net::SocketAddr, path::PathBuf, str::FromStr}; +use thiserror::Error; + +#[derive(Debug, Clone)] +pub struct Config { + pub listen_addr: SocketAddr, + pub data_dir: PathBuf, + pub log_dir: PathBuf, + pub latest_log: PathBuf, + pub whitelist: PathBuf, + pub trust_proxy: bool, + pub trusted_proxy_cidrs: Vec, + pub client_ip_header: String, + pub public_origin: String, + pub initial_log_lines: usize, + pub max_history_lines: usize, + pub archive_cache_max_bytes: usize, + pub archive_cache_max_files: usize, + pub max_archive_bytes: usize, + pub ws_queue_capacity: usize, + pub max_ws_connections: usize, + pub redact_player_ips: bool, +} + +#[derive(Debug, Error)] +pub enum ConfigError { + #[error("invalid {name}: {message}")] + Invalid { name: &'static str, message: String }, + #[error("required path is missing or unreadable: {0}")] + MissingPath(PathBuf), +} + +impl Config { + pub fn from_env() -> Result { + let data_dir = path("MC_DATA_DIR", "/data"); + let log_dir = path("MC_LOG_DIR", data_dir.join("logs")); + let latest_log = path("MC_LATEST_LOG", log_dir.join("latest.log")); + let whitelist = path("MC_WHITELIST", data_dir.join("whitelist.json")); + let trust_proxy = boolean("TRUST_PROXY", false)?; + let trusted_proxy_cidrs = csv("TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128") + .into_iter() + .map(|value| parse("TRUSTED_PROXY_CIDRS", &value)) + .collect::, _>>()?; + if trust_proxy && trusted_proxy_cidrs.is_empty() { + return Err(ConfigError::Invalid { + name: "TRUSTED_PROXY_CIDRS", + message: "must not be empty when TRUST_PROXY=true".into(), + }); + } + let public_origin = env::var("PUBLIC_ORIGIN").map_err(|_| ConfigError::Invalid { + name: "PUBLIC_ORIGIN", + message: "must be set, for example https://logs.example.com".into(), + })?; + if !(public_origin.starts_with("https://") || public_origin.starts_with("http://")) { + return Err(ConfigError::Invalid { + name: "PUBLIC_ORIGIN", + message: "must be an absolute HTTP(S) origin".into(), + }); + } + Ok(Self { + listen_addr: parse( + "LISTEN_ADDR", + &env::var("LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".into()), + )?, + data_dir, + log_dir, + latest_log, + whitelist, + trust_proxy, + trusted_proxy_cidrs, + client_ip_header: env::var("CLIENT_IP_HEADER") + .unwrap_or_else(|_| "X-Forwarded-For".into()), + public_origin, + initial_log_lines: number("INITIAL_LOG_LINES", 1000, 1, 10_000)?, + max_history_lines: number("MAX_HISTORY_LINES_PER_REQUEST", 2000, 1, 10_000)?, + archive_cache_max_bytes: number( + "ARCHIVE_CACHE_MAX_BYTES", + 64 * 1024 * 1024, + 0, + usize::MAX, + )?, + archive_cache_max_files: number("ARCHIVE_CACHE_MAX_FILES", 8, 0, 1_000)?, + max_archive_bytes: number( + "MAX_ARCHIVE_DECOMPRESSED_BYTES", + 64 * 1024 * 1024, + 1024, + usize::MAX, + )?, + ws_queue_capacity: number("WS_CLIENT_QUEUE_CAPACITY", 256, 1, 65_536)?, + max_ws_connections: number("MAX_WS_CONNECTIONS", 64, 1, 100_000)?, + redact_player_ips: boolean("REDACT_PLAYER_IPS", true)?, + }) + } + + pub fn validate_paths(&self) -> Result<(), ConfigError> { + for path in [ + &self.data_dir, + &self.log_dir, + &self.latest_log, + &self.whitelist, + ] { + if !path.exists() { + return Err(ConfigError::MissingPath(path.clone())); + } + } + Ok(()) + } +} + +fn path(key: &str, default: impl Into) -> PathBuf { + env::var_os(key) + .map(PathBuf::from) + .unwrap_or_else(|| default.into()) +} +fn csv(key: &str, default: &str) -> Vec { + env::var(key) + .unwrap_or_else(|_| default.into()) + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect() +} +fn parse(name: &'static str, value: &str) -> Result +where + T::Err: std::fmt::Display, +{ + value.parse().map_err(|e: T::Err| ConfigError::Invalid { + name, + message: e.to_string(), + }) +} +fn boolean(key: &'static str, default: bool) -> Result { + match env::var(key) { + Ok(v) if v.eq_ignore_ascii_case("true") || v == "1" => Ok(true), + Ok(v) if v.eq_ignore_ascii_case("false") || v == "0" => Ok(false), + Ok(v) => Err(ConfigError::Invalid { + name: key, + message: format!("expected true/false, got {v}"), + }), + Err(_) => Ok(default), + } +} +fn number(key: &'static str, default: usize, min: usize, max: usize) -> Result { + let value = match env::var(key) { + Ok(v) => parse(key, &v)?, + Err(_) => default, + }; + if !(min..=max).contains(&value) { + return Err(ConfigError::Invalid { + name: key, + message: format!("must be between {min} and {max}"), + }); + } + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parses_boolean_values() { + std::env::set_var("TEST_BOOLEAN", "1"); + assert!(boolean("TEST_BOOLEAN", false).unwrap()); + std::env::remove_var("TEST_BOOLEAN"); + } + #[test] + fn rejects_out_of_range_number() { + std::env::set_var("TEST_NUMBER", "0"); + assert!(number("TEST_NUMBER", 3, 1, 4).is_err()); + std::env::remove_var("TEST_NUMBER"); + } +} diff --git a/src/frontend.rs b/src/frontend.rs new file mode 100644 index 0000000..767a9a6 --- /dev/null +++ b/src/frontend.rs @@ -0,0 +1,45 @@ +use axum::{ + body::Body, + http::{header, Response, StatusCode}, +}; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "frontend/dist/"] +struct Assets; + +pub async fn serve(uri: axum::http::Uri) -> Response { + let path = uri.path().trim_start_matches('/'); + let path = if path.is_empty() { "index.html" } else { path }; + let (asset, content_path) = match Assets::get(path) { + Some(asset) => (Some(asset), path), + None => (Assets::get("index.html"), "index.html"), + }; + match asset { + Some(file) => Response::builder() + .status(StatusCode::OK) + .header( + header::CONTENT_TYPE, + mime_guess::from_path(content_path) + .first_or_octet_stream() + .as_ref(), + ) + .body(Body::from(file.data)) + .unwrap(), + None => Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .unwrap(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn embeds_spa() { + let r = serve("/missing/route".parse().unwrap()).await; + assert_eq!(r.status(), StatusCode::OK); + assert_eq!(r.headers()[header::CONTENT_TYPE], "text/html"); + } +} diff --git a/src/http/mod.rs b/src/http/mod.rs new file mode 100644 index 0000000..afa4673 --- /dev/null +++ b/src/http/mod.rs @@ -0,0 +1,185 @@ +use crate::{ + auth::{client_ip::ClientIpPolicy, AuthorizationService}, + config::Config, + logs::{follower::LiveMessage, index::HistoryStore}, +}; +use axum::{ + extract::{ + ws::{Message, WebSocket, WebSocketUpgrade}, + ConnectInfo, Query, State, + }, + http::{header, HeaderMap, StatusCode}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use serde::Deserialize; +use std::{ + net::{IpAddr, SocketAddr}, + sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }, +}; +use tokio::sync::broadcast; +use tower_http::{ + catch_panic::CatchPanicLayer, limit::RequestBodyLimitLayer, set_header::SetResponseHeaderLayer, + trace::TraceLayer, +}; + +#[derive(Clone)] +pub struct AppState { + pub config: Arc, + pub auth: AuthorizationService, + pub history: HistoryStore, + pub live_tx: broadcast::Sender, + pub ip_policy: ClientIpPolicy, + pub ws_count: Arc, +} + +pub fn router(state: AppState) -> Router { + Router::new().route("/api/status",get(status)).route("/api/logs/recent",get(recent)).route("/api/logs/history",get(history)).route("/api/live",get(websocket)).fallback(crate::frontend::serve).with_state(state).layer(RequestBodyLimitLayer::new(16*1024)).layer(SetResponseHeaderLayer::if_not_present(header::X_CONTENT_TYPE_OPTIONS,header::HeaderValue::from_static("nosniff"))).layer(SetResponseHeaderLayer::if_not_present(header::REFERRER_POLICY,header::HeaderValue::from_static("no-referrer"))).layer(SetResponseHeaderLayer::if_not_present(header::CONTENT_SECURITY_POLICY,header::HeaderValue::from_static("default-src 'self'; connect-src 'self' ws: wss:; style-src 'self'; script-src 'self'; frame-ancestors 'none'"))).layer(CatchPanicLayer::new()).layer(TraceLayer::new_for_http()) +} + +async fn status() -> impl IntoResponse { + Json(serde_json::json!({"status":"ok"})) +} +#[derive(Deserialize)] +struct PageQuery { + before: Option, + limit: Option, +} +async fn recent( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Query(q): Query, +) -> Response { + logs_response(state, peer, headers, None, q.limit).await +} +async fn history( + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, + Query(q): Query, +) -> Response { + logs_response(state, peer, headers, q.before.as_deref(), q.limit).await +} +async fn logs_response( + state: AppState, + peer: SocketAddr, + headers: HeaderMap, + before: Option<&str>, + limit: Option, +) -> Response { + if let Err(r) = authorize(&state, peer, &headers).await { + return r; + } + let limit = limit + .unwrap_or(state.config.initial_log_lines) + .min(state.config.max_history_lines) + .max(1); + match state.history.page(before, limit).await { + Ok(page) => Json(page).into_response(), + Err(code) if code == "invalid_cursor" => error(StatusCode::BAD_REQUEST, "invalid_cursor"), + Err(code) if code == "stale_cursor" => error(StatusCode::CONFLICT, "stale_cursor"), + Err(_) => error(StatusCode::UNPROCESSABLE_ENTITY, "history_unavailable"), + } +} + +async fn websocket( + ws: WebSocketUpgrade, + State(state): State, + ConnectInfo(peer): ConnectInfo, + headers: HeaderMap, +) -> Response { + if let Err(r) = authorize(&state, peer, &headers).await { + return r; + } + if state.ws_count.load(Ordering::Relaxed) >= state.config.max_ws_connections { + return error(StatusCode::SERVICE_UNAVAILABLE, "connection_limit"); + } + state.ws_count.fetch_add(1, Ordering::Relaxed); + ws.on_upgrade(move |socket| serve_socket(socket, state)) + .into_response() +} +async fn serve_socket(mut socket: WebSocket, state: AppState) { + let _guard = WsGuard(state.ws_count.clone()); + let mut rx = state.live_tx.subscribe(); + if let Ok(hello) = serde_json::to_string(&LiveMessage::Hello) { + if socket.send(Message::Text(hello.into())).await.is_err() { + return; + } + } + loop { + match rx.recv().await { + Ok(event) => { + if let Ok(json) = serde_json::to_string(&event) { + if socket.send(Message::Text(json.into())).await.is_err() { + break; + } + } + } + Err(broadcast::error::RecvError::Lagged(_)) => { + let _ = socket + .send(Message::Text(r#"{"type":"resync_required"}"#.into())) + .await; + break; + } + Err(_) => break, + } + } +} +struct WsGuard(Arc); +impl Drop for WsGuard { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::Relaxed); + } +} + +async fn authorize( + state: &AppState, + peer: SocketAddr, + headers: &HeaderMap, +) -> Result { + validate_origin(&state.config.public_origin, headers) + .map_err(|_| error(StatusCode::FORBIDDEN, "origin_denied"))?; + let ip = state + .ip_policy + .extract(peer.ip(), headers) + .map_err(|_| error(StatusCode::BAD_REQUEST, "invalid_client_ip"))?; + if state.auth.is_allowed(ip).await { + Ok(ip) + } else { + Err(error(StatusCode::FORBIDDEN, "access_denied")) + } +} +fn validate_origin(expected: &str, headers: &HeaderMap) -> Result<(), ()> { + if let Some(origin) = headers.get(header::ORIGIN) { + if origin.to_str().ok() != Some(expected) { + return Err(()); + } + } + Ok(()) +} +fn error(status: StatusCode, code: &str) -> Response { + (status, Json(serde_json::json!({"error":code}))).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rejects_cross_origin() { + let mut h = HeaderMap::new(); + h.insert(header::ORIGIN, "https://evil.example".parse().unwrap()); + assert!(validate_origin("https://logs.example", &h).is_err()); + } + #[test] + fn accepts_matching_or_non_browser_origin() { + let mut h = HeaderMap::new(); + assert!(validate_origin("https://logs.example", &h).is_ok()); + h.insert(header::ORIGIN, "https://logs.example".parse().unwrap()); + assert!(validate_origin("https://logs.example", &h).is_ok()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..e12db5c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,5 @@ +pub mod auth; +pub mod config; +pub mod frontend; +pub mod http; +pub mod logs; diff --git a/src/logs/archive.rs b/src/logs/archive.rs new file mode 100644 index 0000000..9eb60de --- /dev/null +++ b/src/logs/archive.rs @@ -0,0 +1,122 @@ +use flate2::read::GzDecoder; +use std::{ + fs::File, + io::{self, BufRead, BufReader, Read}, + path::Path, +}; +use tar::Archive; +use thiserror::Error; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArchiveKind { + Plain, + Gzip, + TarGzip, +} +#[derive(Debug, Error)] +pub enum ArchiveError { + #[error("archive I/O failed: {0}")] + Io(#[from] io::Error), + #[error("archive decompressed size exceeds configured limit")] + TooLarge, + #[error("unsupported log archive type")] + Unsupported, +} + +pub fn detect(path: &Path) -> Option { + let name = path.file_name()?.to_string_lossy().to_ascii_lowercase(); + if name.ends_with(".tar.gz") || name.ends_with(".tgz") { + Some(ArchiveKind::TarGzip) + } else if name.ends_with(".log.gz") || name.ends_with(".gz") { + Some(ArchiveKind::Gzip) + } else if name.ends_with(".log") { + Some(ArchiveKind::Plain) + } else { + None + } +} + +pub fn read_lines(path: &Path, max_bytes: usize) -> Result, ArchiveError> { + match detect(path).ok_or(ArchiveError::Unsupported)? { + ArchiveKind::Plain => bounded_lines(File::open(path)?, max_bytes), + ArchiveKind::Gzip => bounded_lines(GzDecoder::new(File::open(path)?), max_bytes), + ArchiveKind::TarGzip => { + let mut result = Vec::new(); + let mut used = 0usize; + let gz = GzDecoder::new(File::open(path)?); + let mut tar = Archive::new(gz); + for entry in tar.entries()? { + let entry = entry?; + if entry.header().entry_type().is_file() + && entry.path()?.to_string_lossy().ends_with(".log") + { + for line in bounded_lines(entry, max_bytes.saturating_sub(used))? { + used = used.saturating_add(line.len() + 1); + if used > max_bytes { + return Err(ArchiveError::TooLarge); + } + result.push(line); + } + } + } + Ok(result) + } + } +} +fn bounded_lines(reader: impl Read, max_bytes: usize) -> Result, ArchiveError> { + let mut out = Vec::new(); + let mut used = 0usize; + for line in BufReader::new(reader).split(b'\n') { + let bytes = line?; + used = used.saturating_add(bytes.len() + 1); + if used > max_bytes { + return Err(ArchiveError::TooLarge); + } + out.push( + String::from_utf8_lossy(&bytes) + .trim_end_matches('\r') + .to_owned(), + ); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::{write::GzEncoder, Compression}; + use std::io::Write; + #[test] + fn detects_supported_extensions() { + assert_eq!(detect(Path::new("2026.log")), Some(ArchiveKind::Plain)); + assert_eq!(detect(Path::new("a.tar.gz")), Some(ArchiveKind::TarGzip)); + assert_eq!(detect(Path::new("x.zip")), None); + } + #[test] + fn gzip_is_lazy_and_bounded() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("x.log.gz"); + let mut g = GzEncoder::new(File::create(&p).unwrap(), Compression::default()); + g.write_all(b"one\ntwo\n").unwrap(); + g.finish().unwrap(); + assert_eq!(read_lines(&p, 100).unwrap()[0], "one"); + assert!(matches!(read_lines(&p, 2), Err(ArchiveError::TooLarge))); + } + #[test] + fn tar_gzip_reads_log_members_without_extracting() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("bundle.tar.gz"); + let gzip = GzEncoder::new(File::create(&p).unwrap(), Compression::default()); + let mut archive = tar::Builder::new(gzip); + let content = b"inside\n"; + let mut header = tar::Header::new_gnu(); + header.set_size(content.len() as u64); + header.set_mode(0o444); + header.set_cksum(); + archive + .append_data(&mut header, "nested/server.log", &content[..]) + .unwrap(); + archive.into_inner().unwrap().finish().unwrap(); + assert_eq!(read_lines(&p, 100).unwrap(), vec!["inside"]); + } +} diff --git a/src/logs/cursor.rs b/src/logs/cursor.rs new file mode 100644 index 0000000..1511e37 --- /dev/null +++ b/src/logs/cursor.rs @@ -0,0 +1,46 @@ +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Cursor { + pub source: usize, + pub line: usize, + pub generation: u64, +} + +#[derive(Debug, Error)] +pub enum CursorError { + #[error("invalid cursor encoding")] + Encoding, + #[error("invalid cursor payload")] + Payload, +} + +impl Cursor { + pub fn encode(&self) -> String { + URL_SAFE_NO_PAD + .encode(serde_json::to_vec(self).expect("cursor serialization is infallible")) + } + pub fn decode(value: &str) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(value) + .map_err(|_| CursorError::Encoding)?; + serde_json::from_slice(&bytes).map_err(|_| CursorError::Payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn round_trip_and_rejects_garbage() { + let c = Cursor { + source: 2, + line: 9, + generation: 3, + }; + assert_eq!(Cursor::decode(&c.encode()).unwrap(), c); + assert!(Cursor::decode("../secret").is_err()); + } +} diff --git a/src/logs/follower.rs b/src/logs/follower.rs new file mode 100644 index 0000000..ae7c064 --- /dev/null +++ b/src/logs/follower.rs @@ -0,0 +1,366 @@ +use crate::{ + auth::AuthorizationService, + logs::{index::HistoryStore, stable_line_id, timestamp_from_line, LogLine}, +}; +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher}; +use serde::Serialize; +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::PathBuf, +}; +use tokio::sync::broadcast; + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LiveMessage { + Hello, + LogLines { lines: Vec }, + Rotation, + HistoryChanged, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FileIdentity { + device: u64, + inode: u64, +} +#[cfg(unix)] +fn identity(meta: &std::fs::Metadata) -> FileIdentity { + use std::os::unix::fs::MetadataExt; + FileIdentity { + device: meta.dev(), + inode: meta.ino(), + } +} + +pub struct FileFollower { + path: PathBuf, + file: Option, + identity: Option, + offset: u64, + partial: Vec, + next_line: usize, + redact: bool, +} +#[derive(Debug, Default)] +pub struct FollowResult { + pub lines: Vec, + pub rotated: bool, + pub truncated: bool, +} + +impl FileFollower { + pub fn at_eof(path: PathBuf, redact: bool) -> std::io::Result { + let mut s = Self { + path, + file: None, + identity: None, + offset: 0, + partial: Vec::new(), + next_line: 0, + redact, + }; + s.open(true)?; + Ok(s) + } + pub fn from_start(path: PathBuf, redact: bool) -> std::io::Result { + let mut s = Self { + path, + file: None, + identity: None, + offset: 0, + partial: Vec::new(), + next_line: 0, + redact, + }; + s.open(false)?; + Ok(s) + } + fn open(&mut self, eof: bool) -> std::io::Result<()> { + let mut file = File::open(&self.path)?; + let meta = file.metadata()?; + self.identity = Some(identity(&meta)); + self.partial.clear(); + self.next_line = 0; + if eof { + let mut chunk = [0_u8; 8192]; + loop { + let read = file.read(&mut chunk)?; + if read == 0 { + break; + } + for byte in &chunk[..read] { + if *byte == b'\n' { + self.next_line += 1; + self.partial.clear(); + } else { + self.partial.push(*byte); + } + } + } + self.offset = meta.len(); + } else { + self.offset = 0; + } + self.file = Some(file); + Ok(()) + } + pub fn read_new(&mut self) -> std::io::Result { + let mut result = FollowResult::default(); + let current = match std::fs::metadata(&self.path) { + Ok(m) => m, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(result), + Err(e) => return Err(e), + }; + let current_id = identity(¤t); + if self.identity != Some(current_id) { + if let Some(old) = self.file.as_mut() { + let mut tail = Vec::new(); + old.seek(SeekFrom::Start(self.offset))?; + old.read_to_end(&mut tail)?; + result.lines.extend(decode_lines( + &mut self.partial, + &tail, + self.identity.expect("open file identity"), + &mut self.next_line, + self.redact, + )); + } + self.open(false)?; + result.rotated = true; + } else if current.len() < self.offset { + self.offset = 0; + self.partial.clear(); + self.next_line = 0; + result.truncated = true; + } + let file = self.file.as_mut().expect("opened follower"); + file.seek(SeekFrom::Start(self.offset))?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes)?; + self.offset += bytes.len() as u64; + result.lines.extend(decode_lines( + &mut self.partial, + &bytes, + self.identity.expect("open file identity"), + &mut self.next_line, + self.redact, + )); + Ok(result) + } +} +fn decode_lines( + partial: &mut Vec, + bytes: &[u8], + identity: FileIdentity, + next_line: &mut usize, + redact: bool, +) -> Vec { + partial.extend_from_slice(bytes); + let mut completed = Vec::new(); + let mut consumed = 0; + for (i, b) in partial.iter().enumerate() { + if *b == b'\n' { + let raw = String::from_utf8_lossy(&partial[consumed..i]) + .trim_end_matches('\r') + .to_owned(); + let id = stable_line_id(identity.device, identity.inode, *next_line, &raw); + let text = if redact { + crate::logs::redact::redact_player_ips(&raw) + } else { + raw + }; + completed.push(LogLine { + id, + timestamp: timestamp_from_line(&text), + text, + source: "latest.log".into(), + }); + *next_line += 1; + consumed = i + 1; + } + } + partial.drain(..consumed); + completed +} + +pub fn spawn_watcher( + log_dir: PathBuf, + latest: PathBuf, + whitelist_path: PathBuf, + auth: AuthorizationService, + history: HistoryStore, + tx: broadcast::Sender, + redact: bool, +) -> notify::Result { + // Open first, then register the directory watch, then recover bytes written + // between those two operations. Events queued after registration are harmless: + // the follower's byte offset makes the later read a no-op. + let mut follower = FileFollower::at_eof(latest.clone(), false).map_err(notify::Error::io)?; + let (event_tx, mut event_rx) = tokio::sync::mpsc::unbounded_channel::>(); + let mut watcher = notify::recommended_watcher(move |event| { + let _ = event_tx.send(event); + })?; + watcher.watch(&log_dir, RecursiveMode::NonRecursive)?; + if whitelist_path.parent() != Some(log_dir.as_path()) { + if let Some(parent) = whitelist_path.parent() { + watcher.watch(parent, RecursiveMode::NonRecursive)?; + } + } + let startup_delta = follower.read_new(); + tokio::spawn(async move { + if let Ok(result) = startup_delta { + if !result.lines.is_empty() { + let mut lines = result.lines; + for line in &lines { + auth.observe_line(&line.text).await; + } + if redact { + for line in &mut lines { + line.text = crate::logs::redact::redact_player_ips(&line.text); + } + } + let _ = tx.send(LiveMessage::LogLines { lines }); + } + } + while let Some(event) = event_rx.recv().await { + match event { + Ok(event) => { + let touches_whitelist = event.paths.iter().any(|p| p == &whitelist_path); + let touches_logs = event + .paths + .iter() + .any(|p| p == &latest || p.parent() == Some(log_dir.as_path())); + if touches_whitelist { + match crate::auth::whitelist::Whitelist::load(&whitelist_path) { + Ok(w) => { + auth.replace_whitelist(w).await; + tracing::info!(event = "whitelist_changed") + } + Err(e) => tracing::error!(event="whitelist_reload_failed",error=%e), + } + } + if touches_logs { + match follower.read_new() { + Ok(result) => { + if result.rotated || result.truncated { + auth.clear_for_rotation().await; + let _ = auth.rebuild().await; + let _ = history.refresh(&log_dir).await; + let _ = tx.send(LiveMessage::Rotation); + } + if !result.lines.is_empty() { + let mut lines = result.lines; + for line in &lines { + auth.observe_line(&line.text).await; + } + if redact { + for line in &mut lines { + line.text = + crate::logs::redact::redact_player_ips(&line.text); + } + } + let _ = tx.send(LiveMessage::LogLines { lines }); + } else { + let _ = history.refresh(&log_dir).await; + let _ = tx.send(LiveMessage::HistoryChanged); + } + } + Err(e) => tracing::warn!(event="watcher_recovery",error=%e), + } + } + } + Err(e) => { + tracing::warn!(event="watcher_overflow_recovery",error=%e); + let _ = auth.rebuild().await; + let _ = history.refresh(&log_dir).await; + let _ = follower.read_new(); + } + } + } + }); + Ok(watcher) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + #[test] + fn buffers_partial_and_handles_multiple_lines() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("latest.log"); + std::fs::write(&p, b"").unwrap(); + let mut f = FileFollower::from_start(p.clone(), false).unwrap(); + std::fs::write(&p, b"one").unwrap(); + assert!(f.read_new().unwrap().lines.is_empty()); + std::fs::OpenOptions::new() + .append(true) + .open(&p) + .unwrap() + .write_all(b"\ntwo\n") + .unwrap(); + let r = f.read_new().unwrap(); + assert_eq!( + r.lines.iter().map(|l| l.text.as_str()).collect::>(), + vec!["one", "two"] + ); + } + #[test] + fn detects_truncation() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("latest.log"); + std::fs::write(&p, b"old\n").unwrap(); + let mut f = FileFollower::from_start(p.clone(), false).unwrap(); + assert_eq!(f.read_new().unwrap().lines.len(), 1); + std::fs::write(&p, b"n\n").unwrap(); + let r = f.read_new().unwrap(); + assert!(r.truncated); + assert_eq!(r.lines[0].text, "n"); + } + #[test] + fn detects_file_replacement_and_reads_the_new_generation() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("latest.log"); + std::fs::write(&p, b"old\n").unwrap(); + let mut follower = FileFollower::at_eof(p.clone(), false).unwrap(); + std::fs::rename(&p, d.path().join("previous.log")).unwrap(); + std::fs::write(&p, b"new\n").unwrap(); + let result = follower.read_new().unwrap(); + assert!(result.rotated); + assert_eq!(result.lines.last().unwrap().text, "new"); + } + #[test] + fn eof_startup_retains_an_existing_partial_line() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("latest.log"); + std::fs::write(&p, b"complete\npartial").unwrap(); + let mut follower = FileFollower::at_eof(p.clone(), false).unwrap(); + std::fs::OpenOptions::new() + .append(true) + .open(&p) + .unwrap() + .write_all(b" rest\n") + .unwrap(); + let result = follower.read_new().unwrap(); + assert_eq!(result.lines[0].text, "partial rest"); + } + #[tokio::test] + async fn live_and_recent_snapshots_share_line_ids() { + let d = tempfile::tempdir().unwrap(); + let p = d.path().join("latest.log"); + std::fs::write(&p, b"").unwrap(); + let mut follower = FileFollower::from_start(p.clone(), false).unwrap(); + std::fs::write(&p, b"same line\n").unwrap(); + let live = follower.read_new().unwrap().lines.remove(0); + let index = crate::logs::index::HistoryIndex::scan(d.path()).unwrap(); + let history = HistoryStore::new(index, 0, 0, 1024, false) + .page(None, 1) + .await + .unwrap() + .lines + .remove(0); + assert_eq!(live.id, history.id); + } +} diff --git a/src/logs/index.rs b/src/logs/index.rs new file mode 100644 index 0000000..70af7a7 --- /dev/null +++ b/src/logs/index.rs @@ -0,0 +1,281 @@ +use crate::logs::{archive, cursor::Cursor, stable_line_id, timestamp_from_line, LogLine}; +use serde::Serialize; +use std::{ + collections::{HashMap, VecDeque}, + fs, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::UNIX_EPOCH, +}; + +#[derive(Debug, Clone)] +pub struct Source { + pub path: PathBuf, + pub label: String, + pub modified_ms: u128, + pub compressed_size: u64, + pub device: u64, + pub inode: u64, +} +#[derive(Debug, Clone)] +pub struct HistoryIndex { + pub sources: Vec, + pub generation: u64, +} +#[derive(Debug, Serialize)] +pub struct HistoryPage { + pub lines: Vec, + pub next_before: Option, + pub has_more: bool, +} + +impl HistoryIndex { + pub fn scan(dir: &Path) -> std::io::Result { + let mut sources = Vec::new(); + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if !entry.file_type()?.is_file() || archive::detect(&path).is_none() { + continue; + } + let meta = entry.metadata()?; + #[cfg(unix)] + let (device, inode) = { + use std::os::unix::fs::MetadataExt; + (meta.dev(), meta.ino()) + }; + sources.push(Source { + label: entry.file_name().to_string_lossy().into_owned(), + path, + modified_ms: meta + .modified() + .ok() + .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) + .map(|d| d.as_millis()) + .unwrap_or(0), + compressed_size: meta.len(), + device, + inode, + }); + } + sources.sort_by(|a, b| { + (a.label == "latest.log") + .cmp(&(b.label == "latest.log")) + .then(filename_key(&a.label).cmp(&filename_key(&b.label))) + .then(a.modified_ms.cmp(&b.modified_ms)) + .then(a.label.cmp(&b.label)) + }); + let generation = sources.iter().fold(0u64, |acc, s| { + acc.wrapping_mul(31) + .wrapping_add(s.modified_ms as u64) + .wrapping_add(s.compressed_size) + }); + Ok(Self { + sources, + generation, + }) + } +} +fn filename_key(name: &str) -> String { + name.chars().filter(|c| c.is_ascii_digit()).collect() +} + +#[derive(Clone)] +pub struct HistoryStore { + index: Arc>, + cache: Arc>, + max_archive_bytes: usize, + redact: bool, +} +struct ArchiveCache { + max_bytes: usize, + max_files: usize, + bytes: usize, + items: HashMap>>, + order: VecDeque, +} +impl ArchiveCache { + fn new(max_bytes: usize, max_files: usize) -> Self { + Self { + max_bytes, + max_files, + bytes: 0, + items: HashMap::new(), + order: VecDeque::new(), + } + } + fn get(&mut self, p: &Path) -> Option>> { + self.items.get(p).cloned() + } + fn put(&mut self, p: PathBuf, v: Arc>) { + if self.max_files == 0 || self.max_bytes == 0 { + return; + } + let size: usize = v.iter().map(|x| x.len()).sum(); + if size > self.max_bytes { + return; + } + while self.items.len() >= self.max_files || self.bytes + size > self.max_bytes { + if let Some(old) = self.order.pop_front() { + if let Some(lines) = self.items.remove(&old) { + self.bytes = self + .bytes + .saturating_sub(lines.iter().map(|x| x.len()).sum::()); + } + } else { + break; + } + } + self.bytes += size; + self.order.push_back(p.clone()); + self.items.insert(p, v); + } +} + +impl HistoryStore { + pub fn new( + index: HistoryIndex, + cache_bytes: usize, + cache_files: usize, + max_archive_bytes: usize, + redact: bool, + ) -> Self { + Self { + index: Arc::new(tokio::sync::RwLock::new(index)), + cache: Arc::new(Mutex::new(ArchiveCache::new(cache_bytes, cache_files))), + max_archive_bytes, + redact, + } + } + pub async fn refresh(&self, dir: &Path) -> std::io::Result<()> { + *self.index.write().await = HistoryIndex::scan(dir)?; + Ok(()) + } + pub async fn page(&self, before: Option<&str>, limit: usize) -> Result { + let index = self.index.read().await.clone(); + let mut pos = match before { + Some(v) => { + let c = Cursor::decode(v).map_err(|_| "invalid_cursor")?; + if c.generation != index.generation { + return Err("stale_cursor".into()); + } + (c.source, c.line) + } + None => (index.sources.len(), usize::MAX), + }; + let mut out_rev = Vec::new(); + while pos.0 > 0 && out_rev.len() < limit { + let source_idx = pos.0 - 1; + let source = &index.sources[source_idx]; + let owned_source = source.clone(); + let store = self.clone(); + let lines = tokio::task::spawn_blocking(move || store.load(&owned_source)) + .await + .map_err(|_| "archive_unavailable")? + .map_err(|_| "archive_unavailable")?; + let end = if pos.0 == source_idx + 1 && pos.1 != usize::MAX { + pos.1.min(lines.len()) + } else { + lines.len() + }; + for line_idx in (0..end).rev() { + if out_rev.len() == limit { + pos = (source_idx + 1, line_idx + 1); + break; + } + out_rev.push(make_line(source, line_idx, &lines[line_idx], self.redact)); + pos = (source_idx + 1, line_idx); + } + if pos.1 == 0 { + pos = (source_idx, usize::MAX) + } + } + out_rev.reverse(); + let has_more = pos.0 > 0; + let next_before = has_more.then(|| { + Cursor { + source: pos.0, + line: pos.1, + generation: index.generation, + } + .encode() + }); + Ok(HistoryPage { + lines: out_rev, + next_before, + has_more, + }) + } + fn load(&self, source: &Source) -> Result>, archive::ArchiveError> { + if let Some(v) = self.cache.lock().unwrap().get(&source.path) { + return Ok(v); + } + let v = Arc::new(archive::read_lines(&source.path, self.max_archive_bytes)?); + self.cache + .lock() + .unwrap() + .put(source.path.clone(), v.clone()); + Ok(v) + } +} +fn make_line(source: &Source, index: usize, text: &str, redact: bool) -> LogLine { + let id = stable_line_id(source.device, source.inode, index, text); + let text = if redact { + crate::logs::redact::redact_player_ips(text) + } else { + text.to_owned() + }; + LogLine { + id, + timestamp: timestamp_from_line(&text), + text, + source: source.label.clone(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn deterministic_filename_order() { + let d = tempfile::tempdir().unwrap(); + fs::write(d.path().join("2026-01-02.log"), "b\n").unwrap(); + fs::write(d.path().join("2026-01-01.log"), "a\n").unwrap(); + let i = HistoryIndex::scan(d.path()).unwrap(); + assert_eq!(i.sources[0].label, "2026-01-01.log"); + } + #[test] + fn latest_is_always_newest() { + let d = tempfile::tempdir().unwrap(); + fs::write(d.path().join("latest.log"), "new\n").unwrap(); + fs::write(d.path().join("2026-01-01.log"), "old\n").unwrap(); + let i = HistoryIndex::scan(d.path()).unwrap(); + assert_eq!(i.sources.last().unwrap().label, "latest.log"); + } + #[tokio::test] + async fn paginates_without_exposing_paths() { + let d = tempfile::tempdir().unwrap(); + fs::write(d.path().join("latest.log"), "one\ntwo\nthree\n").unwrap(); + let s = HistoryStore::new(HistoryIndex::scan(d.path()).unwrap(), 1024, 2, 1024, true); + let p = s.page(None, 2).await.unwrap(); + assert_eq!(p.lines.len(), 2); + assert!(p.has_more); + assert!(!p.next_before.unwrap().contains("latest")); + } + #[tokio::test] + async fn refresh_discovers_new_archives() { + let d = tempfile::tempdir().unwrap(); + fs::write(d.path().join("latest.log"), "new\n").unwrap(); + let store = HistoryStore::new(HistoryIndex::scan(d.path()).unwrap(), 0, 0, 1024, false); + fs::write(d.path().join("2026-01-01.log"), "old\n").unwrap(); + store.refresh(d.path()).await.unwrap(); + let page = store.page(None, 10).await.unwrap(); + assert_eq!( + page.lines + .iter() + .map(|line| line.text.as_str()) + .collect::>(), + vec!["old", "new"] + ); + } +} diff --git a/src/logs/mod.rs b/src/logs/mod.rs new file mode 100644 index 0000000..32336d0 --- /dev/null +++ b/src/logs/mod.rs @@ -0,0 +1,32 @@ +pub mod archive; +pub mod cursor; +pub mod follower; +pub mod index; +pub mod redact; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct LogLine { + pub id: String, + pub timestamp: Option, + pub text: String, + pub source: String, +} + +pub fn timestamp_from_line(line: &str) -> Option { + let start = line.find('[')? + 1; + let end = line[start..].find(']')? + start; + let value = &line[start..end]; + (value.len() >= 8).then(|| value.to_owned()) +} + +pub fn stable_line_id(device: u64, inode: u64, line_index: usize, text: &str) -> String { + let mut hash = Sha256::new(); + hash.update(device.to_le_bytes()); + hash.update(inode.to_le_bytes()); + hash.update(line_index.to_le_bytes()); + hash.update(text.as_bytes()); + format!("{:x}", hash.finalize()) +} diff --git a/src/logs/redact.rs b/src/logs/redact.rs new file mode 100644 index 0000000..231e105 --- /dev/null +++ b/src/logs/redact.rs @@ -0,0 +1,21 @@ +use once_cell::sync::Lazy; +use regex::Regex; +static PLAYER_ADDRESS: Lazy = + Lazy::new(|| Regex::new(r"/\[?(?:[0-9a-fA-F:.]+)\]?:\d+").unwrap()); +pub fn redact_player_ips(line: &str) -> String { + PLAYER_ADDRESS + .replace_all(line, "/[IP REDACTED]") + .into_owned() +} +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn redacts_ipv4_and_ipv6() { + assert_eq!( + redact_player_ips("Alex[/203.0.113.1:123] logged in"), + "Alex[/[IP REDACTED]] logged in" + ); + assert!(!redact_player_ips("Alex[/[2001:db8::1]:123] logged in").contains("2001:db8")); + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a4e8db8 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,73 @@ +use anyhow::Context; +use minecraft_log_viewer::{ + auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService}, + config::Config, + http::{self, AppState}, + logs::{ + follower, + index::{HistoryIndex, HistoryStore}, + }, +}; +use std::sync::{atomic::AtomicUsize, Arc}; +use tokio::sync::broadcast; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .json() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + let config = Arc::new(Config::from_env().context("configuration validation failed")?); + config + .validate_paths() + .context("filesystem validation failed")?; + let whitelist = Whitelist::load(&config.whitelist) + .context("initial whitelist load failed; refusing to start")?; + let auth = AuthorizationService::new(config.latest_log.clone(), whitelist); + auth.rebuild() + .await + .context("authorization reconstruction failed")?; + let index = HistoryIndex::scan(&config.log_dir).context("historical index scan failed")?; + let history = HistoryStore::new( + index, + config.archive_cache_max_bytes, + config.archive_cache_max_files, + config.max_archive_bytes, + config.redact_player_ips, + ); + let (tx, _) = broadcast::channel(config.ws_queue_capacity); + let ip_policy = ClientIpPolicy { + trust_proxy: config.trust_proxy, + trusted_proxies: config.trusted_proxy_cidrs.clone(), + header: config.client_ip_header.clone(), + }; + let state = AppState { + config: config.clone(), + auth: auth.clone(), + history: history.clone(), + live_tx: tx.clone(), + ip_policy, + ws_count: Arc::new(AtomicUsize::new(0)), + }; + let _watcher = follower::spawn_watcher( + config.log_dir.clone(), + config.latest_log.clone(), + config.whitelist.clone(), + auth, + history, + tx, + config.redact_player_ips, + ) + .context("filesystem watcher startup failed")?; + let listener = tokio::net::TcpListener::bind(config.listen_addr) + .await + .context("HTTP bind failed")?; + tracing::info!(event="application_startup",listen_addr=%config.listen_addr); + axum::serve( + listener, + http::router(state).into_make_service_with_connect_info::(), + ) + .await + .context("HTTP server failed")?; + Ok(()) +} diff --git a/tests/deployment_contract.rs b/tests/deployment_contract.rs new file mode 100644 index 0000000..3d5a93c --- /dev/null +++ b/tests/deployment_contract.rs @@ -0,0 +1,21 @@ +#[test] +fn compose_is_a_hardened_read_only_sidecar() { + let compose = include_str!("../compose.example.yml"); + assert!(compose.contains("minecraft-data:/data:ro")); + assert!(compose.contains("read_only: true")); + assert!(compose.contains("cap_drop: [ALL]")); + assert!(compose.contains("no-new-privileges:true")); + assert!(!compose.contains("docker.sock")); + assert!(!compose.contains("ports:")); +} +#[test] +fn runtime_has_no_node_and_runs_non_root() { + let dockerfile = include_str!("../Dockerfile"); + let runtime = dockerfile + .split("FROM debian:bookworm-slim AS runtime") + .nth(1) + .unwrap(); + assert!(!runtime.contains("npm ")); + assert!(!runtime.contains("node ")); + assert!(runtime.contains("USER 10001:10001")); +} diff --git a/tests/http_security.rs b/tests/http_security.rs new file mode 100644 index 0000000..ea7abba --- /dev/null +++ b/tests/http_security.rs @@ -0,0 +1,171 @@ +use axum::{ + extract::ConnectInfo, + http::{header, Request, StatusCode}, +}; +use futures_util::StreamExt; +use ipnet::IpNet; +use minecraft_log_viewer::{ + auth::{client_ip::ClientIpPolicy, whitelist::Whitelist, AuthorizationService}, + config::Config, + http::{router, AppState}, + logs::index::{HistoryIndex, HistoryStore}, +}; +use std::{ + net::SocketAddr, + sync::{atomic::AtomicUsize, Arc}, +}; +use tokio::sync::broadcast; +use tower::ServiceExt; + +async fn app() -> ( + axum::Router, + tempfile::TempDir, + broadcast::Sender, +) { + let dir = tempfile::tempdir().unwrap(); + let logs = dir.path().join("logs"); + std::fs::create_dir(&logs).unwrap(); + let latest = logs.join("latest.log"); + std::fs::write(&latest, "[12:00:00] Alex[/203.0.113.4:51234] logged in\n").unwrap(); + let whitelist_path = dir.path().join("whitelist.json"); + let whitelist_json = br#"[{"uuid":"123e4567-e89b-12d3-a456-426614174000","name":"Alex"}]"#; + std::fs::write(&whitelist_path, whitelist_json).unwrap(); + let config = Arc::new(Config { + listen_addr: "127.0.0.1:0".parse().unwrap(), + data_dir: dir.path().to_path_buf(), + log_dir: logs.clone(), + latest_log: latest.clone(), + whitelist: whitelist_path, + trust_proxy: true, + trusted_proxy_cidrs: vec![ + "10.0.0.0/8".parse().unwrap(), + "127.0.0.1/32".parse().unwrap(), + ], + client_ip_header: "x-forwarded-for".into(), + public_origin: "https://logs.example".into(), + initial_log_lines: 100, + max_history_lines: 100, + archive_cache_max_bytes: 1024, + archive_cache_max_files: 2, + max_archive_bytes: 4096, + ws_queue_capacity: 8, + max_ws_connections: 2, + redact_player_ips: true, + }); + let auth = AuthorizationService::new(latest, Whitelist::parse(whitelist_json).unwrap()); + auth.rebuild().await.unwrap(); + let history = HistoryStore::new(HistoryIndex::scan(&logs).unwrap(), 1024, 2, 4096, true); + let (live_tx, _) = broadcast::channel(8); + let state = AppState { + config, + auth, + history, + live_tx: live_tx.clone(), + ip_policy: ClientIpPolicy { + trust_proxy: true, + trusted_proxies: vec![ + "10.0.0.0/8".parse::().unwrap(), + "127.0.0.1/32".parse().unwrap(), + ], + header: "x-forwarded-for".into(), + }, + ws_count: Arc::new(AtomicUsize::new(0)), + }; + (router(state), dir, live_tx) +} + +fn request(peer: &str) -> Request { + let mut request = Request::builder() + .uri("/api/logs/recent") + .body(axum::body::Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(ConnectInfo(peer.parse::().unwrap())); + request +} + +#[tokio::test] +async fn authorized_direct_ip_receives_redacted_logs() { + let (app, _dir, _tx) = app().await; + let response = app.oneshot(request("203.0.113.4:5000")).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), 8192) + .await + .unwrap(); + let text = String::from_utf8(body.to_vec()).unwrap(); + assert!(text.contains("IP REDACTED")); + assert!(!text.contains("203.0.113.4")); +} + +#[tokio::test] +async fn untrusted_peer_cannot_spoof_an_authorized_forwarded_ip() { + let (app, _dir, _tx) = app().await; + let mut request = request("192.0.2.9:5000"); + request + .headers_mut() + .insert("x-forwarded-for", "203.0.113.4".parse().unwrap()); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn unexpected_browser_origin_is_rejected() { + let (app, _dir, _tx) = app().await; + let mut request = request("203.0.113.4:5000"); + request + .headers_mut() + .insert(header::ORIGIN, "https://evil.example".parse().unwrap()); + let response = app.oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn authorized_websocket_receives_ordered_batches() { + use minecraft_log_viewer::logs::{follower::LiveMessage, LogLine}; + use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + let (app, _dir, tx) = app().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + axum::serve( + listener, + app.into_make_service_with_connect_info::(), + ) + .await + .unwrap(); + }); + let mut request = format!("ws://{address}/api/live") + .into_client_request() + .unwrap(); + request + .headers_mut() + .insert(header::ORIGIN, "https://logs.example".parse().unwrap()); + request + .headers_mut() + .insert("x-forwarded-for", "203.0.113.4".parse().unwrap()); + let (mut socket, _) = tokio_tungstenite::connect_async(request).await.unwrap(); + let hello = socket.next().await.unwrap().unwrap().into_text().unwrap(); + assert!(hello.contains("hello")); + tx.send(LiveMessage::LogLines { + lines: vec![ + LogLine { + id: "1".into(), + timestamp: None, + text: "first".into(), + source: "latest.log".into(), + }, + LogLine { + id: "2".into(), + timestamp: None, + text: "second".into(), + source: "latest.log".into(), + }, + ], + }) + .unwrap(); + let batch = socket.next().await.unwrap().unwrap().into_text().unwrap(); + assert!(batch.find("first").unwrap() < batch.find("second").unwrap()); + server.abort(); +} diff --git a/tests/parser_fixtures.rs b/tests/parser_fixtures.rs new file mode 100644 index 0000000..57f7ca4 --- /dev/null +++ b/tests/parser_fixtures.rs @@ -0,0 +1,12 @@ +use minecraft_log_viewer::auth::join_tracker::JoinTracker; +#[test] +fn realistic_fixture_authorizes_only_successful_ipv4_and_ipv6_joins() { + let mut tracker = JoinTracker::default(); + let records = include_str!("../fixtures/join_sequences.log") + .lines() + .filter_map(|line| tracker.push(line)) + .collect::>(); + assert_eq!(records.len(), 2); + assert_eq!(records[0].name, "Alex"); + assert_eq!(records[1].name, "Steve"); +}