diff --git a/.dockerignore b/.dockerignore index a96e552..401401f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,2 +1,9 @@ .git/ target/ +frontend/node_modules +.idea/ +**/target/ +**/.env +frontend/.svelte-kit/ +frontend/dist/ +frontend/build/ diff --git a/Cargo.lock b/Cargo.lock index 5c84b7e..bd235e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -742,15 +742,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" -[[package]] -name = "fid-rs" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6956a1e60e2d1412b44b4169d44a03dae518f8583d3e10090c912c105e48447" -dependencies = [ - "rayon", -] - [[package]] name = "figment" version = "0.10.19" @@ -760,7 +751,7 @@ dependencies = [ "atomic 0.6.1", "pear", "serde", - "toml", + "toml 0.8.23", "uncased", "version_check", ] @@ -840,6 +831,15 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "frontend" +version = "0.0.1" +dependencies = [ + "include_dir", + "serde", + "toml 0.9.12+spec-1.1.0", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -1591,6 +1591,25 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -1905,15 +1924,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "louds-rs" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "936de6c22f08e7135a921f8ada907acd0d88880c4f42b5591f634b9f1dd8e07f" -dependencies = [ - "fid-rs", -] - [[package]] name = "lru-slab" version = "0.1.2" @@ -2557,7 +2567,6 @@ dependencies = [ "serde_json", "thiserror", "tokio", - "trie-rs", "url", ] @@ -2722,26 +2731,6 @@ dependencies = [ "bitflags 2.10.0", ] -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -3237,6 +3226,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3928,11 +3926,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.13", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3942,6 +3955,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3950,10 +3972,19 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_write", - "winnow", + "winnow 0.7.13", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.3", ] [[package]] @@ -3962,6 +3993,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + [[package]] name = "tower" version = "0.5.2" @@ -4069,15 +4106,6 @@ dependencies = [ "tracing-log", ] -[[package]] -name = "trie-rs" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f88f4b0a1ebd6c3d16be3e45eb0e8089372ccadd88849b7ca162ba64b5e6f6" -dependencies = [ - "louds-rs", -] - [[package]] name = "try-lock" version = "0.2.5" @@ -4410,11 +4438,12 @@ dependencies = [ [[package]] name = "website" -version = "0.4.3" +version = "1.0.0" dependencies = [ "dotenvy", "env_logger", "flate2", + "frontend", "governor", "log", "querying", @@ -4795,6 +4824,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "winnow" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" + [[package]] name = "winreg" version = "0.50.0" diff --git a/Cargo.toml b/Cargo.toml index 5b10cee..c3bccdf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [ + "frontend", "querying", "reporter", "reports", diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..f85db2b --- /dev/null +++ b/biome.json @@ -0,0 +1,62 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.15/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "html": { + "experimentalFullSupportEnabled": true, + "linter": { + "enabled": true + }, + "formatter": { + "whitespaceSensitivity": "ignore", + "enabled": true + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "useEditorconfig": true + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noAssignInExpressions": "off", + "noGlobalAssign": "off" + }, + "complexity": { + "noCommaOperator": "off" + }, + "style": { + "noNonNullAssertion": "off" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + }, + "parser": { + "unsafeParameterDecoratorsEnabled": true + } + }, + "assist": { + "enabled": true, + "actions": { + "source": { + "organizeImports": "on" + } + } + } +} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a4f271e --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,25 @@ +node_modules + +# Output +.output +.vercel +.netlify +.wrangler +/.svelte-kit +/build + +# OS +.DS_Store +Thumbs.db + +# Env +.env +.env.* +!.env.example +!.env.test + +# Vite +vite.config.js.timestamp-* +vite.config.ts.timestamp-* + +dist/ \ No newline at end of file diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.vscode/extensions.json b/frontend/.vscode/extensions.json new file mode 100644 index 0000000..42ca69c --- /dev/null +++ b/frontend/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss"] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 0000000..bc31e15 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,5 @@ +{ + "files.associations": { + "*.css": "tailwindcss" + } +} diff --git a/frontend/Cargo.toml b/frontend/Cargo.toml new file mode 100644 index 0000000..604fd68 --- /dev/null +++ b/frontend/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "frontend" +version = "0.0.1" +edition = "2024" + +[dependencies] +include_dir = "0.7" + +[build-dependencies] +serde = { workspace = true } +toml = "0.9" diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..1b74c4e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,42 @@ +# sv + +Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli). + +## Creating a project + +If you're seeing this, you've probably already done this step. Congrats! + +```sh +# create a new project +npx sv create my-app +``` + +To recreate this project with the same configuration: + +```sh +# recreate this project +pnpm dlx sv@0.15.3 create --template minimal --types ts --add tailwindcss="plugins:none" --install pnpm frontend +``` + +## Developing + +Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server: + +```sh +npm run dev + +# or start the server and open the app in a new browser tab +npm run dev -- --open +``` + +## Building + +To create a production version of your app: + +```sh +npm run build +``` + +You can preview the production build with `npm run preview`. + +> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment. diff --git a/frontend/biome.json b/frontend/biome.json new file mode 100644 index 0000000..99b4ab8 --- /dev/null +++ b/frontend/biome.json @@ -0,0 +1,3 @@ +{ + "extends": "//" +} diff --git a/frontend/build.rs b/frontend/build.rs new file mode 100644 index 0000000..575afa9 --- /dev/null +++ b/frontend/build.rs @@ -0,0 +1,43 @@ +use std::error::Error; +use std::path::PathBuf; +use std::process::Command; + +use serde::Deserialize; + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=package.json"); + println!("cargo:rerun-if-changed=pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=svelte.config.js"); + println!("cargo:rerun-if-changed=vite.config.ts"); + println!("cargo:rerun-if-changed=src"); + println!("cargo:rerun-if-changed=static"); + println!("cargo:rerun-if-changed=../website/Cargo.toml"); + + if std::env::var_os("SKIP_FRONTEND_BUILD").is_some() { + return Ok(()); + } + + let manifest_dir = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR")?); + + run_pnpm(&manifest_dir, &["install", "--frozen-lockfile"])?; + run_pnpm(&manifest_dir, &["build"])?; + + Ok(()) +} + +fn run_pnpm(manifest_dir: &PathBuf, args: &[&str]) -> Result<(), Box> { + let mut command = Command::new(pnpm()); + command.args(args).current_dir(manifest_dir); + + let status = command.status()?; + + if status.success() { + Ok(()) + } else { + Err(format!("pnpm {} failed", args.join(" ")).into()) + } +} + +fn pnpm() -> &'static str { + if cfg!(windows) { "pnpm.cmd" } else { "pnpm" } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..99533d9 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,35 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "prepare": "svelte-kit sync || echo ''", + "type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", + "type-check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "format": "biome format --write .", + "check": "pnpm type-check && biome check --write .", + "lint": "biome lint ." + }, + "devDependencies": { + "@biomejs/biome": "2.4.15", + "@sveltejs/adapter-static": "^3.0.10", + "@sveltejs/kit": "^2.57.0", + "@sveltejs/vite-plugin-svelte": "^7.0.0", + "@tailwindcss/vite": "^4.2.2", + "@types/node": "^25.9.1", + "svelte": "^5.55.2", + "svelte-check": "^4.4.6", + "tailwindcss": "^4.2.2", + "typescript": "^6.0.2", + "vite": "^8.0.7" + }, + "dependencies": { + "@lucide/svelte": "^1.3.0", + "@tanstack/svelte-query": "^6.1.33", + "layerchart": "2.0.0-next.64" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..2f03c27 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1645 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@lucide/svelte': + specifier: ^1.3.0 + version: 1.16.0(svelte@5.55.10) + '@tanstack/svelte-query': + specifier: ^6.1.33 + version: 6.1.33(svelte@5.55.10) + layerchart: + specifier: 2.0.0-next.64 + version: 2.0.0-next.64(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10) + devDependencies: + '@biomejs/biome': + specifier: 2.4.15 + version: 2.4.15 + '@sveltejs/adapter-static': + specifier: ^3.0.10 + version: 3.0.10(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0))) + '@sveltejs/kit': + specifier: ^2.57.0 + version: 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + '@sveltejs/vite-plugin-svelte': + specifier: ^7.0.0 + version: 7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + '@tailwindcss/vite': + specifier: ^4.2.2 + version: 4.3.0(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + '@types/node': + specifier: ^25.9.1 + version: 25.9.1 + svelte: + specifier: ^5.55.2 + version: 5.55.10 + svelte-check: + specifier: ^4.4.6 + version: 4.4.8(picomatch@4.0.4)(svelte@5.55.10)(typescript@6.0.3) + tailwindcss: + specifier: ^4.2.2 + version: 4.3.0 + typescript: + specifier: ^6.0.2 + version: 6.0.3 + vite: + specifier: ^8.0.7 + version: 8.0.14(@types/node@25.9.1)(jiti@2.7.0) + +packages: + + '@biomejs/biome@2.4.15': + resolution: {integrity: sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.4.15': + resolution: {integrity: sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.4.15': + resolution: {integrity: sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.4.15': + resolution: {integrity: sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-arm64@2.4.15': + resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-linux-x64-musl@2.4.15': + resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@biomejs/cli-linux-x64@2.4.15': + resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@biomejs/cli-win32-arm64@2.4.15': + resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.4.15': + resolution: {integrity: sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@dagrejs/dagre@2.0.4': + resolution: {integrity: sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==} + + '@dagrejs/graphlib@3.0.4': + resolution: {integrity: sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@layerstack/svelte-actions@1.0.1-next.18': + resolution: {integrity: sha512-gxPzCnJ1c9LTfWtRqLUzefCx+k59ZpxDUQ2XB+LokveZQPe7IDSOwHaBOEMlaGoGrtwc3Ft8dSZq+2WT2o9u/g==} + + '@layerstack/svelte-state@0.1.0-next.23': + resolution: {integrity: sha512-7O4umv+gXwFfs3/vjzFWYHNXGwYnnjBapWJ5Y+9u99F4eVk6rh4ocNwqkqQNkpMZ5tUJBlRTWjPE1So6+hEzIg==} + + '@layerstack/tailwind@2.0.0-next.21': + resolution: {integrity: sha512-Qgp2EpmEHmjtura8MQzWicR6ztBRSsRvddakFtx9ShrLMz6jWzd6bCMVVRu44Q3ZOrtXmSu4QxjCZWu1ytvuPg==} + + '@layerstack/utils@2.0.0-next.18': + resolution: {integrity: sha512-EYILHpfBRYMMEahajInu9C2AXQom5IcAEdtCeucD3QIl/fdDgRbtzn6/8QW9ewumfyNZetdUvitOksmI1+gZYQ==} + + '@lucide/svelte@1.16.0': + resolution: {integrity: sha512-AvvPJnaWxeiNkAljI5MsSEc84yHPLMaWQIAJOcbX7k9au/f9ITS7cxTTQiautDiOFKVOXiYdZ+d6mtl88J+Kbg==} + peerDependencies: + svelte: ^5 + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.10': + resolution: {integrity: sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.61.1': + resolution: {integrity: sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 || ^6.0.0 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte@7.1.2': + resolution: {integrity: sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==} + engines: {node: ^20.19 || ^22.12 || >=24} + peerDependencies: + svelte: ^5.46.4 + vite: ^8.0.0-beta.7 || ^8.0.0 + + '@tailwindcss/node@4.3.0': + resolution: {integrity: sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==} + + '@tailwindcss/oxide-android-arm64@4.3.0': + resolution: {integrity: sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + resolution: {integrity: sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.3.0': + resolution: {integrity: sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + resolution: {integrity: sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + resolution: {integrity: sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + resolution: {integrity: sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + resolution: {integrity: sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + resolution: {integrity: sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.3.0': + resolution: {integrity: sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.3.0': + resolution: {integrity: sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + + '@tanstack/query-core@5.100.14': + resolution: {integrity: sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==} + + '@tanstack/svelte-query@6.1.33': + resolution: {integrity: sha512-hdbGeLLOIfkrB+/leytCON+tjpSbkO36OsXxrTkPpwC7p8jK36DaoBRR1FO1Z0jOg/fmfCs26lafzFsWXV5W9w==} + peerDependencies: + svelte: ^5.25.0 + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/d3-array@3.2.2': + resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} + + '@types/d3-contour@3.0.6': + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + + '@types/node@25.9.1': + resolution: {integrity: sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + commander@7.2.0: + resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} + engines: {node: '>= 10'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + d3-array@2.12.1: + resolution: {integrity: sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ==} + + d3-array@3.2.4: + resolution: {integrity: sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==} + engines: {node: '>=12'} + + d3-chord@3.0.1: + resolution: {integrity: sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g==} + engines: {node: '>=12'} + + d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + + d3-contour@4.0.2: + resolution: {integrity: sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA==} + engines: {node: '>=12'} + + d3-delaunay@6.0.4: + resolution: {integrity: sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A==} + engines: {node: '>=12'} + + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-dsv@3.0.1: + resolution: {integrity: sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q==} + engines: {node: '>=12'} + hasBin: true + + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + + d3-format@3.1.2: + resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} + engines: {node: '>=12'} + + d3-geo-voronoi@2.1.0: + resolution: {integrity: sha512-kqE4yYuOjPbKdBXG0xztCacPwkVSK2REF1opSNrnqqtXJmNcM++UbwQ8SxvwP6IQTj9RvIjjK4qeiVsEfj0Z2Q==} + engines: {node: '>=12'} + + d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + + d3-hierarchy@3.1.2: + resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} + engines: {node: '>=12'} + + d3-interpolate-path@2.3.0: + resolution: {integrity: sha512-tZYtGXxBmbgHsIc9Wms6LS5u4w6KbP8C09a4/ZYc4KLMYYqub57rRBUgpUr2CIarIrJEpdAWWxWQvofgaMpbKQ==} + + d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + + d3-path@1.0.9: + resolution: {integrity: sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg==} + + d3-path@3.1.0: + resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} + engines: {node: '>=12'} + + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + + d3-random@3.0.1: + resolution: {integrity: sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ==} + engines: {node: '>=12'} + + d3-sankey@0.12.3: + resolution: {integrity: sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ==} + + d3-scale-chromatic@3.1.0: + resolution: {integrity: sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==} + engines: {node: '>=12'} + + d3-scale@4.0.2: + resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} + engines: {node: '>=12'} + + d3-shape@1.3.7: + resolution: {integrity: sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw==} + + d3-shape@3.2.0: + resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} + engines: {node: '>=12'} + + d3-tile@1.0.0: + resolution: {integrity: sha512-79fnTKpPMPDS5xQ0xuS9ir0165NEwwkFpe/DSOmc2Gl9ldYzKKRDWogmTTE8wAJ8NA7PMapNfEcyKhI9Lxdu5Q==} + + d3-time-format@4.1.0: + resolution: {integrity: sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==} + engines: {node: '>=12'} + + d3-time@3.1.0: + resolution: {integrity: sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==} + engines: {node: '>=12'} + + d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + + d3-tricontour@1.1.0: + resolution: {integrity: sha512-G7gHKj89n2owmkGb6WX6ixcnQ0Kf/0wpa9VIh9DGdbHu8wdrlaHU4ir3/bFNERl8N8nn4G7e7qbtBG8N9caihQ==} + engines: {node: '>=12'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + delaunator@5.1.0: + resolution: {integrity: sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@5.8.1: + resolution: {integrity: sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==} + + enhanced-resolve@5.22.0: + resolution: {integrity: sha512-xYcDWrpELkFzz9SpZ3PlI6Eu6eD93Yf0WLDRxikGhWJ3MAir2SNZTIVCVZqZ/NUyx8AdMc2gT9C0gPiw18kG+A==} + engines: {node: '>=10.13.0'} + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.9: + resolution: {integrity: sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==} + peerDependencies: + '@typescript-eslint/types': ^8.2.0 + peerDependenciesMeta: + '@typescript-eslint/types': + optional: true + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + iconv-lite@0.6.3: + resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} + engines: {node: '>=0.10.0'} + + internmap@1.0.1: + resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} + + internmap@2.0.3: + resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} + engines: {node: '>=12'} + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + layerchart@2.0.0-next.64: + resolution: {integrity: sha512-vkD8CBPQOFslC8AGgHvTc1lXOJYuj+Fzimjle7Gxu/GUXQ83m68G+H3zhMTB61Q8aXEu4ytz4vVuwuOB+5JD+Q==} + peerDependencies: + svelte: ^5.0.0 + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + memoize@10.2.0: + resolution: {integrity: sha512-DeC6b7QBrZsRs3Y02A6A7lQyzFbsQbqgjI6UW0GigGWV+u1s25TycMr0XHZE4cJce7rY/vyw2ctMQqfDkIhUEA==} + engines: {node: '>=18'} + + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mri@1.2.0: + resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} + engines: {node: '>=4'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + robust-predicates@3.0.3: + resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + runed@0.37.1: + resolution: {integrity: sha512-MeFY73xBW8IueWBm012nNFIGy19WUGPLtknavyUPMpnyt350M47PhGSGrGoSLbidwn+Zlt/O0cp8/OZE3LASWA==} + peerDependencies: + '@sveltejs/kit': ^2.21.0 + svelte: ^5.7.0 + zod: ^4.1.0 + peerDependenciesMeta: + '@sveltejs/kit': + optional: true + zod: + optional: true + + rw@1.3.3: + resolution: {integrity: sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==} + + sade@1.8.1: + resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} + engines: {node: '>=6'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + set-cookie-parser@3.1.0: + resolution: {integrity: sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte-check@4.4.8: + resolution: {integrity: sha512-67adfgBox5eNSNIvIIwgFizKGdcRrGpiMoNO2obHcYuLz7iTa8Xgm/NGU3ntMFnNm8K1grFOIG6HhMLX/vcN8w==} + engines: {node: '>= 18.0.0'} + hasBin: true + peerDependencies: + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: '>=5.0.0' + + svelte@5.55.10: + resolution: {integrity: sha512-v9mFVBY1USosyIWdXE7Cg4AN0ywyKCMcAhONvli8doMowEhFhMdNLKD1j7O/UnsrdVTHaUOk/jv8hD/HClVy+g==} + engines: {node: '>=18'} + + tailwind-merge@3.6.0: + resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} + + tailwindcss@4.3.0: + resolution: {integrity: sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + 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 + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@biomejs/biome@2.4.15': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.4.15 + '@biomejs/cli-darwin-x64': 2.4.15 + '@biomejs/cli-linux-arm64': 2.4.15 + '@biomejs/cli-linux-arm64-musl': 2.4.15 + '@biomejs/cli-linux-x64': 2.4.15 + '@biomejs/cli-linux-x64-musl': 2.4.15 + '@biomejs/cli-win32-arm64': 2.4.15 + '@biomejs/cli-win32-x64': 2.4.15 + + '@biomejs/cli-darwin-arm64@2.4.15': + optional: true + + '@biomejs/cli-darwin-x64@2.4.15': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.4.15': + optional: true + + '@biomejs/cli-linux-arm64@2.4.15': + optional: true + + '@biomejs/cli-linux-x64-musl@2.4.15': + optional: true + + '@biomejs/cli-linux-x64@2.4.15': + optional: true + + '@biomejs/cli-win32-arm64@2.4.15': + optional: true + + '@biomejs/cli-win32-x64@2.4.15': + optional: true + + '@dagrejs/dagre@2.0.4': + dependencies: + '@dagrejs/graphlib': 3.0.4 + + '@dagrejs/graphlib@3.0.4': {} + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/utils@0.2.11': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@layerstack/svelte-actions@1.0.1-next.18': + dependencies: + '@floating-ui/dom': 1.7.6 + '@layerstack/utils': 2.0.0-next.18 + d3-scale: 4.0.2 + + '@layerstack/svelte-state@0.1.0-next.23': + dependencies: + '@layerstack/utils': 2.0.0-next.18 + + '@layerstack/tailwind@2.0.0-next.21': + dependencies: + '@layerstack/utils': 2.0.0-next.18 + clsx: 2.1.1 + d3-array: 3.2.4 + tailwind-merge: 3.6.0 + + '@layerstack/utils@2.0.0-next.18': + dependencies: + d3-array: 3.2.4 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + '@lucide/svelte@1.16.0(svelte@5.55.10)': + dependencies: + svelte: 5.55.10 + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.132.0': {} + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.10(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))': + dependencies: + '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + + '@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0))': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.8.1 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.1.0 + sirv: 3.0.2 + svelte: 5.55.10 + vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0) + optionalDependencies: + typescript: 6.0.3 + + '@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0))': + dependencies: + deepmerge: 4.3.1 + magic-string: 0.30.21 + obug: 2.1.1 + svelte: 5.55.10 + vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0) + vitefu: 1.1.3(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + + '@tailwindcss/node@4.3.0': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.22.0 + jiti: 2.7.0 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.3.0 + + '@tailwindcss/oxide-android-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.3.0': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.3.0': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.3.0': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.3.0': + optional: true + + '@tailwindcss/oxide@4.3.0': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-arm64': 4.3.0 + '@tailwindcss/oxide-darwin-x64': 4.3.0 + '@tailwindcss/oxide-freebsd-x64': 4.3.0 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.3.0 + '@tailwindcss/oxide-linux-arm64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-arm64-musl': 4.3.0 + '@tailwindcss/oxide-linux-x64-gnu': 4.3.0 + '@tailwindcss/oxide-linux-x64-musl': 4.3.0 + '@tailwindcss/oxide-wasm32-wasi': 4.3.0 + '@tailwindcss/oxide-win32-arm64-msvc': 4.3.0 + '@tailwindcss/oxide-win32-x64-msvc': 4.3.0 + + '@tailwindcss/vite@4.3.0(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0))': + dependencies: + '@tailwindcss/node': 4.3.0 + '@tailwindcss/oxide': 4.3.0 + tailwindcss: 4.3.0 + vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0) + + '@tanstack/query-core@5.100.14': {} + + '@tanstack/svelte-query@6.1.33(svelte@5.55.10)': + dependencies: + '@tanstack/query-core': 5.100.14 + svelte: 5.55.10 + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/cookie@0.6.0': {} + + '@types/d3-array@3.2.2': {} + + '@types/d3-contour@3.0.6': + dependencies: + '@types/d3-array': 3.2.2 + '@types/geojson': 7946.0.16 + + '@types/estree@1.0.9': {} + + '@types/geojson@7946.0.16': {} + + '@types/node@25.9.1': + dependencies: + undici-types: 7.24.6 + + '@types/trusted-types@2.0.7': {} + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + clsx@2.1.1: {} + + commander@7.2.0: {} + + cookie@0.6.0: {} + + d3-array@2.12.1: + dependencies: + internmap: 1.0.1 + + d3-array@3.2.4: + dependencies: + internmap: 2.0.3 + + d3-chord@3.0.1: + dependencies: + d3-path: 3.1.0 + + d3-color@3.1.0: {} + + d3-contour@4.0.2: + dependencies: + d3-array: 3.2.4 + + d3-delaunay@6.0.4: + dependencies: + delaunator: 5.1.0 + + d3-dispatch@3.0.1: {} + + d3-dsv@3.0.1: + dependencies: + commander: 7.2.0 + iconv-lite: 0.6.3 + rw: 1.3.3 + + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + + d3-format@3.1.2: {} + + d3-geo-voronoi@2.1.0: + dependencies: + d3-array: 3.2.4 + d3-delaunay: 6.0.4 + d3-geo: 3.1.1 + d3-tricontour: 1.1.0 + + d3-geo@3.1.1: + dependencies: + d3-array: 3.2.4 + + d3-hierarchy@3.1.2: {} + + d3-interpolate-path@2.3.0: {} + + d3-interpolate@3.0.1: + dependencies: + d3-color: 3.1.0 + + d3-path@1.0.9: {} + + d3-path@3.1.0: {} + + d3-quadtree@3.0.1: {} + + d3-random@3.0.1: {} + + d3-sankey@0.12.3: + dependencies: + d3-array: 2.12.1 + d3-shape: 1.3.7 + + d3-scale-chromatic@3.1.0: + dependencies: + d3-color: 3.1.0 + d3-interpolate: 3.0.1 + + d3-scale@4.0.2: + dependencies: + d3-array: 3.2.4 + d3-format: 3.1.2 + d3-interpolate: 3.0.1 + d3-time: 3.1.0 + d3-time-format: 4.1.0 + + d3-shape@1.3.7: + dependencies: + d3-path: 1.0.9 + + d3-shape@3.2.0: + dependencies: + d3-path: 3.1.0 + + d3-tile@1.0.0: {} + + d3-time-format@4.1.0: + dependencies: + d3-time: 3.1.0 + + d3-time@3.1.0: + dependencies: + d3-array: 3.2.4 + + d3-timer@3.0.1: {} + + d3-tricontour@1.1.0: + dependencies: + d3-delaunay: 6.0.4 + d3-scale: 4.0.2 + + deepmerge@4.3.1: {} + + delaunator@5.1.0: + dependencies: + robust-predicates: 3.0.3 + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + devalue@5.8.1: {} + + enhanced-resolve@5.22.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + + esm-env@1.2.2: {} + + esrap@2.2.9: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + iconv-lite@0.6.3: + dependencies: + safer-buffer: 2.1.2 + + internmap@1.0.1: {} + + internmap@2.0.3: {} + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + jiti@2.7.0: {} + + kleur@4.1.5: {} + + layerchart@2.0.0-next.64(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10): + dependencies: + '@dagrejs/dagre': 2.0.4 + '@layerstack/svelte-actions': 1.0.1-next.18 + '@layerstack/svelte-state': 0.1.0-next.23 + '@layerstack/tailwind': 2.0.0-next.21 + '@layerstack/utils': 2.0.0-next.18 + '@types/d3-contour': 3.0.6 + d3-array: 3.2.4 + d3-chord: 3.0.1 + d3-color: 3.1.0 + d3-contour: 4.0.2 + d3-delaunay: 6.0.4 + d3-dsv: 3.0.1 + d3-force: 3.0.0 + d3-geo: 3.1.1 + d3-geo-voronoi: 2.1.0 + d3-hierarchy: 3.1.2 + d3-interpolate: 3.0.1 + d3-interpolate-path: 2.3.0 + d3-path: 3.1.0 + d3-quadtree: 3.0.1 + d3-random: 3.0.1 + d3-sankey: 0.12.3 + d3-scale: 4.0.2 + d3-scale-chromatic: 3.1.0 + d3-shape: 3.2.0 + d3-tile: 1.0.0 + d3-time: 3.1.0 + memoize: 10.2.0 + runed: 0.37.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10) + svelte: 5.55.10 + transitivePeerDependencies: + - '@sveltejs/kit' + - zod + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-character@3.0.0: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + memoize@10.2.0: + dependencies: + mimic-function: 5.0.1 + + mimic-function@5.0.1: {} + + mri@1.2.0: {} + + mrmime@2.0.1: {} + + nanoid@3.3.12: {} + + obug@2.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@4.1.2: {} + + robust-predicates@3.0.3: {} + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + runed@0.37.1(@sveltejs/kit@2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10): + dependencies: + dequal: 2.0.3 + esm-env: 1.2.2 + lz-string: 1.5.0 + svelte: 5.55.10 + optionalDependencies: + '@sveltejs/kit': 2.61.1(@sveltejs/vite-plugin-svelte@7.1.2(svelte@5.55.10)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)))(svelte@5.55.10)(typescript@6.0.3)(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)) + + rw@1.3.3: {} + + sade@1.8.1: + dependencies: + mri: 1.2.0 + + safer-buffer@2.1.2: {} + + set-cookie-parser@3.1.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + svelte-check@4.4.8(picomatch@4.0.4)(svelte@5.55.10)(typescript@6.0.3): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + chokidar: 4.0.3 + fdir: 6.5.0(picomatch@4.0.4) + picocolors: 1.1.1 + sade: 1.8.1 + svelte: 5.55.10 + typescript: 6.0.3 + transitivePeerDependencies: + - picomatch + + svelte@5.55.10: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.10(acorn@8.16.0) + '@types/estree': 1.0.9 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.8.1 + esm-env: 1.2.2 + esrap: 2.2.9 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + transitivePeerDependencies: + - '@typescript-eslint/types' + + tailwind-merge@3.6.0: {} + + tailwindcss@4.3.0: {} + + tapable@2.3.3: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + totalist@3.0.1: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.24.6: {} + + vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.9.1 + fsevents: 2.3.3 + jiti: 2.7.0 + + vitefu@1.1.3(vite@8.0.14(@types/node@25.9.1)(jiti@2.7.0)): + optionalDependencies: + vite: 8.0.14(@types/node@25.9.1)(jiti@2.7.0) + + zimmerframe@1.1.4: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..5b23a1c --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +onlyBuiltDependencies: + - '@tailwindcss/oxide' + - esbuild diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..2c1e3c3 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,21 @@ +// See https://svelte.dev/docs/kit/types#app.d.ts +// for information about these interfaces +declare global { + interface ImportMetaEnv { + readonly VITE_APP_VERSION: string; + } + + interface ImportMeta { + readonly env: ImportMetaEnv; + } + + namespace App { + // interface Error {} + // interface Locals {} + // interface PageData {} + // interface PageState {} + // interface Platform {} + } +} + +export {}; diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..21792fc --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,15 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/lib.rs b/frontend/src/lib.rs new file mode 100644 index 0000000..659927f --- /dev/null +++ b/frontend/src/lib.rs @@ -0,0 +1,3 @@ +pub use include_dir::{Dir, File, include_dir}; + +pub static DIST: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/dist"); diff --git a/frontend/src/lib/api/check.ts b/frontend/src/lib/api/check.ts new file mode 100644 index 0000000..42ba83e --- /dev/null +++ b/frontend/src/lib/api/check.ts @@ -0,0 +1,114 @@ +type ApiNetworkRecord = { + provider: string; + cidr: string; + region?: string | null; +}; + +type ApiAsnInfo = { + asn: number; + prefixes: string[]; + blocked_prefixes: string[]; +}; + +type ApiWhitelist = { + domain?: string | null; + rank?: number | null; + last_ok?: string | null; +}; + +type ApiCheckResponse = { + id?: string | null; + target: string; + target_type: string; + blocked: boolean; + rkn_domain?: string | null; + ips: string[]; + blocked_subnets: string[]; + cdn_providers: Record; + geo: { + asn?: string | null; + country_code?: string | null; + organisation?: string | null; + city_geo_name_id?: number | null; + location: string; + }; + asn_info?: ApiAsnInfo | null; + whitelist?: ApiWhitelist | null; + subnet_size?: string | null; +}; + +export type CheckResult = { + id?: string | null; + targetType: string; + target: string; + found: boolean; + blocked: boolean; + whitelist?: { lastOk?: string | null } | null; + domain?: string | null; + ips: string[]; + subnetSize?: string | null; + geo: { organisation?: string | null; location: string; asn?: string | null }; + providers: { name: string; networks: ApiNetworkRecord[] }[]; + blockedSubnets: string[]; + asnInfo: { prefixes: string[]; blockedPrefixes: string[] } | null; +}; + +export class CheckRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "CheckRequestError"; + } +} + +const sortPrefixes = (prefixes: string[]) => [ + ...prefixes.filter((prefix) => !prefix.includes(":")), + ...prefixes.filter((prefix) => prefix.includes(":")), +]; + +export async function fetchCheck(target: string): Promise { + const response = await fetch( + `/api/v1/check?target=${encodeURIComponent(target)}`, + { + headers: { + Accept: "application/json", + }, + }, + ); + + if (!response.ok) { + throw new CheckRequestError(response.statusText, response.status); + } + + const data = (await response.json()) as ApiCheckResponse; + + return { + id: data.id, + targetType: data.target_type, + target: data.target, + found: data.blocked, + blocked: data.blocked, + whitelist: data.whitelist + ? { + lastOk: data.whitelist.last_ok, + } + : null, + domain: data.rkn_domain, + ips: data.ips, + subnetSize: data.subnet_size, + geo: data.geo, + providers: Object.entries(data.cdn_providers).map(([name, networks]) => ({ + name, + networks, + })), + blockedSubnets: data.blocked_subnets, + asnInfo: data.asn_info + ? { + prefixes: sortPrefixes(data.asn_info.prefixes), + blockedPrefixes: sortPrefixes(data.asn_info.blocked_prefixes), + } + : null, + }; +} diff --git a/frontend/src/lib/api/feedback.ts b/frontend/src/lib/api/feedback.ts new file mode 100644 index 0000000..b2783e2 --- /dev/null +++ b/frontend/src/lib/api/feedback.ts @@ -0,0 +1,30 @@ +export type FeedbackPayload = { + id: string; + works: boolean; +}; + +export class FeedbackRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "FeedbackRequestError"; + } +} + +export async function submitFeedback({ + id, + works, +}: FeedbackPayload): Promise { + const response = await fetch( + `/api/v1/feedback/${encodeURIComponent(id)}/${works}`, + { + method: "POST", + }, + ); + + if (!response.ok) { + throw new FeedbackRequestError(response.statusText, response.status); + } +} diff --git a/frontend/src/lib/api/status.ts b/frontend/src/lib/api/status.ts new file mode 100644 index 0000000..5cbe636 --- /dev/null +++ b/frontend/src/lib/api/status.ts @@ -0,0 +1,30 @@ +export type StatusMetrics = { + domain_count: number; + v4_count: number; + last_update: string | null; + version: string; +}; + +export class StatusRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "StatusRequestError"; + } +} + +export async function fetchStatus(): Promise { + const response = await fetch(`/api/v1/status`, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new StatusRequestError(response.statusText, response.status); + } + + return await response.json(); +} diff --git a/frontend/src/lib/api/whitelist.ts b/frontend/src/lib/api/whitelist.ts new file mode 100644 index 0000000..8dc76ab --- /dev/null +++ b/frontend/src/lib/api/whitelist.ts @@ -0,0 +1,51 @@ +type ApiWhitelistHistogramBin = { + bin_min_rank?: number | null; + bin_max_rank?: number | null; + count?: number | null; +}; + +export type WhitelistHistogramBin = { + label: string; + count: number; +}; + +export class WhitelistHistogramRequestError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + this.name = "WhitelistHistogramRequestError"; + } +} + +export async function fetchWhitelistHistogram( + limit: number, + filter = false, +): Promise { + const params = new URLSearchParams({ limit: String(limit) }); + + if (filter) { + params.set("filter", "true"); + } + + const response = await fetch(`/api/v1/histogram?${params}`, { + headers: { + Accept: "application/json", + }, + }); + + if (!response.ok) { + throw new WhitelistHistogramRequestError( + response.statusText, + response.status, + ); + } + + const data = (await response.json()) as ApiWhitelistHistogramBin[]; + + return data.map((bin) => ({ + label: `${bin.bin_min_rank ?? 0}-${bin.bin_max_rank ?? 0}`, + count: bin.count ?? 0, + })); +} diff --git a/frontend/src/lib/components/AppShell.svelte b/frontend/src/lib/components/AppShell.svelte new file mode 100644 index 0000000..a0898c8 --- /dev/null +++ b/frontend/src/lib/components/AppShell.svelte @@ -0,0 +1,96 @@ + + +
+ +
+ +
+ {@render children()} +
+ + diff --git a/frontend/src/lib/components/DetailRow.svelte b/frontend/src/lib/components/DetailRow.svelte new file mode 100644 index 0000000..e5d3f1c --- /dev/null +++ b/frontend/src/lib/components/DetailRow.svelte @@ -0,0 +1,29 @@ + + +
+ {#if href} + + {label} + + {:else} + + {label} + + {/if} +
+ {@render children()} +
+
diff --git a/frontend/src/lib/components/EmptyResult.svelte b/frontend/src/lib/components/EmptyResult.svelte new file mode 100644 index 0000000..ea63994 --- /dev/null +++ b/frontend/src/lib/components/EmptyResult.svelte @@ -0,0 +1,28 @@ + + +
+
+
+
+
+

+ Не найдено +

+

Возможно вы неправильно ввели запрос?

+
+
+ +
+
{targetType}:
+
{target}
+
+
diff --git a/frontend/src/lib/components/ErrorMessage.svelte b/frontend/src/lib/components/ErrorMessage.svelte new file mode 100644 index 0000000..6fb2dcf --- /dev/null +++ b/frontend/src/lib/components/ErrorMessage.svelte @@ -0,0 +1,39 @@ + + +
+
+

+ {error} +

diff --git a/frontend/src/lib/components/Feedback.svelte b/frontend/src/lib/components/Feedback.svelte new file mode 100644 index 0000000..7b05944 --- /dev/null +++ b/frontend/src/lib/components/Feedback.svelte @@ -0,0 +1,74 @@ + + +
+ {#if feedbackMutation.isSuccess} +
+
+ {:else} +

У вас работает этот ресурс?

+
+ + +
+ {#if feedbackMutation.isError} +

+ Не удалось отправить отзыв. Попробуйте еще раз. +

+ {/if} + {/if} +
diff --git a/frontend/src/lib/components/GitHubIcon.svelte b/frontend/src/lib/components/GitHubIcon.svelte new file mode 100644 index 0000000..3f9964b --- /dev/null +++ b/frontend/src/lib/components/GitHubIcon.svelte @@ -0,0 +1,13 @@ + + GitHub + + diff --git a/frontend/src/lib/components/ResultPanel.svelte b/frontend/src/lib/components/ResultPanel.svelte new file mode 100644 index 0000000..e52ef72 --- /dev/null +++ b/frontend/src/lib/components/ResultPanel.svelte @@ -0,0 +1,205 @@ + + +
+ + + + {#if result.asnInfo} + + {/if} + +
+
+

+ Сетевые данные +

+ + {#if !result.asnInfo} + + + + {/if} + + + {result.geo.organisation || "-"} + + + {result.geo.location} + + + + {#if result.geo.asn} + + {result.geo.asn} + + {:else} + - + {/if} + + + + {#if result.asnInfo} + + + + {/if} +
+ +
+

+ Нахождение в списках +

+ + + {#if result.providers.length > 0} +

НАЙДЕН

+ {:else} + Не найден + {/if} +
+ + {#each result.providers as provider} + + + + {/each} + + {#if result.whitelist} + + + НАЙДЕН - + + {whitelistDate} + + + + {/if} + + + {#if result.domain} + ОГРАНИЧЕН + {:else if result.blockedSubnets.length > 0} + + IP-АДРЕСА + + {:else} + Не найден + {/if} + + + {#if result.domain} + + {result.domain} + + {/if} + + {#if result.blockedSubnets.length > 0 && !result.asnInfo} + +
+ {#each result.blockedSubnets as network} +

{network}

+ {/each} +
+
+ {/if} + + {#if result.asnInfo && blockedPrefixes.length > 0} + + + + {/if} +
+
+ + {#if result.id} + + {/if} +
diff --git a/frontend/src/lib/components/SearchForm.svelte b/frontend/src/lib/components/SearchForm.svelte new file mode 100644 index 0000000..17de0a2 --- /dev/null +++ b/frontend/src/lib/components/SearchForm.svelte @@ -0,0 +1,33 @@ + + +
+ + + + +
+
+ +
diff --git a/frontend/src/lib/components/StatCard.svelte b/frontend/src/lib/components/StatCard.svelte new file mode 100644 index 0000000..238c3cb --- /dev/null +++ b/frontend/src/lib/components/StatCard.svelte @@ -0,0 +1,29 @@ + + +
+
+
+ {value} +
diff --git a/frontend/src/lib/components/kb/KbArticle.svelte b/frontend/src/lib/components/kb/KbArticle.svelte new file mode 100644 index 0000000..9a74c2d --- /dev/null +++ b/frontend/src/lib/components/kb/KbArticle.svelte @@ -0,0 +1,11 @@ + + +
+ {@render children()} +
diff --git a/frontend/src/lib/components/kb/KbCaption.svelte b/frontend/src/lib/components/kb/KbCaption.svelte new file mode 100644 index 0000000..21645a2 --- /dev/null +++ b/frontend/src/lib/components/kb/KbCaption.svelte @@ -0,0 +1,9 @@ + + + + {@render children()} + diff --git a/frontend/src/lib/components/kb/KbChartFrame.svelte b/frontend/src/lib/components/kb/KbChartFrame.svelte new file mode 100644 index 0000000..210b8d6 --- /dev/null +++ b/frontend/src/lib/components/kb/KbChartFrame.svelte @@ -0,0 +1,15 @@ + + +
+ {@render children()} +
diff --git a/frontend/src/lib/components/kb/KbCode.svelte b/frontend/src/lib/components/kb/KbCode.svelte new file mode 100644 index 0000000..61a6a47 --- /dev/null +++ b/frontend/src/lib/components/kb/KbCode.svelte @@ -0,0 +1,9 @@ + + +
{@render children()}
diff --git a/frontend/src/lib/components/kb/KbDivider.svelte b/frontend/src/lib/components/kb/KbDivider.svelte new file mode 100644 index 0000000..f9adc0a --- /dev/null +++ b/frontend/src/lib/components/kb/KbDivider.svelte @@ -0,0 +1 @@ +
diff --git a/frontend/src/lib/components/kb/KbFileLink.svelte b/frontend/src/lib/components/kb/KbFileLink.svelte new file mode 100644 index 0000000..95cd088 --- /dev/null +++ b/frontend/src/lib/components/kb/KbFileLink.svelte @@ -0,0 +1,11 @@ + + + diff --git a/frontend/src/lib/components/kb/KbHeading.svelte b/frontend/src/lib/components/kb/KbHeading.svelte new file mode 100644 index 0000000..f11ca6c --- /dev/null +++ b/frontend/src/lib/components/kb/KbHeading.svelte @@ -0,0 +1,14 @@ + + +

+ + {@render children()} + +

diff --git a/frontend/src/lib/components/kb/KbNote.svelte b/frontend/src/lib/components/kb/KbNote.svelte new file mode 100644 index 0000000..27d1112 --- /dev/null +++ b/frontend/src/lib/components/kb/KbNote.svelte @@ -0,0 +1,22 @@ + + +

+

diff --git a/frontend/src/lib/components/result/ResultAsnSummary.svelte b/frontend/src/lib/components/result/ResultAsnSummary.svelte new file mode 100644 index 0000000..926b9e8 --- /dev/null +++ b/frontend/src/lib/components/result/ResultAsnSummary.svelte @@ -0,0 +1,28 @@ + + +
+
+ + Всего подсетей: + + {total} +
+ {#if blocked > 0} +
+ + Заблокировано: + + {blocked} +
+ {/if} +
diff --git a/frontend/src/lib/components/result/ResultIpList.svelte b/frontend/src/lib/components/result/ResultIpList.svelte new file mode 100644 index 0000000..3887379 --- /dev/null +++ b/frontend/src/lib/components/result/ResultIpList.svelte @@ -0,0 +1,63 @@ + + +
+ {#if ips.length === 1} + + {ips[0]} + + {#if subnetSize} + ({subnetSize} всего) + {/if} + {:else if ips.length <= 5} + {#each ips as ip} + + {ip} + + {/each} + {#if subnetSize} + ({subnetSize} всего) + {/if} + {:else} +
+ + {ips[0]} + - {ips[ips.length - 1]} + + {#if subnetSize} + + ({subnetSize} + всего, проверено {ips.length} + ) + + {:else} + ({ips.length} адресов) + {/if} +
+
+ + Показать все + +
+ {#each ips as ip} + + {ip} + + {/each} +
+
+ {/if} +
diff --git a/frontend/src/lib/components/result/ResultStatusHeader.svelte b/frontend/src/lib/components/result/ResultStatusHeader.svelte new file mode 100644 index 0000000..08f5074 --- /dev/null +++ b/frontend/src/lib/components/result/ResultStatusHeader.svelte @@ -0,0 +1,54 @@ + + +
+
+
+
+

+ {title} +

+

{subtitle}

+
+
diff --git a/frontend/src/lib/components/result/ResultStringList.svelte b/frontend/src/lib/components/result/ResultStringList.svelte new file mode 100644 index 0000000..a642bc7 --- /dev/null +++ b/frontend/src/lib/components/result/ResultStringList.svelte @@ -0,0 +1,40 @@ + + +
+
+ {#each items.slice(0, limit) as item} +

{item}

+ {/each} +
+ {#if items.length > limit} +
+ + Показать все ({items.length} + ) + +
+ {#each items as item} +

{item}

+ {/each} +
+
+ {/if} +
diff --git a/frontend/src/lib/components/result/ResultTargetCard.svelte b/frontend/src/lib/components/result/ResultTargetCard.svelte new file mode 100644 index 0000000..f72ab9b --- /dev/null +++ b/frontend/src/lib/components/result/ResultTargetCard.svelte @@ -0,0 +1,14 @@ + + +
+
{targetType}:
+
{target}
+
diff --git a/frontend/src/lib/context/status.ts b/frontend/src/lib/context/status.ts new file mode 100644 index 0000000..03c64dd --- /dev/null +++ b/frontend/src/lib/context/status.ts @@ -0,0 +1,8 @@ +import type { CreateQueryResult } from "@tanstack/svelte-query"; +import { createContext } from "svelte"; +import type { StatusMetrics } from "$lib/api/status"; + +type StatusContext = CreateQueryResult; + +export const [getStatusContext, setStatusContext] = + createContext(); diff --git a/frontend/src/lib/index.ts b/frontend/src/lib/index.ts new file mode 100644 index 0000000..856f2b6 --- /dev/null +++ b/frontend/src/lib/index.ts @@ -0,0 +1 @@ +// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..ae6b7ee --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,44 @@ + + + + Cheburcheck + + + + + + + + + + + + + + + {@render children()} + diff --git a/frontend/src/routes/+layout.ts b/frontend/src/routes/+layout.ts new file mode 100644 index 0000000..a3d1578 --- /dev/null +++ b/frontend/src/routes/+layout.ts @@ -0,0 +1 @@ +export const ssr = false; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..fef47ad --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,53 @@ + + + + Cheburcheck + + +
+

+ Статус Ресурса +

+

+ Введите домен или IP-адрес для поиска по спискам заблокированных адресов и + хостинг-провайдеров. +

+
+ + + +
+ + + +
diff --git a/frontend/src/routes/check/+page.svelte b/frontend/src/routes/check/+page.svelte new file mode 100644 index 0000000..0021dae --- /dev/null +++ b/frontend/src/routes/check/+page.svelte @@ -0,0 +1,40 @@ + + + + +{#if target.length === 0} +
+ Введите домен, IP-адрес, подсеть или ASN для проверки. +
+{:else if checkQuery.isPending} + +{:else if error?.status === 404} + +{:else if error} +
+ +
+{:else if checkQuery.data} + +{/if} diff --git a/frontend/src/routes/kb/faq/+page.svelte b/frontend/src/routes/kb/faq/+page.svelte new file mode 100644 index 0000000..90cb807 --- /dev/null +++ b/frontend/src/routes/kb/faq/+page.svelte @@ -0,0 +1,130 @@ + + + + FAQ - Cheburcheck + + + + + + + +

Ответы на частые вопросы

+

+ На этой странице собраны ответы на часто задаваемые вопросы. Если вас + интересует что-то ещё - пишите нам на почту + support@cheburcheck.ru + . +

+ + Что это за сайт? +

+ Этот сайт - инструмент, который позволяет проверить, находится ли сайт, + домен, IP-адрес в + списках + , блокируемых Роскомнадзором на территории России. +

+ + + Откуда вы берете эти списки? + +

+ Для поиска сайтов мы используем списки IP-подсетей популярных + CDN-провайдеров, которые могут блокироваться Роскомнадзором, а также домены + из самого реестра Роскомнадзора. +

+

+ Подсети, которые мы используем, публикуют сами провайдеры, либо собираются + из принадлежащих им автономных сетей. Мы используем + этот репозиторий + , в котором автоматически агрегируются эти подсети. +

+

+ Данные из Реестра Роскомнадзора подгружаются из сервисов + antifilter.download + и + antifilter.network + . +

+ + Что такое «белый список»? +

+ РКН формирует свой белый список доменов, которые доступны, даже если их + адреса находятся в списке заблокированных подсетей. +

+ Подробнее... + + + Мне написало, что сайт «Заблокирован», но у меня все работает! + +

+ Мы не можем с точностью утверждать, заблокирован сайт или нет. Блокировки + Роскомнадзора, зачастую, непредсказуемые, непубличные и незаконные. + Например, блокировка CDN-сервисов, таких, как + + Cloudflare, + + никак не комментировалась официально и не публиковались в Реестре. + Аналогичная ситуация с + YouTube, + информация о блокировке которого, не добавлена в Реестр — для таких + случаев мы создали + + свой список + + подобных сайтов. Однако, список не исчерпывающий, поэтому + + принимаются правки. + +

+

+ Сайты помечаются как "заблокированные", в случае, если они были найдены хотя + бы в одном списке. +

+ + + Как часто обновляются списки? + +

Мы обновляем списки из источников раз в 6 часов.

+

Сами источники обновляют их с разной периодичностью:

+ + + + Как я могу помочь в развитии сайта? + +

Самый простой способ помочь - рассказать о сайте знакомым.

+

+ Если у вас есть навыки разработки, то мы будем рады принять ваши + pull-requests на + GitHub. +

+

Если вы хотите помочь финансово:

+
    +
  • TON: UQAACsiwpGryjP-kqp4TJPAWpXytuB6M_puuO0Cg5zNvaSJW
  • +
+

Большое спасибо!

+
diff --git a/frontend/src/routes/kb/whitelist/+page.svelte b/frontend/src/routes/kb/whitelist/+page.svelte new file mode 100644 index 0000000..492412d --- /dev/null +++ b/frontend/src/routes/kb/whitelist/+page.svelte @@ -0,0 +1,196 @@ + + + + Белые списки - Cheburcheck + + + + + + + +

Белые списки доменов

+ + Не путать с + + белыми списками мобильного интернета + + ! + +

+ Российские операторы связи начали применять новый тип блокировок CDN, при + котором загрузка контента обрывается после передачи примерно 16–20 килобайт + данных, из‑за чего большинство сайтов на Cloudflare и других зарубежных + платформах становятся практически неработоспособными для пользователей в + России. Одновременно сохраняются и развиваются белые списки с популярными и + «социально значимыми» доменами, которые старательно выводятся из‑под + подобных ограничений. +

+

+ Суть схемы в том, что соединение технически устанавливается, первые + килобайты HTML, CSS или скриптов передаются, но затем трафик режется или + соединение принудительно сбрасывается на уровне российских операторов, + обычно после примерно 16 килобайт (10–14 пакетов в зависимости от + протокола). Этого объёма достаточно, чтобы страница начала загружаться и + создавалась иллюзия доступности, но ключевые части сайта (основной HTML, + JS‑бандлы, стили, API‑запросы) не проходят, и сервис фактически перестаёт + работать. +

+ + +$ curl -k https://cheburcheck.ru/100MB.bin -o/dev/null -r 0-65536 --resolve cheburcheck.ru:443:5.78.7.195 --max-time 5 + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed + 24 65537 24 16101 0 0 3220 0 0:00:20 0:00:04 0:00:16 0 +curl: (28) Operation timed out after 5000 milliseconds with 16101 out of 65537 bytes received + +

+ Определение домена и выбор, «разрешить или задушить» запрос, в текущей схеме + делается на связке DPI‑фильтров. По умолчанию, все HTTP(s) запросы на адреса + из подсетей хостеров блокируются. Однако, запросы к некоторым доменам из тех + же подсетей проходят нормально — они обнаруживаются по открытому + расширению TLS SNI, либо по заголовку Host (в случае с plain http). +

+

+ Проверить нахождение домена в белом списке можно с помощью простого + HTTPS-запроса с подменой SNI на адрес из заблокированных диапазонов: +

+ + +$ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.78.7.195 + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed +100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258 + +

+ Также, стоит отметить, что домены белого списка добавляются с маской по + поддоменам: +

+ + +$ curl -k https://ok.ru/100MB.bin -o/dev/null -r 0-65536 --resolve ok.ru:443:5.78.7.195 + % Total % Received % Xferd Average Speed Time Time Time Current + Dload Upload Total Spent Left Speed +100 65537 100 65537 0 0 55226 0 0:00:01 0:00:01 --:--:-- 55258 + + + + Автоматическое сканирование + +

+ Используя методы, указанные выше, мы разработали инструмент для + автоматического сканирования и анализа блокировок + + + Cheburcheck Reporter + + + . +

+

+ Исходя из анализа 1,000,000 доменов из рейтинга + + Tranco list от 26 ноября 2025 + + , в белом списке содержится около 1000 доменов. +

+ + * - Мы не включаем в это число домены из зоны .co.uk, так как по какой-то + причине, они все находятся в белом списке. + + + + Гистограмма количества доменов относительно их положения в рейтинге + (топ-30k, без .co.uk) + + {#if histogramError} + Не удалось загрузить данные гистограммы. + {/if} + + + + + + Гистограмма количества доменов относительно их положения в рейтинге + (топ-1kk, включая .co.uk) + + + + + + Скачать списки +

Мы публикуем результаты наших сканирований в виде CSV-файлов:

+ Полный список (CSV) + Только домены (CSV) +
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css new file mode 100644 index 0000000..a87e9b5 --- /dev/null +++ b/frontend/src/routes/layout.css @@ -0,0 +1,29 @@ +@import "tailwindcss"; +@source "../../node_modules/layerchart/dist"; + +@theme { + --color-surface-100: #171717; + --color-surface-200: #262626; + --color-surface-300: #404040; + --color-surface-content: #e5e5e5; + --color-danger: #ef4444; + --color-primary: 188 86% 53%; +} + +@layer base { + ::-webkit-scrollbar { + width: 8px; + } + + ::-webkit-scrollbar-track { + background: #09090b; + } + + ::-webkit-scrollbar-thumb { + background: #262626; + } + + ::-webkit-scrollbar-thumb:hover { + background: #404040; + } +} diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000..26f225e Binary files /dev/null and b/frontend/static/favicon.png differ diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..e8490ea --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,11 @@ + + + + diff --git a/frontend/static/og-cover.png b/frontend/static/og-cover.png new file mode 100644 index 0000000..79d9691 Binary files /dev/null and b/frontend/static/og-cover.png differ diff --git a/frontend/static/robots.txt b/frontend/static/robots.txt new file mode 100644 index 0000000..b6dd667 --- /dev/null +++ b/frontend/static/robots.txt @@ -0,0 +1,3 @@ +# allow crawling everything by default +User-agent: * +Disallow: diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..6b4ad82 --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,20 @@ +import adapter from "@sveltejs/adapter-static"; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + compilerOptions: { + // Force runes mode for the project, except for libraries. Can be removed in svelte 6. + runes: ({ filename }) => + filename.split(/[/\\]/).includes("node_modules") ? undefined : true, + }, + kit: { + adapter: adapter({ + pages: "dist", + assets: "dist", + fallback: "index.html", + strict: true, + }), + }, +}; + +export default config; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..2c2ed3c --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "rewriteRelativeImportExtensions": true, + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } + // Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias + // except $lib which is handled by https://svelte.dev/docs/kit/configuration#files + // + // To make changes to top-level options such as include and exclude, we recommend extending + // the generated config; see https://svelte.dev/docs/kit/configuration#typescript +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..6b0cbbd --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,14 @@ +import { sveltekit } from "@sveltejs/kit/vite"; +import tailwindcss from "@tailwindcss/vite"; +import { defineConfig } from "vite"; + +export default defineConfig({ + server: { + proxy: { + "/api": { + target: "http://localhost:8000", + }, + }, + }, + plugins: [tailwindcss(), sveltekit()], +}); diff --git a/querying/Cargo.toml b/querying/Cargo.toml index 1625b84..33de5d0 100644 --- a/querying/Cargo.toml +++ b/querying/Cargo.toml @@ -8,7 +8,6 @@ ipnet-trie = "0.3.0" ipnet = "2.12.0" serde = { version = "1", features = ["derive"] } async-trait = "0.1.89" -trie-rs = "0.4.2" tokio = { workspace = true } reqwest = { workspace = true } csv = "1.4.0" diff --git a/querying/src/asn.rs b/querying/src/asn.rs index c9bcf17..8b6ca4c 100644 --- a/querying/src/asn.rs +++ b/querying/src/asn.rs @@ -57,20 +57,22 @@ pub async fn fetch_asn_prefixes(asn: u32) -> Result, AsnError> { "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS{}", asn ); - + let client = reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) .build()?; - + let response = client.get(&url).send().await?; - + if response.status() == 404 { return Err(AsnError::NotFound); } - - let data: RipeStatResponse = response.json().await + + let data: RipeStatResponse = response + .json() + .await .map_err(|e| AsnError::ParseError(e.to_string()))?; - + Ok(data.data.prefixes.into_iter().map(|p| p.prefix).collect()) } @@ -86,7 +88,7 @@ pub async fn fetch_asn_prefixes_cached( let prefixes = fetch_asn_prefixes(asn).await?; cache_result(asn, prefixes.clone()); - + Ok(prefixes) } @@ -107,21 +109,22 @@ impl AsnInfo { } pub fn ipv4_prefixes(&self) -> Vec<&str> { - self.prefixes.iter() + self.prefixes + .iter() .filter(|p| !p.contains(':')) .map(|s| s.as_str()) .collect() } - + pub fn ipv6_prefixes(&self) -> Vec<&str> { - self.prefixes.iter() + self.prefixes + .iter() .filter(|p| p.contains(':')) .map(|s| s.as_str()) .collect() } } - pub struct AsnCache { cache: Arc>>, } diff --git a/querying/src/geoip.rs b/querying/src/geoip.rs index afdc194..8e5d125 100644 --- a/querying/src/geoip.rs +++ b/querying/src/geoip.rs @@ -1,11 +1,11 @@ -use crate::updater::{fetch_db, Updatable}; +use crate::updater::{Updatable, fetch_db}; use async_trait::async_trait; use maxminddb::geoip2::{City, Country}; -use maxminddb::{geoip2, MaxMindDbError}; +use maxminddb::{MaxMindDbError, geoip2}; use serde::Serialize; +use std::io; use std::io::Error; use std::net::IpAddr; -use std::io; pub struct GeoIp { asn: Option>>, @@ -39,7 +39,7 @@ impl GeoIp { GeoIp { asn: None, country: None, - city: None + city: None, } } @@ -49,7 +49,12 @@ impl GeoIp { Ok(geoip) } - pub fn update(&mut self, asn: Vec, country: Vec, city: Vec) -> Result<(), MaxMindDbError> { + pub fn update( + &mut self, + asn: Vec, + country: Vec, + city: Vec, + ) -> Result<(), MaxMindDbError> { self.asn = Some(maxminddb::Reader::from_source(asn)?); self.country = Some(maxminddb::Reader::from_source(country)?); self.city = Some(maxminddb::Reader::from_source(city)?); @@ -59,29 +64,38 @@ impl GeoIp { pub fn lookup(&self, ip: IpAddr) -> Result { let asn = if let Some(db) = &self.asn { db.lookup(ip)?.decode::()? - } else { None }; + } else { + None + }; let city = if let Some(db) = &self.city { db.lookup(ip)?.decode::()? - } else { None }; + } else { + None + }; let country = if let Some(db) = &self.country { db.lookup(ip)?.decode::()? - } else { None }; + } else { + None + }; - let country_code = country.as_ref() + let country_code = country + .as_ref() .map(|c| c.country.iso_code) .flatten() .map(|c| c.to_string()); - let city_geo_name_id = city.as_ref() - .map(|c| c.city.geoname_id) - .flatten(); + let city_geo_name_id = city.as_ref().map(|c| c.city.geoname_id).flatten(); let mut location = (None, None); if let Some(city) = city { location.0 = city.city.names.russian.or(city.city.names.english); } if let Some(country) = country { - location.1 = country.country.names.russian.or(country.country.names.english); + location.1 = country + .country + .names + .russian + .or(country.country.names.english); } let location = match location { @@ -95,9 +109,12 @@ impl GeoIp { location, country_code, city_geo_name_id, - asn: asn.clone().and_then(|asn| asn.autonomous_system_number) + asn: asn + .clone() + .and_then(|asn| asn.autonomous_system_number) .map(|asn| format!("AS{}", asn)), - organisation: asn.and_then(|asn| asn.autonomous_system_organization) + organisation: asn + .and_then(|asn| asn.autonomous_system_organization) .map(|org| org.to_string()), }) } @@ -108,9 +125,19 @@ impl Updatable for GeoIp { type Base = (Vec, Vec, Vec); async fn download() -> Result { - Ok((fetch_db(Self::get_url("GEO_ASN", "https://git.io/GeoLite2-ASN.mmdb")).await?, - fetch_db(Self::get_url("GEO_COUNTRY", "https://git.io/GeoLite2-Country.mmdb")).await?, - fetch_db(Self::get_url("GEO_CITY", "https://git.io/GeoLite2-City.mmdb")).await?)) + Ok(( + fetch_db(Self::get_url("GEO_ASN", "https://git.io/GeoLite2-ASN.mmdb")).await?, + fetch_db(Self::get_url( + "GEO_COUNTRY", + "https://git.io/GeoLite2-Country.mmdb", + )) + .await?, + fetch_db(Self::get_url( + "GEO_CITY", + "https://git.io/GeoLite2-City.mmdb", + )) + .await?, + )) } async fn install(&mut self, (asn, country, city): Self::Base) -> Result<(), Error> { diff --git a/querying/src/lib.rs b/querying/src/lib.rs index 10238e1..1d906df 100644 --- a/querying/src/lib.rs +++ b/querying/src/lib.rs @@ -3,25 +3,25 @@ use crate::lists::{CdnList, NetworkRecord, RuBlacklist}; use crate::resolver::{ResolveError, Resolver}; use crate::target::Target; use crate::updater::Updatable; +use arc_swap::ArcSwap; use chrono::{DateTime, Utc}; use ipnet::IpNet; use log::error; +use maxminddb::MaxMindDbError; use std::collections::{HashMap, HashSet}; use std::io; use std::net::IpAddr; use std::sync::Arc; -use maxminddb::MaxMindDbError; use thiserror::Error; use tokio::sync::watch; -use arc_swap::ArcSwap; pub mod asn; pub mod geoip; pub mod lists; pub mod resolver; -pub mod updater; -pub mod target; pub mod subnet_sampler; +pub mod target; +pub mod updater; pub use subnet_sampler::{sample_ipv4_subnet, sample_ipv6_subnet}; @@ -62,7 +62,11 @@ pub enum CheckError { NotFound, } -pub type Bases = (::Base, ::Base, ::Base); +pub type Bases = ( + ::Base, + ::Base, + ::Base, +); impl Checker { pub async fn new() -> Checker { @@ -91,7 +95,7 @@ impl Checker { Err(e) => { error!("{}", e); return Err(CheckError::ResolveError(e)); - }, + } }; let geo_ip = self.geo_ip.load(); let geo = match ips.get(0).map(|ip| geo_ip.lookup(ip.clone())) { @@ -100,17 +104,22 @@ impl Checker { Some(Err(e)) => { error!("{}", e); return Err(CheckError::GeoIpError); - }, + } }; let mut cdn_provider_subnets: HashMap> = HashMap::new(); let cdn_list = self.cdn_list.load(); ips.iter() .filter_map(|ip| cdn_list.contains(ip)) - .map(|ip| (match &ip.region { - None => ip.provider.clone(), - Some(region) => format!("{} ({})", ip.provider, region), - }, ip.clone())) + .map(|ip| { + ( + match &ip.region { + None => ip.provider.clone(), + Some(region) => format!("{} ({})", ip.provider, region), + }, + ip.clone(), + ) + }) .for_each(|(k, v)| { cdn_provider_subnets.entry(k).or_default().insert(v); }); @@ -118,10 +127,11 @@ impl Checker { let ru_blacklist = self.ru_blacklist.load(); let domain = match &target { Target::Domain(domain) => ru_blacklist.contains_domain(domain), - _ => None + _ => None, }; - let rkn_subnets: HashSet = ips.iter() + let rkn_subnets: HashSet = ips + .iter() .filter_map(|ip| ru_blacklist.contains_ip(ip)) .collect(); @@ -161,14 +171,20 @@ impl Checker { None }; - let asn_has_blocked = asn_info.as_ref() + let asn_has_blocked = asn_info + .as_ref() .map(|info| !info.blocked_prefixes.is_empty()) .unwrap_or(false); let has_blocked_subnets = !rkn_subnets.is_empty(); Ok(Check { - verdict: match (domain, cdn_provider_subnets.is_empty(), asn_has_blocked, has_blocked_subnets) { + verdict: match ( + domain, + cdn_provider_subnets.is_empty(), + asn_has_blocked, + has_blocked_subnets, + ) { (None, true, false, false) => CheckVerdict::Clear, (domain, _, _, _) => CheckVerdict::Blocked { rkn_domain: domain, @@ -187,7 +203,11 @@ impl Checker { } pub async fn download_all() -> Result { - Ok((GeoIp::download().await?, RuBlacklist::download().await?, CdnList::download().await?)) + Ok(( + GeoIp::download().await?, + RuBlacklist::download().await?, + CdnList::download().await?, + )) } pub async fn update_all(&self, (geo_ip_base, ru_blacklist_base, cdn_list_base): Bases) { @@ -199,7 +219,11 @@ impl Checker { } }; - let ru_blacklist = match RuBlacklist::load(ru_blacklist_base.0, ru_blacklist_base.1, ru_blacklist_base.2) { + let ru_blacklist = match RuBlacklist::load( + ru_blacklist_base.0, + ru_blacklist_base.1, + ru_blacklist_base.2, + ) { Ok(ru_blacklist) => Some(ru_blacklist), Err(e) => { error!("Failed to load RKN: {}", e); @@ -235,5 +259,4 @@ impl Checker { pub async fn total_v4s(&self) -> usize { (self.cdn_list.load().v4_count() + self.ru_blacklist.load().v4_count()) as usize } - } diff --git a/querying/src/lists.rs b/querying/src/lists.rs index 223e6a9..4a0871a 100644 --- a/querying/src/lists.rs +++ b/querying/src/lists.rs @@ -1,15 +1,14 @@ -use crate::updater::{fetch_db, Updatable}; +use crate::updater::{Updatable, fetch_db}; use async_trait::async_trait; use ipnet::IpNet; use ipnet_trie::IpnetTrie; use log::info; -use serde::{de, Deserialize, Deserializer, Serializer}; +use serde::{Deserialize, Deserializer, Serializer, de}; use std::collections::VecDeque; use std::io; use std::io::{BufRead, Error, Read}; use std::net::IpAddr; use std::str::FromStr; -use trie_rs::map::{Trie, TrieBuilder}; pub struct CdnList { trie: IpnetTrie, @@ -40,8 +39,10 @@ where } impl CdnList { - pub fn new() -> CdnList{ - CdnList { trie: IpnetTrie::new() } + pub fn new() -> CdnList { + CdnList { + trie: IpnetTrie::new(), + } } pub fn load(list_reader: R) -> Result { @@ -50,7 +51,7 @@ impl CdnList { Ok(list) } - pub fn update(&mut self, list_reader: R) -> Result<(), Error> { + pub fn update(&mut self, list_reader: R) -> Result<(), Error> { let mut trie = IpnetTrie::new(); let mut rdr = csv::Reader::from_reader(list_reader); for result in rdr.deserialize() { @@ -68,7 +69,9 @@ impl CdnList { } pub fn contains(&self, ip: &IpAddr) -> Option { - self.trie.longest_match(&IpNet::from(*ip)).map(|(_, net)| net.clone()) + self.trie + .longest_match(&IpNet::from(*ip)) + .map(|(_, net)| net.clone()) } } @@ -90,7 +93,7 @@ impl Updatable for CdnList { pub struct RuBlacklist { ip_trie: IpnetTrie<()>, - domain_trie: Trie, + blocked_domains: Vec>, pub domain_count: usize, } @@ -98,39 +101,50 @@ impl RuBlacklist { pub fn new() -> RuBlacklist { RuBlacklist { ip_trie: Default::default(), - domain_trie: TrieBuilder::new().build(), - domain_count: 0 + blocked_domains: Vec::new(), + domain_count: 0, } } - pub fn load(ip_reader: R, domain_reader: R, custom_domains_reader: R) -> Result { + pub fn load( + ip_reader: R, + domain_reader: R, + custom_domains_reader: R, + ) -> Result { let mut list = Self::new(); list.update(ip_reader, domain_reader, custom_domains_reader)?; Ok(list) } - pub fn update(&mut self, ip_reader: R, domain_reader: R, custom_domains_reader: R) -> Result<(), Error> { + pub fn update( + &mut self, + ip_reader: R, + domain_reader: R, + custom_domains_reader: R, + ) -> Result<(), Error> { let mut ip_trie = IpnetTrie::new(); for net in ip_reader.lines() { let net = net?; - let net = IpNet::from_str(&net) - .map_err(|e| Error::new(io::ErrorKind::InvalidData, e))?; + let net = + IpNet::from_str(&net).map_err(|e| Error::new(io::ErrorKind::InvalidData, e))?; ip_trie.insert(net, ()); } let (v4, v6) = ip_trie.ip_count(); info!("ip count: v4={}, v6={}", v4, v6); self.ip_trie = ip_trie; - let mut domain_trie = TrieBuilder::new(); + let mut blocked_domains = Vec::new(); let mut count = 0; for domain in domain_reader.lines().chain(custom_domains_reader.lines()) { let domain = domain?; - domain_trie.insert(Self::domain_chunks(&domain), domain); + blocked_domains.push(domain.into_boxed_str()); count += 1; } + blocked_domains.sort_unstable(); + blocked_domains.dedup(); info!("domain count: {}", count); self.domain_count = count; - self.domain_trie = domain_trie.build(); + self.blocked_domains = blocked_domains; Ok(()) } @@ -138,19 +152,57 @@ impl RuBlacklist { self.ip_trie.ip_count().0 } - fn domain_chunks(domain: &str) -> Vec { - domain.split(".").collect::>() - .into_iter().map(|s| s.to_string()) - .rev().collect() - } - pub fn contains_ip(&self, ip: &IpAddr) -> Option { - self.ip_trie.longest_match(&IpNet::from(*ip)).map(|(ip, _)| ip) + self.ip_trie + .longest_match(&IpNet::from(*ip)) + .map(|(ip, _)| ip) } pub fn contains_domain(&self, domain: &str) -> Option { - self.domain_trie.common_prefix_search(Self::domain_chunks(domain)).next() - .map(|(_, b): (Vec<_>, &String)| b).cloned() + let mut suffix = domain; + loop { + if let Ok(index) = self + .blocked_domains + .binary_search_by(|blocked_domain| blocked_domain.as_ref().cmp(suffix)) + { + return Some(self.blocked_domains[index].to_string()); + } + + match suffix.find('.') { + Some(dot) => suffix = &suffix[dot + 1..], + None => return None, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::RuBlacklist; + use std::io::Cursor; + + #[test] + fn domain_lookup_matches_subdomains_on_label_boundaries() { + let list = RuBlacklist::load( + Cursor::new(""), + Cursor::new("blocked.example\n"), + Cursor::new("custom.test\n"), + ) + .unwrap(); + + assert_eq!( + list.contains_domain("blocked.example"), + Some("blocked.example".to_string()) + ); + assert_eq!( + list.contains_domain("www.blocked.example"), + Some("blocked.example".to_string()) + ); + assert_eq!( + list.contains_domain("custom.test"), + Some("custom.test".to_string()) + ); + assert_eq!(list.contains_domain("notblocked.example"), None); } } @@ -159,11 +211,22 @@ impl Updatable for RuBlacklist { type Base = (VecDeque, VecDeque, VecDeque); async fn download() -> Result { - Ok((VecDeque::from( - fetch_db(Self::get_url("RKN_NETS", "https://antifilter.network/download/ipsum.lst")).await?), + Ok(( VecDeque::from( - fetch_db(Self::get_url("RKN_DOMAINS", "https://antifilter.download/list/domains.lst")).await?), - VecDeque::from(include_bytes!("../dist-domains.txt").to_vec()) + fetch_db(Self::get_url( + "RKN_NETS", + "https://antifilter.network/download/ipsum.lst", + )) + .await?, + ), + VecDeque::from( + fetch_db(Self::get_url( + "RKN_DOMAINS", + "https://antifilter.download/list/domains.lst", + )) + .await?, + ), + VecDeque::from(include_bytes!("../dist-domains.txt").to_vec()), )) } diff --git a/querying/src/resolver.rs b/querying/src/resolver.rs index 662e9a6..7549e1b 100644 --- a/querying/src/resolver.rs +++ b/querying/src/resolver.rs @@ -1,6 +1,6 @@ use hickory_resolver::config::{LookupIpStrategy, ResolverConfig, ResolverOpts}; -use hickory_resolver::net::{DnsError, NetError}; use hickory_resolver::net::runtime::TokioRuntimeProvider; +use hickory_resolver::net::{DnsError, NetError}; use hickory_resolver::proto::ProtoError; use std::io::{Error, ErrorKind}; use std::net::IpAddr; @@ -45,23 +45,34 @@ impl Resolver { let config = ResolverConfig::https(&hickory_resolver::config::QUAD9); let mut opts = ResolverOpts::default(); opts.ip_strategy = LookupIpStrategy::Ipv4AndIpv6; - let resolver = hickory_resolver::Resolver::builder_with_config(config, TokioRuntimeProvider::default()) - .with_options(opts) - .build().expect("build resolver"); - Resolver { + let resolver = hickory_resolver::Resolver::builder_with_config( + config, + TokioRuntimeProvider::default(), + ) + .with_options(opts) + .build() + .expect("build resolver"); + Resolver { resolver, asn_cache: Arc::new(AsnCache::new()), } } pub async fn lookup_ips(&self, domain: &str) -> Result, ResolveError> { - Ok(self.resolver.lookup_ip(domain).await + Ok(self + .resolver + .lookup_ip(domain) + .await .map_err(|e| match e { NetError::Dns(DnsError::NoRecordsFound(..)) => ResolveError::NxDomain, - NetError::Proto(ProtoError::Msg(msg)) - if msg.contains("Malformed label") || - msg.contains("invalid characters") => ResolveError::NxDomain, - _ => ResolveError::Other(Error::new(ErrorKind::Other, e)) - })?.iter().collect()) + NetError::Proto(ProtoError::Msg(msg)) + if msg.contains("Malformed label") || msg.contains("invalid characters") => + { + ResolveError::NxDomain + } + _ => ResolveError::Other(Error::new(ErrorKind::Other, e)), + })? + .iter() + .collect()) } } diff --git a/querying/src/subnet_sampler.rs b/querying/src/subnet_sampler.rs index b2112cf..9d51309 100644 --- a/querying/src/subnet_sampler.rs +++ b/querying/src/subnet_sampler.rs @@ -7,15 +7,15 @@ pub fn sample_ipv4_subnet(net: Ipv4Net) -> Vec { } else { let mut ips = Vec::with_capacity(12); ips.push(IpAddr::V4(net.network())); - + let base = u32::from(net.network()); let max = (1u64 << (32 - net.prefix_len())) - 1; - + for i in 1..=10 { let offset = (max * i) / 10; ips.push(IpAddr::V4(Ipv4Addr::from(base + offset as u32))); } - + ips.push(IpAddr::V4(net.broadcast())); ips } @@ -27,7 +27,7 @@ pub fn sample_ipv6_subnet(net: Ipv6Net) -> Vec { } else { let mut ips = Vec::with_capacity(12); ips.push(IpAddr::V6(net.network())); - + let base = u128::from(net.network()); let bits = 128 - net.prefix_len(); let max = if bits >= 64 { @@ -35,12 +35,12 @@ pub fn sample_ipv6_subnet(net: Ipv6Net) -> Vec { } else { (1u128 << bits) - 1 }; - + for i in 1..=10 { let offset = (max * i) / 10; ips.push(IpAddr::V6(Ipv6Addr::from(base + offset))); } - + ips.push(IpAddr::V6(net.broadcast())); ips } diff --git a/querying/src/target.rs b/querying/src/target.rs index c8fe992..e1b8ee2 100644 --- a/querying/src/target.rs +++ b/querying/src/target.rs @@ -15,7 +15,6 @@ pub enum Target { Asn(u32), } - impl From<&str> for Target { fn from(input: &str) -> Self { if input.to_lowercase().starts_with("as") { @@ -32,13 +31,12 @@ impl From<&str> for Target { return Target::Ipv4Subnet(ipv4_net); } } - + if let Ok(ipv6_net) = input.parse::() { if ipv6_net.prefix_len() >= 32 { return Target::Ipv6Subnet(ipv6_net); } } - } if let Ok(ipv4) = input.parse::() { @@ -84,27 +82,27 @@ impl Target { |asn, prefixes| resolver.asn_cache.cache_asn(asn, prefixes), ) .await?; - + if prefixes.is_empty() { return Ok(vec![]); } - + if prefixes.len() > 100 { prefixes.truncate(100); } - + let mut all_ips = Vec::new(); - + for prefix in &prefixes { if let Ok(ipv4_net) = prefix.parse::() { all_ips.extend(sample_ipv4_subnet(ipv4_net)); } else if let Ok(ipv6_net) = prefix.parse::() { all_ips.extend(sample_ipv6_subnet(ipv6_net)); - } + } } - + all_ips - }, + } }) } diff --git a/querying/src/updater.rs b/querying/src/updater.rs index c4d0a2d..98a2f99 100644 --- a/querying/src/updater.rs +++ b/querying/src/updater.rs @@ -9,7 +9,8 @@ use std::io::Error; pub async fn fetch_db(url: T) -> Result, Error> { info!("Fetching {}", url); - let response = reqwest::get(url).await + let response = reqwest::get(url) + .await .map_err(|e| Error::new(io::ErrorKind::Other, e))? .error_for_status() .map_err(|e| Error::new(io::ErrorKind::Other, e))?; diff --git a/reporter/build.rs b/reporter/build.rs index 0728b45..5b66f41 100644 --- a/reporter/build.rs +++ b/reporter/build.rs @@ -1,5 +1,5 @@ -use std::{env, fs, path::Path}; use reqwest::blocking::Client; +use std::{env, fs, path::Path}; fn main() { let out_dir = env::var("OUT_DIR").expect("OUT_DIR not set"); @@ -11,12 +11,17 @@ fn main() { let client = Client::new(); println!("cargo:rerun-if-env-changed=DIST_DOMAIN_COUNT"); - let data = client.get( - format!("https://tranco-list.eu/download/2NPQ9/{}", option_env!("DIST_DOMAIN_COUNT") - .map(|x| x.parse::().expect("DIST_DOMAIN_COUNT is not a number")) - .unwrap_or(1_000_000))) - .send().expect("Failed to send request") - .bytes().expect("Failed to read bytes"); + let data = client + .get(format!( + "https://tranco-list.eu/download/2NPQ9/{}", + option_env!("DIST_DOMAIN_COUNT") + .map(|x| x.parse::().expect("DIST_DOMAIN_COUNT is not a number")) + .unwrap_or(1_000_000) + )) + .send() + .expect("Failed to send request") + .bytes() + .expect("Failed to read bytes"); let list_csv = Path::new(&out_dir).join("list.csv"); fs::write(&list_csv, &data).expect("Failed to write list"); diff --git a/reporter/src/counter.rs b/reporter/src/counter.rs index 4802878..7f925ce 100644 --- a/reporter/src/counter.rs +++ b/reporter/src/counter.rs @@ -1,9 +1,9 @@ +use crate::Verbosity; +use log::info; +use reports::Evidence; use std::collections::HashMap; use std::fmt::Display; use std::path::PathBuf; -use log::info; -use reports::Evidence; -use crate::Verbosity; #[derive(Default)] pub struct Counter { @@ -31,8 +31,12 @@ impl Counter { for (target, evidence) in &self.results { match evidence { Evidence::Ok if verbosity >= &Verbosity::All => println!(" [Ok] {}", target), - Evidence::Blocked if verbosity >= &Verbosity::Block => println!(" [Blocked] {}", target), - Evidence::ConnectError if verbosity >= &Verbosity::Error => println!(" [ConnectError] {}", target), + Evidence::Blocked if verbosity >= &Verbosity::Block => { + println!(" [Blocked] {}", target) + } + Evidence::ConnectError if verbosity >= &Verbosity::Error => { + println!(" [ConnectError] {}", target) + } _ => {} } } @@ -55,12 +59,16 @@ impl Counter { impl Display for Counter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let total = self.total(); - write!(f, "OK {} ({:.2}%) | Blocked {} (early: {}) ({:.2}%) | Error {} ({:.2}%)", - self.ok, - self.ok as f32 / total as f32 * 100.0, - self.block, self.early, - self.block as f32 / total as f32 * 100.0, - self.err, - self.err as f32 / total as f32 * 100.0) + write!( + f, + "OK {} ({:.2}%) | Blocked {} (early: {}) ({:.2}%) | Error {} ({:.2}%)", + self.ok, + self.ok as f32 / total as f32 * 100.0, + self.block, + self.early, + self.block as f32 / total as f32 * 100.0, + self.err, + self.err as f32 / total as f32 * 100.0 + ) } } diff --git a/reporter/src/main.rs b/reporter/src/main.rs index 361a99f..1812c24 100644 --- a/reporter/src/main.rs +++ b/reporter/src/main.rs @@ -1,25 +1,25 @@ -mod resolver; mod counter; +mod resolver; use crate::resolver::Resolver; use anyhow::Result; use clap::{Parser, ValueEnum}; -use futures::stream::FuturesUnordered; +use counter::Counter; use futures::StreamExt; +use futures::stream::FuturesUnordered; use indicatif::{ProgressIterator, ProgressStyle}; -use log::{error, info, warn, LevelFilter}; +use log::{LevelFilter, error, info, warn}; use reports::{AgencyReport, Evidence, ReporterConfig}; -use reqwest::redirect::Policy; use reqwest::Client; +use reqwest::redirect::Policy; use serde::Serialize; use std::collections::HashMap; use std::net::IpAddr; use std::path::PathBuf; -use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::time::Duration; use tokio::time::Instant; -use counter::Counter; const JUNK: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/junk.bin")); @@ -33,7 +33,11 @@ enum Verbosity { } #[derive(Parser, Debug, Clone)] -#[command(author, version, about = "DPI probe: checks blockage of domains by SNI")] +#[command( + author, + version, + about = "DPI probe: checks blockage of domains by SNI" +)] struct Args { /// Output results file #[arg(required = false)] @@ -91,7 +95,6 @@ struct Args { /// Agency endpoint API key #[arg(short, long, env = "AGENCY_KEY")] key: Option, - } impl Args { @@ -129,17 +132,27 @@ async fn main() -> Result<()> { { let file_limit: Option = unsafe { libc::getdtablesize() }.try_into().ok(); if matches!(file_limit, Some(file_limit) if file_limit <= args.probe_count + 128) { - warn!("Open file limit is too low ({})! Consider increasing it using `ulimit -n`.", file_limit.unwrap()); + warn!( + "Open file limit is too low ({})! Consider increasing it using `ulimit -n`.", + file_limit.unwrap() + ); } } let api_client = Client::new(); info!("Loading targets list..."); let targets = include_str!(concat!(env!("OUT_DIR"), "/list.csv")); - let targets: Vec = targets.lines().take(args.count) - .map(|s| s.split(",").last().unwrap().to_string()).collect(); + let targets: Vec = targets + .lines() + .take(args.count) + .map(|s| s.split(",").last().unwrap().to_string()) + .collect(); - info!("Probing {} domains with {} concurrent probes...", targets.len(), args.probe_count); + info!( + "Probing {} domains with {} concurrent probes...", + targets.len(), + args.probe_count + ); let sem = Arc::new(tokio::sync::Semaphore::new(args.probe_count)); let cancelled = wait_for_ctrlc(); let start = Instant::now(); @@ -194,7 +207,11 @@ async fn main() -> Result<()> { counter.save_results(output)?; } - info!("Probed {} domains in {}s! \nSummary: {counter}", counter.total(), start.elapsed().as_secs()); + info!( + "Probed {} domains in {}s! \nSummary: {counter}", + counter.total(), + start.elapsed().as_secs() + ); if let Err(e) = upload_results(&args, &api_client, counter.results).await { warn!("Upload failed: {}", e); } @@ -202,10 +219,15 @@ async fn main() -> Result<()> { Ok(()) } -async fn upload_results(args: &Args, api_client: &Client, results: HashMap) -> Result<()> { +async fn upload_results( + args: &Args, + api_client: &Client, + results: HashMap, +) -> Result<()> { info!("Uploading to {}", args.agency_endpoint); - let uploaded = api_client.post(&args.agency_endpoint) + let uploaded = api_client + .post(&args.agency_endpoint) .header("Content-Type", "application/msgpack") .body(rmp_serde::to_vec(&AgencyReport { version: env!("CARGO_PKG_VERSION").to_string(), @@ -215,7 +237,9 @@ async fn upload_results(args: &Args, api_client: &Client, results: HashMap impl Fn() -> bool { } }); - move || { - cancelled.load(Ordering::SeqCst) != 0 - } + move || cancelled.load(Ordering::SeqCst) != 0 } enum Verdict { @@ -257,19 +279,21 @@ enum Verdict { } async fn check_target(args: &Args, target: &str) -> Result { - let url = format!("http{}://{target}/{}", if args.http {""} else {"s"}, args.path); + let url = format!( + "http{}://{target}/{}", + if args.http { "" } else { "s" }, + args.path + ); let mut attempts = 0; loop { attempts += 1; let client = build_client(&args, 1)?; - let mut resp = client.get(&url) - .header("Range", "bytes=0-65536"); + let mut resp = client.get(&url).header("Range", "bytes=0-65536"); if args.tx { resp = resp.body(JUNK) } - let resp = resp.send() - .await; + let resp = resp.send().await; let resp = match resp { Ok(resp) => match (resp.status(), resp.bytes().await) { @@ -283,7 +307,11 @@ async fn check_target(args: &Args, target: &str) -> Result Result Error: {:?}", target, e); Err(e) } - }, - } + } + }; } } diff --git a/reporter/src/resolver.rs b/reporter/src/resolver.rs index bfd517c..caccebb 100644 --- a/reporter/src/resolver.rs +++ b/reporter/src/resolver.rs @@ -16,8 +16,6 @@ impl Resolver { impl Resolve for Resolver { fn resolve(&self, _: Name) -> Resolving { let ip = self.ip.clone(); - Box::pin(async move { - Ok(Addrs::from(Box::new(vec![ip].into_iter()))) - }) + Box::pin(async move { Ok(Addrs::from(Box::new(vec![ip].into_iter()))) }) } } diff --git a/website.Dockerfile b/website.Dockerfile index 8aeb115..157c48a 100644 --- a/website.Dockerfile +++ b/website.Dockerfile @@ -1,18 +1,39 @@ +# syntax=docker/dockerfile:1.10 + FROM docker.io/rust:1-slim-bookworm AS build WORKDIR /build +ENV PNPM_HOME=/pnpm +ENV PATH="${PNPM_HOME}:${PATH}" + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && \ + apt-get install -y --no-install-recommends ca-certificates curl libssl-dev pkg-config && \ + curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + corepack enable && \ + corepack prepare pnpm@10.33.0 --activate && \ + pnpm config set store-dir /pnpm/store && \ + rm -rf /var/lib/apt/lists/* + +COPY frontend/package.json frontend/pnpm-lock.yaml frontend/pnpm-workspace.yaml ./frontend/ + +RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \ + pnpm --dir frontend fetch --frozen-lockfile + COPY . . -RUN apt update && apt install -y libssl-dev pkg-config - -RUN --mount=type=cache,target=/build/target \ - --mount=type=cache,target=/usr/local/cargo/registry \ - --mount=type=cache,target=/usr/local/cargo/git \ +RUN --mount=type=cache,id=website-target,target=/build/target \ + --mount=type=cache,id=cargo-registry,target=/usr/local/cargo/registry \ + --mount=type=cache,id=cargo-git,target=/usr/local/cargo/git \ + --mount=type=cache,id=pnpm-store,target=/pnpm/store \ set -eux; \ - export SQLX_OFFLINE=true; \ - cargo build --release --package website; \ - objcopy --compress-debug-sections target/release/website ./main + pnpm --dir frontend install --frozen-lockfile --offline; \ + pnpm --dir frontend build; \ + RUSTFLAGS="-C strip=symbols" SKIP_FRONTEND_BUILD=true SQLX_OFFLINE=true cargo build --locked --release --package website; \ + cp target/release/website ./main ################################################################################ @@ -20,19 +41,24 @@ FROM docker.io/debian:bookworm-slim WORKDIR /app -RUN apt update && apt install -y libssl3 ca-certificates curl +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && \ + apt-get install -y --no-install-recommends libssl3 ca-certificates && \ + rm -rf /var/lib/apt/lists/* && \ + groupadd --system app && \ + useradd --system --gid app --home-dir /app --shell /usr/sbin/nologin app COPY --from=build /build/website/Rocket.toml ./ ## copy the main binary COPY --from=build /build/main ./ -COPY --from=build /build/website/static ./static -COPY --from=build /build/website/templates ./templates - ## ensure the container listens globally on port 8080 ENV ROCKET_ADDRESS=:: ENV ROCKET_PORT=8080 EXPOSE 8080 -CMD ./main +USER app + +CMD ["./main"] diff --git a/website/Cargo.toml b/website/Cargo.toml index 86c62b7..11593ef 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "website" -version = "0.4.3" +version = "1.0.0" edition = "2024" [dependencies] @@ -11,6 +11,7 @@ rocket-client-addr = "0.5.4" serde = { workspace = true } reports = { path = "../reports" } querying = { path = "../querying" } +frontend = { path = "../frontend" } env_logger = "0.11.10" rocket-cache-response = "0.6.4" log = { workspace = true } diff --git a/website/build.rs b/website/build.rs index 13716d3..6289c06 100644 --- a/website/build.rs +++ b/website/build.rs @@ -1,79 +1,6 @@ -use std::{ - env, - fs::File, - io::{self, Cursor}, - path::Path, -}; -use std::collections::HashMap; -use std::path::PathBuf; -use flate2::read::GzDecoder; -use reqwest::blocking::Client; -use serde::Deserialize; -use tar::Archive; - -#[derive(Deserialize)] -struct NpmMetadata { - dist: Dist, -} - -#[derive(Deserialize)] -struct Dist { - tarball: String, -} - fn main() -> Result<(), Box> { println!("cargo:rerun-if-changed=migrations"); - let out_dir = PathBuf::from(env::var("OUT_DIR")?); - - println!("cargo:rerun-if-env-changed=LUCIDE_VERSION"); - install_npm("lucide", option_env!("LUCIDE_VERSION"), HashMap::from([ - (PathBuf::from("package/dist/umd/lucide.min.js"), out_dir.join("lucide.js")), - ]))?; - - println!("cargo:rerun-if-env-changed=CHARTJS_VERSION"); - install_npm("chart.js", option_env!("CHARTJS_VERSION"), HashMap::from([ - (PathBuf::from("package/dist/chart.umd.min.js"), out_dir.join("chart.js")), - ]))?; - - println!("cargo:rerun-if-env-changed=CHARTDATALABELS_VERSION"); - install_npm("chartjs-plugin-datalabels", option_env!("CHARTDATALABELS_VERSION"), HashMap::from([ - (PathBuf::from("package/dist/chartjs-plugin-datalabels.min.js"), out_dir.join("chartjs-plugin-datalabels.js")), - ]))?; - println!("cargo:rerun-if-changed=build.rs"); Ok(()) } - -fn install_npm(package: &str, version: Option<&str>, mut entries: HashMap) -> Result<(), Box> { - let client = Client::new(); - let metadata_url = format!( - "https://registry.npmjs.org/{package}/{}", - version.unwrap_or("latest") - ); - - println!("Fetching npm metadata from {}", metadata_url); - let metadata: NpmMetadata = client.get(metadata_url).send()?.json()?; - - println!("Downloading tarball from {}", metadata.dist.tarball); - let tarball_bytes = client.get(&metadata.dist.tarball).send()?.bytes()?; - - let tar = GzDecoder::new(Cursor::new(tarball_bytes)); - let mut archive = Archive::new(tar); - - for entry in archive.entries()? { - let mut entry = entry?; - let path = PathBuf::from(entry.path()?); - if let Some(dest) = entries.remove(&path) { - println!("Found {:?}, extracting to {:?}", path.file_name(), dest); - let mut out_file = File::create(&dest)?; - io::copy(&mut entry, &mut out_file)?; - } - } - - if !entries.is_empty() { - panic!("Not all entries found! {:?}", entries); - } - - Ok(()) -} diff --git a/website/migrations/20260528125659_optimizations.sql b/website/migrations/20260528125659_optimizations.sql new file mode 100644 index 0000000..3644401 --- /dev/null +++ b/website/migrations/20260528125659_optimizations.sql @@ -0,0 +1,61 @@ +ALTER TABLE + queries +ALTER COLUMN + id +SET + DEFAULT uuidv7(); + +CREATE INDEX IF NOT EXISTS reports_reporter_date_id_desc_idx ON reports (reporter, date DESC, id DESC); + +CREATE INDEX IF NOT EXISTS report_row_domain_idx ON report_row (domain); + +DROP MATERIALIZED VIEW IF EXISTS whitelist; + +CREATE MATERIALIZED VIEW whitelist AS WITH report_domains AS ( + SELECT + DISTINCT domain + FROM + report_row +) +SELECT + latest.domain, + d.rank, + MAX( + CASE + WHEN latest.evidence = 'ok' THEN latest.date + END + ) AS last_ok +FROM + report_domains rd + CROSS JOIN LATERAL ( + SELECT + rd.domain, + rr.evidence, + r.date + FROM + reports r + JOIN report_row rr ON rr.report_id = r.id + AND rr.domain = rd.domain + WHERE + r.reporter = 1 + ORDER BY + r.date DESC, + r.id DESC + LIMIT + 5 + ) latest + LEFT JOIN domains d ON d.domain = latest.domain +GROUP BY + latest.domain, + d.rank +HAVING + COUNT(*) FILTER ( + WHERE + latest.evidence = 'ok' + ) >= COUNT(*) / 2.0 +ORDER BY + d.rank; + +CREATE UNIQUE INDEX IF NOT EXISTS whitelist_domain_idx ON whitelist (domain); + +CREATE INDEX IF NOT EXISTS whitelist_rank_idx ON whitelist (rank); \ No newline at end of file diff --git a/website/src/agency.rs b/website/src/agency.rs index 59f2d94..a4d83ea 100644 --- a/website/src/agency.rs +++ b/website/src/agency.rs @@ -1,10 +1,10 @@ use reports::AgencyReport; +use rocket::State; use rocket::http::Status; use rocket::serde::json::serde_json::json; use rocket::serde::json::{Json, Value}; use rocket::serde::msgpack::MsgPack; use rocket_client_addr::ClientRealAddr; -use rocket::State; use sqlx::Acquire; use sqlx::postgres::PgPool; @@ -20,7 +20,10 @@ pub async fn upload_report( agency: Agency, pool: &State, ) -> Result, (Status, String)> { - let mut db = pool.acquire().await.map_err(|e| (Status::InternalServerError, e.to_string()))?; + let mut db = pool + .acquire() + .await + .map_err(|e| (Status::InternalServerError, e.to_string()))?; let mut tx = db .begin() .await diff --git a/website/src/api.rs b/website/src/api.rs index c722504..e30b98e 100644 --- a/website/src/api.rs +++ b/website/src/api.rs @@ -1,4 +1,4 @@ -use crate::db::{check_whitelist, save_query, WhitelistedEntry}; +use crate::db::{WhitelistedEntry, check_whitelist, save_query}; use governor::clock::DefaultClock; use governor::state::keyed::DefaultKeyedStateStore; use governor::{Quota, RateLimiter}; @@ -8,20 +8,21 @@ use querying::geoip::IpInfo; use querying::lists::NetworkRecord; use querying::target::Target; use querying::{Check, CheckError, CheckVerdict, Checker}; +use rocket::State; use rocket::http::Status; use rocket::serde::json::Json; use rocket::tokio::sync::RwLock; -use rocket::State; use rocket_client_addr::ClientRealAddr; use serde::Serialize; use sqlx::postgres::PgPool; +use sqlx::types::Uuid; +use sqlx::types::chrono::{DateTime, Utc}; use std::collections::HashMap; use std::net::IpAddr; use std::num::NonZeroU32; use std::sync::Arc; -pub type ApiRateLimiter = - RateLimiter, DefaultClock>; +pub type ApiRateLimiter = RateLimiter, DefaultClock>; pub fn build_rate_limiter(per_minute: u32) -> ApiRateLimiter { RateLimiter::keyed(Quota::per_minute( @@ -45,6 +46,8 @@ pub struct ApiCheckResponse { pub asn_info: Option, #[serde(skip_serializing_if = "Option::is_none")] pub whitelist: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subnet_size: Option, } fn build_response( @@ -79,6 +82,7 @@ fn build_response( geo: check.geo, asn_info: check.asn_info, whitelist, + subnet_size: target.subnet_size(), } } @@ -131,3 +135,51 @@ pub async fn check( } } } + +#[get("/healthcheck")] +pub async fn healthcheck(checker: &State>>) -> (Status, String) { + if checker.read().await.last_update().is_some() { + (Status::Ok, "OK".to_string()) + } else { + (Status::InternalServerError, "LOADING DATABASES".to_string()) + } +} + +#[post("/feedback//")] +pub async fn feedback( + uuid: &str, + works: bool, + pool: &State, + addr: &ClientRealAddr, +) -> Result<(), Status> { + sqlx::query!( + "INSERT INTO human_reports (id, source_ip, works) VALUES ($1, $2, $3)", + Uuid::try_parse(uuid).map_err(|_| Status::BadRequest)?, + addr.ip.to_string(), + works + ) + .execute(&**pool) + .await + .map_err(|_| Status::InternalServerError)?; + + Ok(()) +} + +#[derive(Serialize)] +pub struct ApiStatusResponse { + domain_count: usize, + v4_count: usize, + last_update: Option>, + version: &'static str, +} + +#[get("/status")] +pub async fn get_system_status(checker: &State>>) -> Json { + let checker_ref = checker.read().await; + Json(ApiStatusResponse { + domain_count: checker_ref.total_domains().await, + v4_count: checker_ref.total_v4s().await, + last_update: checker_ref.last_update(), + version: env!("CARGO_PKG_VERSION"), + }) +} diff --git a/website/src/db.rs b/website/src/db.rs index db65890..91d9599 100644 --- a/website/src/db.rs +++ b/website/src/db.rs @@ -2,14 +2,14 @@ use crate::agency::Agency; use querying::target::Target; use querying::{Check, CheckVerdict, Checker}; use rocket::http::Status; -use rocket::outcome::{try_outcome, IntoOutcome}; +use rocket::outcome::{IntoOutcome, try_outcome}; use rocket::request::{FromRequest, Outcome}; use rocket::tokio::sync::RwLockReadGuard; use rocket::{Request, State}; use rocket_client_addr::ClientRealAddr; use serde::Serialize; -use sqlx::types::chrono::NaiveDateTime; use sqlx::types::Uuid; +use sqlx::types::chrono::NaiveDateTime; pub async fn save_query( db: &mut sqlx::PgConnection, @@ -40,12 +40,14 @@ pub async fn save_query( let (resolved_ips, cdn_networks) = match target { Target::Asn(_) => (vec![], vec![]), - _ => (check - .ips - .iter() - .map(|i| i.to_string()) - .collect::>(), - cdn_networks) + _ => ( + check + .ips + .iter() + .map(|i| i.to_string()) + .collect::>(), + cdn_networks, + ), }; let id = sqlx::query_scalar( @@ -93,11 +95,12 @@ impl<'r> FromRequest<'r> for Agency { async fn from_request(request: &'r Request<'_>) -> Outcome { let pool = request.guard::<&State>().await.unwrap(); - let mut db = try_outcome!(pool - .acquire() - .await - .map_err(|e| Some(e)) - .or_forward(Status::InternalServerError)); + let mut db = try_outcome!( + pool.acquire() + .await + .map_err(|e| Some(e)) + .or_forward(Status::InternalServerError) + ); let token = request.headers().get_one("Authorization"); let token = try_outcome!( @@ -166,7 +169,6 @@ pub async fn collect_histogram( limit: i32, filter: bool, ) -> Result, sqlx::Error> { - sqlx::query_as!( WhitelistHistogramBin, "WITH bins AS ( @@ -181,7 +183,10 @@ FROM bins b LEFT JOIN whitelist w ON FLOOR(w.rank / $2) = b.bin GROUP BY b.bin -ORDER BY b.bin;", bins, limit / bins, filter +ORDER BY b.bin;", + bins, + limit / bins, + filter ) .fetch_all(db) .await diff --git a/website/src/main.rs b/website/src/main.rs index 64e8e53..ccba29a 100644 --- a/website/src/main.rs +++ b/website/src/main.rs @@ -5,205 +5,21 @@ mod api; mod db; mod whitelist; -use crate::db::{check_whitelist, save_query, WhitelistedEntry}; -use log::{error, info, warn, LevelFilter}; -use querying::target::Target; -use querying::{Check, CheckError, CheckVerdict, Checker}; +use env_logger::Env; +use log::{LevelFilter, error, info}; +use querying::Checker; use rocket::fairing::AdHoc; -use rocket::fs::FileServer; -use rocket::http::Status; -use rocket::response::content::RawJavaScript; +use rocket::http::{ContentType, Status}; +use rocket::response::content::RawHtml; +use rocket::serde::json::Json; use rocket::tokio::sync::RwLock; use rocket::tokio::time; -use rocket::{fairing, tokio, Build, Request, Rocket, State}; -use rocket_cache_response::CacheResponse; -use rocket_client_addr::ClientRealAddr; -use rocket_dyn_templates::{context, Metadata, Template}; +use rocket::{Build, Request, Rocket, fairing, tokio}; use serde::Serialize; +use sqlx::postgres::PgPool; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use env_logger::Env; -use rocket::serde::json::Json; -use sqlx::types::Uuid; -use sqlx::postgres::PgPool; - -#[derive(Serialize)] -struct GlobalContext { - version: &'static str, -} - -impl GlobalContext { - fn new() -> Self { - GlobalContext { - version: env!("CARGO_PKG_VERSION"), - } - } -} - -#[get("/")] -async fn index(checker: &State>>) -> Template { - let checker_ref = checker.read().await; - Template::render( - "index", - context! { - global: GlobalContext::new(), - domain_count: format_number(checker_ref.total_domains().await), - v4_count: format_number(checker_ref.total_v4s().await), - last_update: checker_ref.last_update(), - }, - ) -} - -#[get("/kb/")] -fn page(metadata: Metadata, page: &str) -> Option