commit bba448e3294f00c27db481fec797438b690457c1 Author: Wind-Explorer Date: Mon Aug 10 21:55:52 2026 +0800 init 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"); +}