From c1107147b463e2cad96658ee56b6143845dbd99a Mon Sep 17 00:00:00 2001 From: Sunil Thakares Date: Thu, 11 Jun 2026 20:19:03 +0530 Subject: [PATCH] Initial public release --- .env.example | 19 + .gitignore | 7 + Cargo.lock | 2770 +++++++++++++++++++++++++++++++++ Cargo.toml | 30 + Dockerfile | 67 + README.md | 284 ++++ bzod.service | 27 + deploy.sh | 147 ++ docker-compose.yml | 28 + src/analytics/aggregate.rs | 140 ++ src/analytics/events.rs | 48 + src/analytics/location.rs | 19 + src/analytics/mod.rs | 10 + src/analytics/queue.rs | 29 + src/analytics/worker.rs | 56 + src/auth/csrf.rs | 15 + src/auth/middleware.rs | 34 + src/auth/mod.rs | 9 + src/auth/password.rs | 31 + src/auth/session.rs | 80 + src/charts/bar.rs | 66 + src/charts/line.rs | 106 ++ src/charts/mod.rs | 10 + src/charts/pie.rs | 8 + src/charts/svg.rs | 25 + src/charts/timeseries.rs | 9 + src/cli/backup.rs | 23 + src/cli/create_admin.rs | 46 + src/cli/doctor.rs | 76 + src/cli/migrate.rs | 24 + src/cli/mod.rs | 75 + src/cli/restore.rs | 45 + src/cli/serve.rs | 75 + src/cli/stats.rs | 49 + src/cli/validate.rs | 26 + src/config.rs | 148 ++ src/db/admin.rs | 257 +++ src/db/analytics.rs | 472 ++++++ src/db/content.rs | 384 +++++ src/db/migrations.rs | 294 ++++ src/db/mod.rs | 126 ++ src/db/sqlite.rs | 191 +++ src/error.rs | 153 ++ src/jobs/aggregate.rs | 53 + src/jobs/backup.rs | 81 + src/jobs/healthcheck.rs | 94 ++ src/jobs/mod.rs | 35 + src/jobs/retention.rs | 32 + src/lib.rs | 14 + src/main.rs | 44 + src/models/api_key.rs | 11 + src/models/audit.rs | 13 + src/models/mod.rs | 13 + src/models/page.rs | 13 + src/models/url.rs | 14 + src/models/user.rs | 17 + src/models/visit.rs | 25 + src/services/api_keys.rs | 14 + src/services/audit.rs | 25 + src/services/landing_pages.rs | 22 + src/services/mod.rs | 4 + src/services/shortener.rs | 22 + src/state.rs | 28 + src/templates/dashboard.rs | 29 + src/templates/mod.rs | 33 + src/templates/pages.rs | 24 + src/templates/settings.rs | 26 + src/templates/stats.rs | 44 + src/templates/urls.rs | 25 + src/utils/hashing.rs | 8 + src/utils/mod.rs | 11 + src/utils/network.rs | 24 + src/utils/random.rs | 8 + src/utils/system.rs | 40 + src/utils/time.rs | 12 + src/web/admin.rs | 911 +++++++++++ src/web/api.rs | 413 +++++ src/web/middleware.rs | 1 + src/web/mod.rs | 9 + src/web/pages.rs | 76 + src/web/redirect.rs | 73 + src/web/routes.rs | 55 + src/web/system.rs | 172 ++ templates/audit.html | 71 + templates/dashboard.html | 80 + templates/layout.html | 441 ++++++ templates/login.html | 174 +++ templates/pages.html | 137 ++ templates/settings.html | 188 +++ templates/status_ui.html | 69 + templates/urls.html | 146 ++ tests/security_tests.rs | 150 ++ 92 files changed, 10562 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bzod.service create mode 100755 deploy.sh create mode 100644 docker-compose.yml create mode 100644 src/analytics/aggregate.rs create mode 100644 src/analytics/events.rs create mode 100644 src/analytics/location.rs create mode 100644 src/analytics/mod.rs create mode 100644 src/analytics/queue.rs create mode 100644 src/analytics/worker.rs create mode 100644 src/auth/csrf.rs create mode 100644 src/auth/middleware.rs create mode 100644 src/auth/mod.rs create mode 100644 src/auth/password.rs create mode 100644 src/auth/session.rs create mode 100644 src/charts/bar.rs create mode 100644 src/charts/line.rs create mode 100644 src/charts/mod.rs create mode 100644 src/charts/pie.rs create mode 100644 src/charts/svg.rs create mode 100644 src/charts/timeseries.rs create mode 100644 src/cli/backup.rs create mode 100644 src/cli/create_admin.rs create mode 100644 src/cli/doctor.rs create mode 100644 src/cli/migrate.rs create mode 100644 src/cli/mod.rs create mode 100644 src/cli/restore.rs create mode 100644 src/cli/serve.rs create mode 100644 src/cli/stats.rs create mode 100644 src/cli/validate.rs create mode 100644 src/config.rs create mode 100644 src/db/admin.rs create mode 100644 src/db/analytics.rs create mode 100644 src/db/content.rs create mode 100644 src/db/migrations.rs create mode 100644 src/db/mod.rs create mode 100644 src/db/sqlite.rs create mode 100644 src/error.rs create mode 100644 src/jobs/aggregate.rs create mode 100644 src/jobs/backup.rs create mode 100644 src/jobs/healthcheck.rs create mode 100644 src/jobs/mod.rs create mode 100644 src/jobs/retention.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/models/api_key.rs create mode 100644 src/models/audit.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/page.rs create mode 100644 src/models/url.rs create mode 100644 src/models/user.rs create mode 100644 src/models/visit.rs create mode 100644 src/services/api_keys.rs create mode 100644 src/services/audit.rs create mode 100644 src/services/landing_pages.rs create mode 100644 src/services/mod.rs create mode 100644 src/services/shortener.rs create mode 100644 src/state.rs create mode 100644 src/templates/dashboard.rs create mode 100644 src/templates/mod.rs create mode 100644 src/templates/pages.rs create mode 100644 src/templates/settings.rs create mode 100644 src/templates/stats.rs create mode 100644 src/templates/urls.rs create mode 100644 src/utils/hashing.rs create mode 100644 src/utils/mod.rs create mode 100644 src/utils/network.rs create mode 100644 src/utils/random.rs create mode 100644 src/utils/system.rs create mode 100644 src/utils/time.rs create mode 100644 src/web/admin.rs create mode 100644 src/web/api.rs create mode 100644 src/web/middleware.rs create mode 100644 src/web/mod.rs create mode 100644 src/web/pages.rs create mode 100644 src/web/redirect.rs create mode 100644 src/web/routes.rs create mode 100644 src/web/system.rs create mode 100644 templates/audit.html create mode 100644 templates/dashboard.html create mode 100644 templates/layout.html create mode 100644 templates/login.html create mode 100644 templates/pages.html create mode 100644 templates/settings.html create mode 100644 templates/status_ui.html create mode 100644 templates/urls.html create mode 100644 tests/security_tests.rs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..720b00f --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# BZOD Platform Configuration +HOST=0.0.0.0 +PORT=8080 +DATA_DIR=./data + +# Security Settings +COOKIE_SECURE=false +SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it + +# Bootstrap Admin Credentials +# Default username: admin +# Default password: admin +ADMIN_USERNAME=admin +ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 + +# Cron & Cleaner Intervals (in minutes) +LINK_CHECK_INTERVAL_MINS=60 +AGGREGATION_INTERVAL_MINS=60 +DATA_RETENTION_DAYS=365 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06176d0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +data/ +*.db +*.db-wal +*.db-shm +.env + diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..7460876 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2770 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "askama" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28" +dependencies = [ + "askama_derive", + "askama_escape", + "humansize", + "num-traits", + "percent-encoding", +] + +[[package]] +name = "askama_derive" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83" +dependencies = [ + "askama_parser", + "basic-toml", + "mime", + "mime_guess", + "proc-macro2", + "quote", + "serde", + "syn", +] + +[[package]] +name = "askama_escape" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341" + +[[package]] +name = "askama_parser" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" +dependencies = [ + "nom", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c794b30c904f0a1c2fb7740f7df7f7972dfaa14ef6f57cb6178dc63e5dca2f04" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "fastrand", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "multer", + "pin-project-lite", + "serde", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "basic-toml" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" +dependencies = [ + "serde", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "bzod" +version = "0.1.0" +dependencies = [ + "argon2", + "askama", + "axum", + "axum-extra", + "chrono", + "clap", + "dotenvy", + "flate2", + "hex", + "rand 0.8.6", + "reqwest", + "rusqlite", + "serde", + "serde_json", + "sha2", + "tar", + "time", + "tokio", + "toml", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "cc" +version = "1.2.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humansize" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" +dependencies = [ + "libm", +] + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.123" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6a2ae2f --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "bzod" +version = "0.1.0" +edition = "2021" + +[dependencies] +tokio = { version = "1", features = ["full"] } +axum = { version = "0.7", features = ["macros"] } +axum-extra = { version = "0.9", features = ["cookie"] } +rusqlite = { version = "0.31", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +uuid = { version = "1.8", features = ["v4", "serde"] } +clap = { version = "4.5", features = ["derive", "env"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" +askama = { version = "0.12" } +argon2 = "0.5" +sha2 = "0.10" +rand = "0.8" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } +tar = "0.4" +flate2 = "1.0" +chrono = { version = "0.4", features = ["serde"] } +hex = "0.4" +time = "0.3" +toml = "0.8" + + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0a5c78e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,67 @@ +# ========================================== +# Stage 1: Build +# ========================================== +FROM rust:1.82-slim-bookworm AS builder + +WORKDIR /app + +# Install build dependencies +RUN apt-get update && apt-get install -y \ + pkg-config \ + libssl-dev \ + git \ + && rm -rf /var/lib/apt/lists/* + +# Copy configuration files +COPY Cargo.toml ./ + +# Pre-build dependencies to cache them +RUN mkdir src && echo "fn main() {}" > src/main.rs +RUN cargo build --release +RUN rm -rf src + +# Copy source and templates +COPY src ./src +COPY templates ./templates + +# Trigger rebuilding with actual source +RUN touch src/main.rs +RUN cargo build --release + +# ========================================== +# Stage 2: Runner +# ========================================== +FROM debian:bookworm-slim + +WORKDIR /app + +# Install runtime dependencies +RUN apt-get update && apt-get install -y \ + openssl \ + ca-certificates \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy binary from builder +COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod + +# Create non-root user and data directory +RUN groupadd -g 10001 bzod && \ + useradd -u 10001 -g bzod -m -s /bin/bash bzod + +RUN mkdir -p /app/data && chown -R bzod:bzod /app/data + +USER bzod + +ENV DATA_DIR=/app/data +ENV PORT=8080 +ENV HOST=0.0.0.0 +ENV COOKIE_SECURE=true + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1 + +ENTRYPOINT ["bzod"] +CMD ["serve"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..b319c2b --- /dev/null +++ b/README.md @@ -0,0 +1,284 @@ +# nx9-url-shortner + +A lightweight, self-hosted URL shortener and landing page platform written in Rust. + +`nx9-url-shortner` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services. + +Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint. + +--- + +## Features + +### URL Shortening + +Create short links using compact hexadecimal identifiers. + +Example: + +```text +https://bzo.in/1bb170 +``` + +Redirects to: + +```text +https://github.com/thakares/chronoseal-rs +``` + +--- + +### Landing Pages + +Create standalone landing pages using dedicated page identifiers. + +Example: + +```text +https://bzo.in/p/1a2b +``` + +--- + +### Analytics + +Track: + +* Total visits +* Country statistics +* Referrers +* User agents +* Daily statistics +* Monthly statistics +* Yearly statistics + +--- + +### Administrative Dashboard + +Web-based administration interface featuring: + +* URL management +* Landing page management +* API token management +* Audit logs +* Health checks +* Analytics dashboard +* SVG charts + +--- + +### API Support + +REST API endpoints for automation and integration. + +```text +/api/v1/* +``` + +--- + +### Security + +* Password-protected administration interface +* Session management +* CSRF protection +* API token authentication +* Audit logging + +--- + +### Self-Hosted + +No external services required. + +Dependencies: + +* Rust +* SQLite +* Docker (optional) + +No: + +* React +* Node.js +* Redis +* Kubernetes +* External databases + +--- + +## Architecture + +### Databases + +The application uses four SQLite databases. + +| Database | Purpose | +| ------------ | ---------------------------------------- | +| admin.db | Users, sessions, API keys, audit logs | +| content.db | URLs, landing pages, tags | +| analytics.db | Visits and statistics | +| system.db | Jobs, migrations, backups, health checks | + +--- + +## Default Credentials + +Initial login: + +```text +Username: admin +Password: admin +``` + +Change the password immediately after first login. + +--- + +## Docker Deployment + +### Build + +```bash +docker compose build +``` + +### Start + +```bash +docker compose up -d +``` + +### View Logs + +```bash +docker logs -f bzod +``` + +--- + +## Docker Compose Example + +```yaml +services: + bzod: + build: . + container_name: bzod + restart: unless-stopped + + ports: + - "8654:8654" + + volumes: + - ./data:/app/data + - ./config:/app/config + + environment: + HOST: 0.0.0.0 + PORT: 8654 + DATA_DIR: /app/data +``` + +--- + +## Development + +### Build + +```bash +cargo build +``` + +### Run + +```bash +cargo run -- serve +``` + +### Run Migrations + +```bash +cargo run -- migrate +``` + +### Create Admin User + +```bash +cargo run -- create-admin +``` + +### Run Tests + +```bash +cargo test +``` + +--- + +## Project Structure + +```text +src/ +├── analytics/ +├── auth/ +├── charts/ +├── cli/ +├── db/ +├── jobs/ +├── models/ +├── services/ +├── templates/ +├── utils/ +└── web/ +``` + +--- + +## Roadmap + +Planned features: + +* QR code generation +* Link expiration +* Link disabling +* CSV exports +* Bulk URL import +* GeoIP integration +* Multi-user administration +* SSO support + +--- + +## Production Deployment + +Recommended stack: + +```text +Internet + │ + ▼ +Nginx Proxy Manager + │ + ▼ +nx9-url-shortner + │ + ▼ +SQLite +``` + +HTTPS is strongly recommended. + +--- + +## License + +Apache License 2.0 + +--- + +## Author + +Sunil Thakare + +Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software. diff --git a/bzod.service b/bzod.service new file mode 100644 index 0000000..95a6bca --- /dev/null +++ b/bzod.service @@ -0,0 +1,27 @@ +[Unit] +Description=BZOD - Personal URL Shortener & Landing Page Platform +After=network.target + +[Service] +Type=simple +User=bzod +Group=bzod +WorkingDirectory=/var/lib/bzod +EnvironmentFile=/etc/bzod/bzod.env +ExecStart=/usr/local/bin/bzod serve --host 0.0.0.0 --port 8080 --data-dir /var/lib/bzod/data +Restart=on-failure +RestartSec=5s + +# Hardening / Sandboxing options for security +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/var/lib/bzod +CapabilityBoundingSet= + +[Install] +WantedBy=multi-user.target diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..75939fd --- /dev/null +++ b/deploy.sh @@ -0,0 +1,147 @@ +#!/usr/bin/env bash + +# BZOD Deployment Script (Debian Native Deployment) +# This script sets up a secure, production-ready systemd service for BZOD. + +set -euo pipefail + +# Configurations +SERVICE_USER="bzod" +INSTALL_PATH="/usr/local/bin/bzod" +CONFIG_DIR="/etc/bzod" +DATA_DIR="/var/lib/bzod/data" +ENV_FILE="${CONFIG_DIR}/bzod.env" +SYSTEMD_UNIT="/etc/systemd/system/bzod.service" + +# Color outputs +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}" + +# 1. Check Root Privileges +if [ "$EUID" -ne 0 ]; then + echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}" + exit 1 +fi + +# 2. Install Package Dependencies +echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}" +apt-get update +apt-get install -y openssl sqlite3 ca-certificates curl tar gzip + +# 3. Compile Production Build Locally +echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}" +if ! command -v cargo &> /dev/null; then + echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}" + exit 1 +fi + +cargo build --release +echo -e "${GREEN}Release build completed.${NC}" + +# 4. Install Binary +echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}" +cp target/release/bzod "${INSTALL_PATH}" +chmod 755 "${INSTALL_PATH}" +chown root:root "${INSTALL_PATH}" +echo -e "${GREEN}Binary installed successfully.${NC}" + +# 5. Create Dedicated locked-down System User +echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}" +if ! id -u "${SERVICE_USER}" &>/dev/null; then + useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}" + echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}" +else + echo "User '${SERVICE_USER}' already exists." +fi + +# 6. Configure Directory Trees and Permissions +echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}" +mkdir -p "${CONFIG_DIR}" +mkdir -p "${DATA_DIR}" + +# Copy .env file if it exists, otherwise prompt/generate +if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then + echo "Copying local .env file to ${ENV_FILE}..." + cp .env "${ENV_FILE}" +elif [ ! -f "${ENV_FILE}" ]; then + echo "Generating default configuration file at ${ENV_FILE}..." + cat < "${ENV_FILE}" +HOST=0.0.0.0 +PORT=8080 +DATA_DIR=${DATA_DIR} +COOKIE_SECURE=true +SESSION_SECRET=$(openssl rand -hex 32) +ADMIN_USERNAME=admin +# SHA-256 for bootstrap (Default: admin) +ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 +LINK_CHECK_INTERVAL_MINS=60 +AGGREGATION_INTERVAL_MINS=60 +DATA_RETENTION_DAYS=365 +EOF +fi + +chmod 600 "${ENV_FILE}" +chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}" +chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod +echo -e "${GREEN}Directories and permission parameters configured.${NC}" + +# 7. Initialise DB as the service user (avoids file permission conflicts) +echo -e "\n${BLUE}[6/8] Initialising databases...${NC}" +sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}" +echo -e "${GREEN}Databases initialised.${NC}" + +# 8. Set Up Systemd Service +echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}" +cat < "${SYSTEMD_UNIT}" +[Unit] +Description=BZOD - Personal URL Shortener & Landing Page Platform +After=network.target + +[Service] +Type=simple +User=${SERVICE_USER} +Group=${SERVICE_USER} +WorkingDirectory=/var/lib/bzod +EnvironmentFile=${ENV_FILE} +ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR} +Restart=on-failure +RestartSec=5s + +# Hardening / Sandboxing options for security +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ReadWritePaths=/var/lib/bzod + +[Install] +WantedBy=multi-user.target +EOF + +chmod 644 "${SYSTEMD_UNIT}" +systemctl daemon-reload +echo -e "${GREEN}Systemd service registered.${NC}" + +# 9. Enable and Start the Service +echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}" +systemctl enable bzod +systemctl restart bzod + +sleep 2 +if systemctl is-active --quiet bzod; then + echo -e "${GREEN}BZOD service is running successfully!${NC}" + echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}" + echo -e "You can access BZOD at http://localhost:8080" + echo -e "Admin Login Dashboard is at http://localhost:8080/admin" + echo -e "System service logs: journalctl -u bzod -f" + echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}" +else + echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}" +fi diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..11dab2a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + bzod: + build: + context: . + dockerfile: Dockerfile + + container_name: bzod + + restart: unless-stopped + + ports: + - "8654:8654" + + volumes: + - /DATA/AppData/bzod/data:/app/data + - /DATA/AppData/bzod/config:/app/config + + environment: + HOST: 0.0.0.0 + PORT: 8654 + DATA_DIR: /app/data + COOKIE_SECURE: "false" + + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8654/status"] + interval: 30s + timeout: 5s + retries: 3 diff --git a/src/analytics/aggregate.rs b/src/analytics/aggregate.rs new file mode 100644 index 0000000..b6c1f7f --- /dev/null +++ b/src/analytics/aggregate.rs @@ -0,0 +1,140 @@ +use rusqlite::{Connection, params}; +use std::collections::HashMap; +use crate::db::analytics::{parse_ua, clean_referrer}; + +// Run aggregation for a specific day +pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> { + let mut visits = Vec::new(); + { + // 1. Fetch all visits on that day + let mut stmt = conn.prepare( + "SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;" + )?; + + struct RawVisit { + target_type: String, + target_id: String, + user_agent: String, + referer: String, + country: String, + } + + let rows = stmt.query_map(params![date], |row| { + Ok(RawVisit { + target_type: row.get(0)?, + target_id: row.get(1)?, + user_agent: row.get(2)?, + referer: row.get(3)?, + country: row.get(4)?, + }) + })?; + + for r in rows { + visits.push(r?); + } + } + + if visits.is_empty() { + return Ok(()); + } + + // 2. Compute metrics in-memory + // Key structure: (target_type, target_id, metric_type, metric_key) -> count + let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new(); + + // Also track total per day (all targets combined) using target_id = "all" + for v in visits { + let (browser, os, device) = parse_ua(&v.user_agent); + let referrer = clean_referrer(&v.referer); + let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() }; + + let targets = vec![ + (v.target_type.clone(), v.target_id.clone()), + (v.target_type.clone(), "all".to_string()), + ]; + + for (t_type, t_id) in targets { + // Clicks + *aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1; + + // Country + *aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1; + + // Browser + *aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1; + + // OS + *aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1; + + // Device + *aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1; + + // Referrer + *aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1; + } + } + + // 3. Save to database in a transaction + let tx = conn.transaction()?; + { + // Delete old aggregates for this day + tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?; + + let mut insert_stmt = tx.prepare( + "INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);" + )?; + + for ((t_type, t_id, m_type, m_key), value) in aggregates { + insert_stmt.execute(params![ + date, + t_type, + t_id, + m_type, + m_key, + value + ])?; + } + } + tx.commit()?; + + // Update monthly and yearly summaries using the daily summaries + aggregate_month_from_daily(conn, &date[0..7])?; + aggregate_year_from_daily(conn, &date[0..4])?; + + Ok(()) +} + +pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?; + tx.execute( + "INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value) + SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value) + FROM daily_summaries + WHERE date LIKE ?2 + GROUP BY target_type, target_id, metric_type, metric_key;", + params![year_month, format!("{}-%", year_month)], + )?; + } + tx.commit()?; + Ok(()) +} + +pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?; + tx.execute( + "INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value) + SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value) + FROM daily_summaries + WHERE date LIKE ?2 + GROUP BY target_type, target_id, metric_type, metric_key;", + params![year, format!("{}-%", year)], + )?; + } + tx.commit()?; + Ok(()) +} diff --git a/src/analytics/events.rs b/src/analytics/events.rs new file mode 100644 index 0000000..15d7d32 --- /dev/null +++ b/src/analytics/events.rs @@ -0,0 +1,48 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub enum AnalyticsEvent { + RedirectVisit { + url_id: String, + code: String, + timestamp: String, + ip_address: String, + user_agent: String, + referer: String, + accept_language: String, + country: String, + status_code: u16, + }, + LandingPageVisit { + page_id: String, + code: String, + slug: String, + timestamp: String, + ip_address: String, + user_agent: String, + referer: String, + accept_language: String, + country: String, + status_code: u16, + }, + AdminLogin { + username: String, + timestamp: String, + ip_address: Option, + user_agent: Option, + success: bool, + }, + ApiRequest { + username: String, + endpoint: String, + method: String, + timestamp: String, + ip_address: Option, + status_code: u16, + }, + SystemEvent { + event_type: String, // 'startup', 'shutdown', 'backup', etc. + details: String, + timestamp: String, + }, +} diff --git a/src/analytics/location.rs b/src/analytics/location.rs new file mode 100644 index 0000000..e5aba23 --- /dev/null +++ b/src/analytics/location.rs @@ -0,0 +1,19 @@ +use axum::http::HeaderMap; + +// Guess or extract client country from headers +pub fn get_client_country(headers: &HeaderMap) -> String { + if let Some(country) = headers.get("cf-ipcountry").and_then(|h| h.to_str().ok()) { + return country.to_uppercase(); + } + if let Some(lang) = headers.get("accept-language").and_then(|h| h.to_str().ok()) { + if let Some(dash_idx) = lang.find('-') { + if lang.len() > dash_idx + 2 { + let code = &lang[dash_idx + 1..dash_idx + 3]; + if code.chars().all(|c| c.is_ascii_alphabetic()) { + return code.to_uppercase(); + } + } + } + } + "Unknown".to_string() +} diff --git a/src/analytics/mod.rs b/src/analytics/mod.rs new file mode 100644 index 0000000..5838fde --- /dev/null +++ b/src/analytics/mod.rs @@ -0,0 +1,10 @@ +pub mod queue; +pub mod worker; +pub mod location; +pub mod events; +pub mod aggregate; + +pub use queue::AnalyticsQueue; +pub use location::get_client_country; +pub use events::AnalyticsEvent; +pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily}; diff --git a/src/analytics/queue.rs b/src/analytics/queue.rs new file mode 100644 index 0000000..fcc391e --- /dev/null +++ b/src/analytics/queue.rs @@ -0,0 +1,29 @@ +use tokio::sync::mpsc; +use crate::models::VisitRecord; +use crate::db::Db; + +#[derive(Clone)] +pub struct AnalyticsQueue { + sender: mpsc::Sender, +} + +impl AnalyticsQueue { + pub fn new(db: Db, capacity: usize) -> Self { + let (sender, receiver) = mpsc::channel(capacity); + + // Spawn background worker to batch-write records + tokio::spawn(async move { + super::worker::run_worker(db, receiver).await; + }); + + Self { sender } + } + + // Attempt to queue a visit. Non-blocking. + pub fn push(&self, record: VisitRecord) { + use tracing::error; + if let Err(e) = self.sender.try_send(record) { + error!("Failed to queue analytics record: {:?}", e); + } + } +} diff --git a/src/analytics/worker.rs b/src/analytics/worker.rs new file mode 100644 index 0000000..3c826d9 --- /dev/null +++ b/src/analytics/worker.rs @@ -0,0 +1,56 @@ +use tokio::sync::mpsc; +use tokio::time::{interval, MissedTickBehavior}; +use std::time::Duration; +use tracing::{info, error}; + +use crate::db::Db; +use crate::models::VisitRecord; +use crate::db::analytics::insert_visits_batch; + +pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver) { + let mut batch = Vec::new(); + let batch_size = 50; + let flush_interval = Duration::from_secs(2); + + let mut timer = interval(flush_interval); + timer.set_missed_tick_behavior(MissedTickBehavior::Delay); + + loop { + tokio::select! { + record_opt = receiver.recv() => { + match record_opt { + Some(record) => { + batch.push(record); + if batch.len() >= batch_size { + flush_batch(&db, &mut batch); + } + } + None => { + info!("Analytics channel closed. Flushing remaining records."); + flush_batch(&db, &mut batch); + break; + } + } + } + _ = timer.tick() => { + if !batch.is_empty() { + flush_batch(&db, &mut batch); + } + } + } + } +} + +fn flush_batch(db: &Db, batch: &mut Vec) { + if batch.is_empty() { + return; + } + + info!("Flushing {} visits to analytics database", batch.len()); + let mut conn_lock = db.analytics.lock().unwrap(); + if let Err(e) = insert_visits_batch(&mut conn_lock, batch) { + error!("Failed to write analytics batch to database: {:?}", e); + } else { + batch.clear(); + } +} diff --git a/src/auth/csrf.rs b/src/auth/csrf.rs new file mode 100644 index 0000000..59670d9 --- /dev/null +++ b/src/auth/csrf.rs @@ -0,0 +1,15 @@ +use sha2::{Sha256, Digest}; + +// Deterministic CSRF token derived from session token +pub fn generate_csrf_token(session_id: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(session_id.as_bytes()); + hasher.update(b"csrf-salt-bzod-2026"); + hex::encode(hasher.finalize()) +} + +// Verify CSRF token +pub fn verify_csrf(session_id: &str, submitted_token: &str) -> bool { + let expected = generate_csrf_token(session_id); + expected == submitted_token +} diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..2ba4a12 --- /dev/null +++ b/src/auth/middleware.rs @@ -0,0 +1,34 @@ +use axum::{ + extract::{FromRequestParts, FromRef}, + http::{request::Parts, StatusCode}, +}; +use crate::state::AppState; +use crate::models::User; +use crate::auth::session::authenticate_api_key; + +// Extractor: Authenticate API requests using Bearer token +pub struct ApiUser(pub User); + +#[axum::async_trait] +impl FromRequestParts for ApiUser +where + AppState: FromRef, + S: Send + Sync, +{ + type Rejection = (StatusCode, &'static str); + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let app_state = AppState::from_ref(state); + let auth_header = parts.headers + .get("Authorization") + .and_then(|h| h.to_str().ok()) + .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; + + let conn = app_state.admin_db.lock().unwrap(); + match authenticate_api_key(&conn, auth_header) { + Ok(Some(user)) => Ok(ApiUser(user)), + Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")), + Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")), + } + } +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..942eb4b --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,9 @@ +pub mod password; +pub mod session; +pub mod csrf; +pub mod middleware; + +pub use password::{hash_password, verify_password, verify_sha256}; +pub use session::{generate_token, authenticate_session, authenticate_api_key}; +pub use csrf::{generate_csrf_token, verify_csrf}; +pub use middleware::ApiUser; diff --git a/src/auth/password.rs b/src/auth/password.rs new file mode 100644 index 0000000..c14f3b9 --- /dev/null +++ b/src/auth/password.rs @@ -0,0 +1,31 @@ +use sha2::{Sha256, Digest}; +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +// Hashing password with Argon2id +pub fn hash_password(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string(); + Ok(password_hash) +} + +// Verifying Argon2id password hash +pub fn verify_password(password: &str, hash: &str) -> bool { + if let Ok(parsed_hash) = PasswordHash::new(hash) { + Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() + } else { + false + } +} + +// Verifying SHA-256 bootstrap hash +pub fn verify_sha256(password: &str, expected_hex: &str) -> bool { + let mut hasher = Sha256::new(); + hasher.update(password.as_bytes()); + let result = hasher.finalize(); + let hex_result = hex::encode(result); + hex_result.eq_ignore_ascii_case(expected_hex) +} diff --git a/src/auth/session.rs b/src/auth/session.rs new file mode 100644 index 0000000..75050eb --- /dev/null +++ b/src/auth/session.rs @@ -0,0 +1,80 @@ +use sha2::{Sha256, Digest}; +use rand::{RngCore, thread_rng}; +use axum_extra::extract::CookieJar; +use rusqlite::Connection; +use chrono::Utc; +use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash}; +use crate::models::User; + +// Generate a secure random token (hex-encoded) +pub fn generate_token(bytes_len: usize) -> String { + let mut key = vec![0u8; bytes_len]; + thread_rng().fill_bytes(&mut key); + hex::encode(key) +} + +// Authenticate session from cookies +pub fn authenticate_session( + conn: &Connection, + jar: &CookieJar, +) -> Result, rusqlite::Error> { + let cookie = match jar.get("bzod_session") { + Some(c) => c, + None => return Ok(None), + }; + + let session_id = cookie.value(); + let session = match get_session(conn, session_id)? { + Some(s) => s, + None => return Ok(None), + }; + + // Check expiration + if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) { + if expires.with_timezone(&Utc) < Utc::now() { + // Expired + return Ok(None); + } + } else { + return Ok(None); + } + + // Get user + if let Some(user) = get_user_by_id(conn, &session.user_id)? { + Ok(Some((user, session.id))) + } else { + Ok(None) + } +} + +// Authenticate API key from Authorization header +pub fn authenticate_api_key( + conn: &Connection, + auth_header: &str, +) -> Result, rusqlite::Error> { + if !auth_header.starts_with("Bearer ") { + return Ok(None); + } + + let key = auth_header.trim_start_matches("Bearer ").trim(); + if key.is_empty() { + return Ok(None); + } + + // Hash the API key using SHA-256 to compare with stored hash + let mut hasher = Sha256::new(); + hasher.update(key.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + + if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? { + // Update last used timestamp + update_api_key_last_used(conn, &api_key_rec.id)?; + + // Get user + if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { + return Ok(Some(user)); + } + } + + Ok(None) +} diff --git a/src/charts/bar.rs b/src/charts/bar.rs new file mode 100644 index 0000000..87ca834 --- /dev/null +++ b/src/charts/bar.rs @@ -0,0 +1,66 @@ +use super::svg::{wrap_in_svg, DEFAULT_TEXT_COLOR}; + +pub fn generate_bar_chart(data: &[(String, i64)]) -> String { + if data.is_empty() { + return r##" + No data available + "##.to_string(); + } + + let bar_height = 24.0; + let gap = 14.0; + let pad_top = 10.0; + let pad_bottom = 10.0; + let pad_left = 130.0; + let pad_right = 70.0; + let width = 600.0; + + let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap; + let chart_w = width - pad_left - pad_right; + + let max_val = data.iter().map(|(_, v)| *v).max().unwrap_or(0); + let max_x = if max_val == 0 { 1.0 } else { max_val as f64 }; + + let mut bars = String::new(); + let total_count: i64 = data.iter().map(|(_, v)| *v).sum(); + + for (i, (label, val)) in data.iter().enumerate() { + let y = pad_top + (i as f64 * (bar_height + gap)); + let bar_w = (*val as f64 / max_x) * chart_w; + + let pct = if total_count > 0 { + (*val as f64 / total_count as f64) * 100.0 + } else { + 0.0 + }; + + // Truncate long labels + let display_label = if label.len() > 18 { + format!("{}...", &label[0..15]) + } else { + label.to_string() + }; + + bars.push_str(&format!( + r##" + {label} + + {val} ({pct:.1}%) + "##, + i = i, + pad_left_label = pad_left - 10.0, + text_y = y + (bar_height / 2.0) + 1.0, + label = display_label, + pad_left = pad_left, + y = y, + bar_w = bar_w.max(2.0), + bar_height = bar_height, + val_x = pad_left + bar_w.max(2.0) + 8.0, + val = val, + pct = pct, + text_color = DEFAULT_TEXT_COLOR + )); + } + + wrap_in_svg(width, height, &bars) +} diff --git a/src/charts/line.rs b/src/charts/line.rs new file mode 100644 index 0000000..5003b48 --- /dev/null +++ b/src/charts/line.rs @@ -0,0 +1,106 @@ +use super::svg::{wrap_in_svg, DEFAULT_GRID_COLOR, DEFAULT_TEXT_COLOR}; + +pub fn generate_line_chart(data: &[(String, i64)]) -> String { + if data.is_empty() { + return r##" + No traffic data available + "##.to_string(); + } + + let width = 800.0; + let height = 300.0; + let pad_left = 60.0; + let pad_right = 30.0; + let pad_top = 30.0; + let pad_bottom = 40.0; + + let chart_w = width - pad_left - pad_right; + let chart_h = height - pad_top - pad_bottom; + + // Find max value for scaling + let max_val = data.iter().map(|(_, v)| *v).max().unwrap_or(0); + let max_y = if max_val == 0 { 10.0 } else { max_val as f64 }; + + // Y-axis grid ticks + let mut inner_content = String::new(); + let ticks = 4; + for i in 0..=ticks { + let pct = i as f64 / ticks as f64; + let y = pad_top + chart_h - (pct * chart_h); + let val = (pct * max_y).round() as i64; + inner_content.push_str(&format!( + r##" + {}"##, + pad_left, y, width - pad_right, y, DEFAULT_GRID_COLOR, pad_left - 10.0, y, DEFAULT_TEXT_COLOR, val + )); + } + + // Coordinates calculations + let count = data.len(); + let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w }; + + let mut points = Vec::new(); + for (i, &(_, val)) in data.iter().enumerate() { + let x = pad_left + (i as f64 * step_x); + let y = pad_top + chart_h - ((val as f64 / max_y) * chart_h); + points.push((x, y)); + } + + // Path strings + let mut line_path = String::new(); + let mut area_path = String::new(); + + if !points.is_empty() { + line_path.push_str(&format!("M {:.1} {:.1}", points[0].0, points[0].1)); + area_path.push_str(&format!("M {:.1} {:.1}", points[0].0, pad_top + chart_h)); + area_path.push_str(&format!("L {:.1} {:.1}", points[0].0, points[0].1)); + + for &(x, y) in points.iter().skip(1) { + line_path.push_str(&format!(" L {:.1} {:.1}", x, y)); + area_path.push_str(&format!(" L {:.1} {:.1}", x, y)); + } + + let last_x = points[points.len() - 1].0; + area_path.push_str(&format!(" L {:.1} {:.1} Z", last_x, pad_top + chart_h)); + } + + // X-axis labels + let mut x_labels = String::new(); + let label_step = (count / 7).max(1); + for (i, (label, _)) in data.iter().enumerate() { + if i % label_step == 0 || i == count - 1 { + let x = points[i].0; + let short_label = if label.len() == 10 { &label[5..] } else { label }; + x_labels.push_str(&format!( + r##"{}"##, + x, height - 15.0, DEFAULT_TEXT_COLOR, short_label + )); + + x_labels.push_str(&format!( + r##""##, + x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR + )); + } + } + + // Draw little circles on points + let mut dots = String::new(); + if count < 40 { + for &(x, y) in &points { + dots.push_str(&format!( + r##""##, + x, y + )); + } + } + + inner_content.push_str(&format!( + r##" + + {} + {}"##, + area_path, line_path, dots, x_labels + )); + + wrap_in_svg(width, height, &inner_content) +} diff --git a/src/charts/mod.rs b/src/charts/mod.rs new file mode 100644 index 0000000..5a3ebdb --- /dev/null +++ b/src/charts/mod.rs @@ -0,0 +1,10 @@ +pub mod svg; +pub mod line; +pub mod bar; +pub mod pie; +pub mod timeseries; + +pub use line::generate_line_chart; +pub use bar::generate_bar_chart; +pub use pie::generate_pie_chart; +pub use timeseries::generate_timeseries_chart; diff --git a/src/charts/pie.rs b/src/charts/pie.rs new file mode 100644 index 0000000..e1e5422 --- /dev/null +++ b/src/charts/pie.rs @@ -0,0 +1,8 @@ +// Donut / Pie SVG Chart Renderer (Placeholder / Stub) + +pub fn generate_pie_chart(_data: &[(String, i64)]) -> String { + r##" + + Donut Chart Placeholder + "##.to_string() +} diff --git a/src/charts/svg.rs b/src/charts/svg.rs new file mode 100644 index 0000000..354fea9 --- /dev/null +++ b/src/charts/svg.rs @@ -0,0 +1,25 @@ +pub const DEFAULT_GRID_COLOR: &str = "#334155"; +pub const DEFAULT_TEXT_COLOR: &str = "#94a3b8"; + +pub fn wrap_in_svg(width: f64, height: f64, content: &str) -> String { + // Note: raw string literals with # inside double-quotes can break standard parsing, + // so we use r##"..."## formatting to prevent compiler errors. + format!( + r##" + + + + + + + + + + + {content} + "##, + width = width, + height = height, + content = content + ) +} diff --git a/src/charts/timeseries.rs b/src/charts/timeseries.rs new file mode 100644 index 0000000..7c5667c --- /dev/null +++ b/src/charts/timeseries.rs @@ -0,0 +1,9 @@ +use super::line::generate_line_chart; + +/// Generates a timeseries traffic trend SVG chart. +/// +/// This provides a specialized wrapper around line charts, specifically +/// configured for timeseries data streams (daily/monthly traffic logs). +pub fn generate_timeseries_chart(data: &[(String, i64)]) -> String { + generate_line_chart(data) +} diff --git a/src/cli/backup.rs b/src/cli/backup.rs new file mode 100644 index 0000000..baf29c0 --- /dev/null +++ b/src/cli/backup.rs @@ -0,0 +1,23 @@ +use std::path::PathBuf; +use tracing::info; +use crate::config::Config; +use crate::db::Db; +use crate::jobs::backup::perform_backup; + +pub async fn run( + out: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + if let Some(o) = out { config.backup_dir = PathBuf::from(o); } + + // Init DB connections to ensure databases exist and migrate if needed + let db = Db::init(&config)?; + + info!("Starting database backup..."); + let backup_path = perform_backup(&db, &config).await?; + info!("Database backup generated successfully: {}", backup_path); + + Ok(()) +} diff --git a/src/cli/create_admin.rs b/src/cli/create_admin.rs new file mode 100644 index 0000000..34863ec --- /dev/null +++ b/src/cli/create_admin.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; +use std::io::{self, Write}; +use tracing::{info, error}; +use crate::config::Config; +use crate::db::Db; +use crate::auth::hash_password; + +pub async fn run( + username: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + let db = Db::init(&config)?; + + let final_username = match username { + Some(u) => u, + None => read_input("Enter administrator username: "), + }; + + if final_username.trim().is_empty() { + error!("Username cannot be empty"); + return Ok(()); + } + + let password = read_input("Enter password: "); + if password.trim().is_empty() { + error!("Password cannot be empty"); + return Ok(()); + } + + let hash = hash_password(&password).map_err(|e| e.to_string())?; + let conn = db.admin.lock().unwrap(); + let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; + info!("Successfully created admin user: {} (ID: {})", u.username, u.id); + + Ok(()) +} + +fn read_input(prompt: &str) -> String { + print!("{}", prompt); + let _ = io::stdout().flush(); + let mut input = String::new(); + let _ = io::stdin().read_line(&mut input); + input.trim().to_string() +} diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..ef64bc0 --- /dev/null +++ b/src/cli/doctor.rs @@ -0,0 +1,76 @@ +use std::path::PathBuf; +use tracing::info; +use crate::config::Config; +use crate::db::sqlite; +use rusqlite::Connection; + +/// Run comprehensive database diagnostics. +/// +/// Opens each database, collects health reports (schema version, journal mode, +/// foreign key enforcement, integrity check), and prints a summary. +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + + info!("Running BZOD database diagnostics..."); + println!("BZOD Database Doctor"); + println!("===================="); + println!("Data directory: {:?}", config.data_dir); + println!(); + + let databases = ["admin", "content", "analytics", "system"]; + let mut all_healthy = true; + + for db_name in &databases { + let db_path = config.data_dir.join(format!("{}.db", db_name)); + + if !db_path.exists() { + println!("Database: {}", db_name); + println!(" Status: NOT FOUND at {:?}", db_path); + println!(); + all_healthy = false; + continue; + } + + match Connection::open(&db_path) { + Ok(conn) => { + match sqlite::collect_health_report(&conn, db_name) { + Ok(report) => { + println!("Database: {}", report.database); + println!(" Path: {:?}", db_path); + println!(" Schema version: {}", report.schema_version); + println!(" Journal mode: {}", report.journal_mode); + println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" }); + println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" }); + + if !report.integrity_ok || !report.foreign_keys_enabled { + all_healthy = false; + } + } + Err(e) => { + println!("Database: {}", db_name); + println!(" Status: ERROR collecting health report: {}", e); + all_healthy = false; + } + } + } + Err(e) => { + println!("Database: {}", db_name); + println!(" Status: FAILED to open: {}", e); + all_healthy = false; + } + } + println!(); + } + + println!("--------------------"); + if all_healthy { + println!("Overall status: HEALTHY"); + } else { + println!("Overall status: ISSUES DETECTED"); + } + + Ok(()) +} diff --git a/src/cli/migrate.rs b/src/cli/migrate.rs new file mode 100644 index 0000000..eea6819 --- /dev/null +++ b/src/cli/migrate.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; +use tracing::info; +use crate::config::Config; +use crate::db::Db; + +pub async fn run( + data_dir: Option, + dry_run: bool, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + + if dry_run { + info!("Dry run enabled: pending database migrations will be reported but not applied."); + info!("Data directory: {:?}", config.data_dir); + return Ok(()); + } + + info!("Running database migrations..."); + let _db = Db::init(&config)?; + info!("Database migrations applied successfully."); + + Ok(()) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..8558ab2 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,75 @@ +use clap::{Parser, Subcommand}; + +pub mod serve; +pub mod backup; +pub mod restore; +pub mod migrate; +pub mod stats; +pub mod validate; +pub mod create_admin; +pub mod doctor; + +#[derive(Parser)] +#[command(name = "bzod")] +#[command(about = "BZOD - Personal Redirector & Landing Page Platform")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Start the BZOD web server + Serve { + #[arg(long)] + host: Option, + #[arg(long)] + port: Option, + #[arg(long)] + data_dir: Option, + }, + /// Create a tar.gz backup of all databases + Backup { + #[arg(long)] + out: Option, + #[arg(long)] + data_dir: Option, + }, + /// Restore databases from a tar.gz backup file + Restore { + #[arg(long, required = true)] + file: String, + #[arg(long)] + data_dir: Option, + }, + /// Apply pending database schema migrations + Migrate { + #[arg(long)] + data_dir: Option, + /// Show what migrations would be applied without executing them + #[arg(long)] + dry_run: bool, + }, + /// Print database statistics and record counts in the terminal + Stats { + #[arg(long)] + data_dir: Option, + }, + /// Perform a one-shot validation of all registered short link destinations + Validate { + #[arg(long)] + data_dir: Option, + }, + /// Create a new administrator user in the database + CreateAdmin { + #[arg(long)] + username: Option, + #[arg(long)] + data_dir: Option, + }, + /// Run database diagnostics and health checks + Doctor { + #[arg(long)] + data_dir: Option, + }, +} diff --git a/src/cli/restore.rs b/src/cli/restore.rs new file mode 100644 index 0000000..d5f015e --- /dev/null +++ b/src/cli/restore.rs @@ -0,0 +1,45 @@ +use std::path::PathBuf; +use std::fs::File; +use std::io::{self, Write}; +use tracing::{info, error}; +use flate2::read::GzDecoder; +use tar::Archive; +use crate::config::Config; + +pub async fn run( + file: String, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + let file_path = PathBuf::from(file); + + if !file_path.exists() { + error!("Backup file not found: {:?}", file_path); + return Ok(()); + } + + info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir); + print!("Are you sure you want to restore? (y/N): "); + let _ = io::stdout().flush(); + let mut confirm = String::new(); + let _ = io::stdin().read_line(&mut confirm); + + if !confirm.trim().eq_ignore_ascii_case("y") { + info!("Restore cancelled."); + return Ok(()); + } + + if !config.data_dir.exists() { + std::fs::create_dir_all(&config.data_dir)?; + } + + info!("Restoring backup from: {:?}", file_path); + let f = File::open(&file_path)?; + let tar_gz = GzDecoder::new(f); + let mut archive = Archive::new(tar_gz); + archive.unpack(&config.data_dir)?; + info!("Database files successfully restored."); + + Ok(()) +} diff --git a/src/cli/serve.rs b/src/cli/serve.rs new file mode 100644 index 0000000..3aadbc9 --- /dev/null +++ b/src/cli/serve.rs @@ -0,0 +1,75 @@ +use std::path::PathBuf; +use std::time::Instant; +use tracing::info; +use crate::config::Config; +use crate::db::Db; +use crate::analytics::AnalyticsQueue; +use crate::state::AppState; +use crate::web::create_router; + +pub async fn run( + host: Option, + port: Option, + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(h) = host { config.host = h; } + if let Some(p) = port { config.port = p; } + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + + info!("Starting BZOD server on {}:{}", config.host, config.port); + info!("Database directory: {:?}", config.data_dir); + + // Init DBs + let db = Db::init(&config)?; + + // Init Queue + let queue = AnalyticsQueue::new(db.clone(), 1000); + + // Spawn background tasks + let link_checker_db = db.clone(); + let link_checker_interval = config.link_check_interval_mins; + tokio::spawn(async move { + crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await; + }); + + let aggregator_db = db.clone(); + let aggregator_interval = config.aggregation_interval_mins; + tokio::spawn(async move { + crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await; + }); + + let retention_db = db.clone(); + let retention_days = config.data_retention_days; + tokio::spawn(async move { + crate::jobs::run_retention_cleaner(retention_db, retention_days).await; + }); + + // Spawn optional backup scheduler + let backup_db = db.clone(); + let backup_config = config.clone(); + tokio::spawn(async move { + crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await; + }); + + let state = AppState { + admin_db: db.admin.clone(), + content_db: db.content.clone(), + analytics_db: db.analytics.clone(), + system_db: db.system.clone(), + db: db.clone(), + config: config.clone(), + analytics_queue: queue, + start_time: Instant::now(), + }; + + // Run axum server + let router = create_router(state); + let addr = format!("{}:{}", config.host, config.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + + info!("Listening for requests on http://{}", addr); + axum::serve(listener, router).await?; + + Ok(()) +} diff --git a/src/cli/stats.rs b/src/cli/stats.rs new file mode 100644 index 0000000..435ebd8 --- /dev/null +++ b/src/cli/stats.rs @@ -0,0 +1,49 @@ +use std::path::PathBuf; +use crate::config::Config; +use crate::db::Db; + +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + let db = Db::init(&config)?; + + println!("=== BZOD Database Stats ==="); + println!("Storage Directory: {:?}", config.data_dir); + + let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; + for f in files { + let p = config.data_dir.join(f); + if p.exists() { + let sz = std::fs::metadata(&p)?.len(); + println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0); + } + } + + let users_count = { + let conn = db.admin.lock().unwrap(); + crate::db::admin::get_user_count(&conn)? + }; + println!("Users Count: {}", users_count); + + let (urls_total, urls_active, urls_dead) = { + let conn = db.content.lock().unwrap(); + crate::db::content::get_url_counts(&conn)? + }; + println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead); + + let pages_count = { + let conn = db.content.lock().unwrap(); + crate::db::content::get_landing_page_count(&conn)? + }; + println!("Landing Pages: {}", pages_count); + + let total_visits = { + let conn = db.analytics.lock().unwrap(); + crate::db::analytics::get_total_clicks(&conn)? + }; + println!("Redirect Clicks: {}", total_visits); + + Ok(()) +} diff --git a/src/cli/validate.rs b/src/cli/validate.rs new file mode 100644 index 0000000..648a4cd --- /dev/null +++ b/src/cli/validate.rs @@ -0,0 +1,26 @@ +use std::path::PathBuf; +use tracing::info; +use reqwest::Client; +use std::time::Duration; +use crate::config::Config; +use crate::db::Db; + +pub async fn run( + data_dir: Option, + mut config: Config, +) -> Result<(), Box> { + if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } + + let db = Db::init(&config)?; + + info!("Running one-shot link validation..."); + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent("bzod-cli-checker/0.1") + .build()?; + + crate::jobs::perform_link_check(&db, &client).await?; + info!("Link validation complete."); + + Ok(()) +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..a801b70 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,148 @@ +use std::path::PathBuf; +use std::env; +use std::fs; +use serde::Deserialize; + +#[derive(Clone, Debug)] +pub struct Config { + pub host: String, + pub port: u16, + pub data_dir: PathBuf, + pub admin_username: String, + pub bootstrap_password_sha256: String, + pub session_secret: String, + pub cookie_secure: bool, + pub data_retention_days: Option, + pub link_check_interval_mins: u64, + pub aggregation_interval_mins: u64, + pub backup_enabled: bool, + pub backup_interval_mins: u64, + pub backup_dir: PathBuf, +} + +#[derive(Deserialize, Default)] +struct TomlConfig { + host: Option, + port: Option, + data_dir: Option, + admin_username: Option, + bootstrap_password_sha256: Option, + session_secret: Option, + cookie_secure: Option, + data_retention_days: Option, + link_check_interval_mins: Option, + aggregation_interval_mins: Option, + backup: Option, +} + +#[derive(Deserialize, Default)] +struct TomlBackupConfig { + enabled: Option, + interval_mins: Option, + out_dir: Option, +} + +impl Config { + pub fn load() -> Self { + // 1. Built-in defaults + let mut host = "0.0.0.0".to_string(); + let mut port = 8080u16; + let mut data_dir = PathBuf::from("./data"); + let mut admin_username = "admin".to_string(); + let mut bootstrap_password_sha256 = "".to_string(); + let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string(); + let mut cookie_secure = true; + let mut data_retention_days = None; + let mut link_check_interval_mins = 60u64; + let mut aggregation_interval_mins = 60u64; + let mut backup_enabled = false; + let mut backup_interval_mins = 1440u64; // Default: once per day + let mut backup_dir = PathBuf::from("./backups"); + + // 2. Load bzod.toml if it exists + let mut toml_path = "bzod.toml".to_string(); + if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() { + toml_path = "config/bzod.toml".to_string(); + } + if let Ok(toml_content) = fs::read_to_string(&toml_path) { + if let Ok(toml_config) = toml::from_str::(&toml_content) { + if let Some(h) = toml_config.host { host = h; } + if let Some(p) = toml_config.port { port = p; } + if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); } + if let Some(u) = toml_config.admin_username { admin_username = u; } + if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; } + if let Some(sec) = toml_config.session_secret { session_secret = sec; } + if let Some(c) = toml_config.cookie_secure { cookie_secure = c; } + if let Some(ret) = toml_config.data_retention_days { + if ret.eq_ignore_ascii_case("unlimited") { + data_retention_days = None; + } else if let Ok(parsed) = ret.parse::() { + data_retention_days = Some(parsed); + } + } + if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; } + if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; } + if let Some(b) = toml_config.backup { + if let Some(be) = b.enabled { backup_enabled = be; } + if let Some(bi) = b.interval_mins { backup_interval_mins = bi; } + if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); } + } + } + } + + // 3. Load .env if present + let _ = dotenvy::dotenv(); + if fs::metadata("config/.env").is_ok() { + let _ = dotenvy::from_path("config/.env"); + } + + // 4. Load from Environment Variables (taking highest precedence) + if let Ok(h) = env::var("HOST") { host = h; } + if let Ok(p_str) = env::var("PORT") { + if let Ok(p) = p_str.parse::() { port = p; } + } + if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); } + if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; } + if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; } + if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; } + if let Ok(c_str) = env::var("COOKIE_SECURE") { + if let Ok(c) = c_str.parse::() { cookie_secure = c; } + } + if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") { + if ret_str.eq_ignore_ascii_case("unlimited") { + data_retention_days = None; + } else if let Ok(parsed) = ret_str.parse::() { + data_retention_days = Some(parsed); + } + } + if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") { + if let Ok(lc) = lc_str.parse::() { link_check_interval_mins = lc; } + } + if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") { + if let Ok(ag) = ag_str.parse::() { aggregation_interval_mins = ag; } + } + if let Ok(be_str) = env::var("BACKUP_ENABLED") { + if let Ok(be) = be_str.parse::() { backup_enabled = be; } + } + if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") { + if let Ok(bi) = bi_str.parse::() { backup_interval_mins = bi; } + } + if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); } + + Self { + host, + port, + data_dir, + admin_username, + bootstrap_password_sha256, + session_secret, + cookie_secure, + data_retention_days, + link_check_interval_mins, + aggregation_interval_mins, + backup_enabled, + backup_interval_mins, + backup_dir, + } + } +} diff --git a/src/db/admin.rs b/src/db/admin.rs new file mode 100644 index 0000000..4126932 --- /dev/null +++ b/src/db/admin.rs @@ -0,0 +1,257 @@ +use rusqlite::{Connection, params}; +use uuid::Uuid; +use chrono::Utc; +use crate::models::{User, Session, ApiKey, AuditLog}; + +pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result { + let id = Uuid::new_v4().to_string(); + let created_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);", + params![id, username, password_hash, created_at], + )?; + + Ok(User { + id, + username: username.to_string(), + password_hash: password_hash.to_string(), + created_at, + }) +} + +pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?; + let mut rows = stmt.query(params![username])?; + + if let Some(row) = rows.next()? { + Ok(Some(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + })) + } else { + Ok(None) + } +} + +pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?; + let mut rows = stmt.query(params![id])?; + + if let Some(row) = rows.next()? { + Ok(Some(User { + id: row.get(0)?, + username: row.get(1)?, + password_hash: row.get(2)?, + created_at: row.get(3)?, + })) + } else { + Ok(None) + } +} + +pub fn get_user_count(conn: &Connection) -> rusqlite::Result { + conn.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0)) +} + +pub fn create_session( + conn: &Connection, + session_id: &str, + user_id: &str, + expires_at_rfc3339: &str, +) -> rusqlite::Result { + let created_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", + params![session_id, user_id, expires_at_rfc3339, created_at], + )?; + + Ok(Session { + id: session_id.to_string(), + user_id: user_id.to_string(), + expires_at: expires_at_rfc3339.to_string(), + created_at, + }) +} + +pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?; + let mut rows = stmt.query(params![session_id])?; + + if let Some(row) = rows.next()? { + Ok(Some(Session { + id: row.get(0)?, + user_id: row.get(1)?, + expires_at: row.get(2)?, + created_at: row.get(3)?, + })) + } else { + Ok(None) + } +} + +pub fn delete_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?; + Ok(()) +} + +pub fn cleanup_expired_sessions(conn: &Connection) -> rusqlite::Result { + let now = Utc::now().to_rfc3339(); + let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?; + Ok(count) +} + +pub fn create_api_key( + conn: &Connection, + user_id: &str, + name: &str, + key_hash: &str, +) -> rusqlite::Result { + let id = Uuid::new_v4().to_string(); + let created_at = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);", + params![id, user_id, key_hash, name, created_at], + )?; + + Ok(ApiKey { + id, + user_id: user_id.to_string(), + key_hash: key_hash.to_string(), + name: name.to_string(), + created_at, + last_used_at: None, + }) +} + +pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;" + )?; + let mut rows = stmt.query(params![key_hash])?; + + if let Some(row) = rows.next()? { + Ok(Some(ApiKey { + id: row.get(0)?, + user_id: row.get(1)?, + key_hash: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + last_used_at: row.get(5)?, + })) + } else { + Ok(None) + } +} + +pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE user_id = ?1 ORDER BY created_at DESC;" + )?; + let rows = stmt.query_map(params![user_id], |row| { + Ok(ApiKey { + id: row.get(0)?, + user_id: row.get(1)?, + key_hash: row.get(2)?, + name: row.get(3)?, + created_at: row.get(4)?, + last_used_at: row.get(5)?, + }) + })?; + + let mut keys = Vec::new(); + for key in rows { + keys.push(key?); + } + Ok(keys) +} + +pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> { + conn.execute("DELETE FROM api_keys WHERE id = ?1;", params![id])?; + Ok(()) +} + +pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> { + let now = Utc::now().to_rfc3339(); + conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?; + Ok(()) +} + +pub fn write_audit_log( + conn: &Connection, + username: &str, + action: &str, + object_type: Option<&str>, + object_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, +) -> rusqlite::Result { + let id = Uuid::new_v4().to_string(); + let timestamp = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);", + params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent], + )?; + + Ok(AuditLog { + id, + timestamp, + username: username.to_string(), + action: action.to_string(), + object_type: object_type.map(|s| s.to_string()), + object_id: object_id.map(|s| s.to_string()), + ip_address: ip_address.map(|s| s.to_string()), + user_agent: user_agent.map(|s| s.to_string()), + }) +} + +pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent + FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;" + )?; + let rows = stmt.query_map(params![limit, offset], |row| { + Ok(AuditLog { + id: row.get(0)?, + timestamp: row.get(1)?, + username: row.get(2)?, + action: row.get(3)?, + object_type: row.get(4)?, + object_id: row.get(5)?, + ip_address: row.get(6)?, + user_agent: row.get(7)?, + }) + })?; + + let mut logs = Vec::new(); + for log in rows { + logs.push(log?); + } + Ok(logs) +} + +pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> { + conn.execute( + "INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2);", + params![key, value], + )?; + Ok(()) +} + +pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?; + let mut rows = stmt.query(params![key])?; + + if let Some(row) = rows.next()? { + let val: String = row.get(0)?; + Ok(Some(val)) + } else { + Ok(None) + } +} diff --git a/src/db/analytics.rs b/src/db/analytics.rs new file mode 100644 index 0000000..aa2b302 --- /dev/null +++ b/src/db/analytics.rs @@ -0,0 +1,472 @@ +use rusqlite::{Connection, params}; +use std::collections::HashMap; +use crate::models::VisitRecord; + +// Custom User-Agent parser to avoid bloated dependencies +pub fn parse_ua(ua: &str) -> (String, String, String) { + let ua_lower = ua.to_lowercase(); + + let os = if ua_lower.contains("windows") { + "Windows".to_string() + } else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") { + if ua_lower.contains("iphone") || ua_lower.contains("ipad") { + "iOS".to_string() + } else { + "macOS".to_string() + } + } else if ua_lower.contains("android") { + "Android".to_string() + } else if ua_lower.contains("linux") { + "Linux".to_string() + } else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") { + "iOS".to_string() + } else { + "Other".to_string() + }; + + let browser = if ua_lower.contains("firefox") { + "Firefox".to_string() + } else if ua_lower.contains("opr/") || ua_lower.contains("opera") { + "Opera".to_string() + } else if ua_lower.contains("edg/") { + "Edge".to_string() + } else if ua_lower.contains("chrome") { + "Chrome".to_string() + } else if ua_lower.contains("safari") { + "Safari".to_string() + } else { + "Other".to_string() + }; + + let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") { + "Mobile".to_string() + } else if ua_lower.contains("ipad") || ua_lower.contains("tablet") { + "Tablet".to_string() + } else { + "Desktop".to_string() + }; + + (browser, os, device) +} + +// Clean referer to domain +pub fn clean_referrer(referer: &str) -> String { + if referer.is_empty() || referer == "direct" { + return "Direct".to_string(); + } + + if let Ok(url) = reqwest::Url::parse(referer) { + if let Some(host) = url.host_str() { + return host.trim_start_matches("www.").to_string(); + } + } + + // Fallback if not a valid URL + let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://"); + let cleaned = cleaned.split('/').next().unwrap_or("Direct"); + if cleaned.is_empty() { + "Direct".to_string() + } else { + cleaned.trim_start_matches("www.").to_string() + } +} + +pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + let mut stmt = tx.prepare( + "INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);" + )?; + + for r in records { + stmt.execute(params![ + r.id, + r.target_type, + r.target_id, + r.timestamp, + r.ip_address, + r.user_agent, + r.referer, + r.accept_language, + r.country, + r.status_code + ])?; + } + } + tx.commit()?; + Ok(()) +} + +pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result { + conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0)) +} + +pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result { + conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0)) +} + +// Get the date range of visits in the DB +pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?; + let mut rows = stmt.query([])?; + if let Some(row) = rows.next()? { + let min_date: Option = row.get(0)?; + let max_date: Option = row.get(1)?; + if let (Some(min), Some(max)) = (min_date, max_date) { + return Ok(Some((min, max))); + } + } + Ok(None) +} + +// Run aggregation for a specific day +pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> { + let mut visits = Vec::new(); + { + // 1. Fetch all visits on that day + let mut stmt = conn.prepare( + "SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;" + )?; + + struct RawVisit { + target_type: String, + target_id: String, + user_agent: String, + referer: String, + country: String, + } + + let rows = stmt.query_map(params![date], |row| { + Ok(RawVisit { + target_type: row.get(0)?, + target_id: row.get(1)?, + user_agent: row.get(2)?, + referer: row.get(3)?, + country: row.get(4)?, + }) + })?; + + for r in rows { + visits.push(r?); + } + } + + if visits.is_empty() { + return Ok(()); + } + + + // 2. Compute metrics in-memory + // Key structure: (target_type, target_id, metric_type, metric_key) -> count + let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new(); + + // Also track total per day (all targets combined) using target_id = "all" + for v in visits { + let (browser, os, device) = parse_ua(&v.user_agent); + let referrer = clean_referrer(&v.referer); + let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() }; + + let targets = vec![ + (v.target_type.clone(), v.target_id.clone()), + (v.target_type.clone(), "all".to_string()), + ]; + + for (t_type, t_id) in targets { + // Clicks + *aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1; + + // Country + *aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1; + + // Browser + *aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1; + + // OS + *aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1; + + // Device + *aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1; + + // Referrer + *aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1; + } + } + + // 3. Save to database in a transaction + let tx = conn.transaction()?; + { + // Delete old aggregates for this day + tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?; + + let mut insert_stmt = tx.prepare( + "INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);" + )?; + + for ((t_type, t_id, m_type, m_key), value) in aggregates { + insert_stmt.execute(params![ + date, + t_type, + t_id, + m_type, + m_key, + value + ])?; + } + } + tx.commit()?; + + // Update monthly and yearly summaries using the daily summaries + aggregate_month_from_daily(conn, &date[0..7])?; + aggregate_year_from_daily(conn, &date[0..4])?; + + Ok(()) +} + +fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?; + tx.execute( + "INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value) + SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value) + FROM daily_summaries + WHERE date LIKE ?2 + GROUP BY target_type, target_id, metric_type, metric_key;", + params![year_month, format!("{}-%", year_month)], + )?; + } + tx.commit()?; + Ok(()) +} + +fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> { + let tx = conn.transaction()?; + { + tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?; + tx.execute( + "INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value) + SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value) + FROM daily_summaries + WHERE date LIKE ?2 + GROUP BY target_type, target_id, metric_type, metric_key;", + params![year, format!("{}-%", year)], + )?; + } + tx.commit()?; + Ok(()) +} + +// Clean old raw visit records +pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result { + let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days); + let limit_str = limit_date.to_rfc3339(); + let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?; + Ok(count) +} + +// --- Query functions for Dashboard & API --- + +pub fn get_clicks_trend( + conn: &Connection, + target_type: &str, + target_id: &str, + limit_days: i64, +) -> rusqlite::Result> { + let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string(); + + let mut stmt = conn.prepare( + "SELECT date, SUM(metric_value) FROM daily_summaries + WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3 + GROUP BY date ORDER BY date ASC;" + )?; + + let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + Ok(res) +} + +// Fallback to query raw visits table if daily summaries are not aggregated yet +pub fn get_clicks_trend_raw( + conn: &Connection, + target_type: &str, + target_id: &str, + limit_days: i64, +) -> rusqlite::Result> { + let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339(); + + let mut stmt = conn.prepare( + "SELECT date(timestamp) as d, COUNT(*) FROM visits + WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3 + GROUP BY d ORDER BY d ASC;" + )?; + + let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + Ok(res) +} + +pub fn get_hourly_trend_raw( + conn: &Connection, + target_type: &str, + target_id: &str, + limit_days: i64, +) -> rusqlite::Result> { + let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339(); + + // SQLite strftime('%H', timestamp) extracts the hour + let mut stmt = conn.prepare( + "SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits + WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3 + GROUP BY h ORDER BY h ASC;" + )?; + + let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + Ok(res) +} + +pub fn get_metric_rankings( + conn: &Connection, + target_type: &str, + target_id: &str, + metric_type: &str, + limit: i64, +) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT metric_key, SUM(metric_value) as val FROM daily_summaries + WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3 + GROUP BY metric_key ORDER BY val DESC LIMIT ?4;" + )?; + + let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + Ok(res) +} + +pub fn get_metric_rankings_raw( + conn: &Connection, + target_type: &str, + target_id: &str, + metric_type: &str, + limit: i64, +) -> rusqlite::Result> { + // Falls back to direct query on visits + let mut res = Vec::new(); + + match metric_type { + "country" => { + let mut stmt = conn.prepare( + "SELECT country, COUNT(*) as c FROM visits + WHERE target_type = ?1 AND target_id = ?2 + GROUP BY country ORDER BY c DESC LIMIT ?3;" + )?; + let rows = stmt.query_map(params![target_type, target_id, limit], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + for r in rows { res.push(r?); } + } + "referrer" => { + let mut stmt = conn.prepare( + "SELECT referer, COUNT(*) as c FROM visits + WHERE target_type = ?1 AND target_id = ?2 + GROUP BY referer ORDER BY c DESC LIMIT ?3;" + )?; + let rows = stmt.query_map(params![target_type, target_id, limit], |row| { + let raw_ref: String = row.get(0)?; + Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?)) + })?; + + // Re-aggregate because clean_referrer might group different referrers + let mut grouped: HashMap = HashMap::new(); + for r in rows { + let (k, v) = r?; + *grouped.entry(k).or_insert(0) += v; + } + res = grouped.into_iter().collect(); + res.sort_by_key(|b| std::cmp::Reverse(b.1)); + res.truncate(limit as usize); + } + "browser" | "os" | "device" => { + let mut stmt = conn.prepare( + "SELECT user_agent, COUNT(*) as c FROM visits + WHERE target_type = ?1 AND target_id = ?2 + GROUP BY user_agent;" + )?; + let rows = stmt.query_map(params![target_type, target_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) + })?; + + let mut grouped: HashMap = HashMap::new(); + for r in rows { + let (ua, count) = r?; + let (b, o, d) = parse_ua(&ua); + let key = match metric_type { + "browser" => b, + "os" => o, + _ => d, + }; + *grouped.entry(key).or_insert(0) += count; + } + res = grouped.into_iter().collect(); + res.sort_by_key(|b| std::cmp::Reverse(b.1)); + res.truncate(limit as usize); + } + _ => {} + } + + Ok(res) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_ua_browsers() { + let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0"; + let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"; + let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15"; + let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36"; + + assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string())); + assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string())); + assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string())); + assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string())); + } + + #[test] + fn test_clean_referrer() { + assert_eq!(clean_referrer("direct"), "Direct"); + assert_eq!(clean_referrer(""), "Direct"); + assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com"); + assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com"); + assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com"); + } +} + diff --git a/src/db/content.rs b/src/db/content.rs new file mode 100644 index 0000000..8ee926d --- /dev/null +++ b/src/db/content.rs @@ -0,0 +1,384 @@ +use rusqlite::{Connection, params}; +use uuid::Uuid; +use chrono::Utc; +use crate::models::{Url, LandingPage}; + +// Helper: Associate tags with a URL +fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> { + conn.execute("DELETE FROM url_tags WHERE url_id = ?1;", params![url_id])?; + for tag_name in tags { + let tag_name = tag_name.trim().to_lowercase(); + if tag_name.is_empty() { + continue; + } + + // Insert tag if it doesn't exist + conn.execute( + "INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);", + params![Uuid::new_v4().to_string(), tag_name], + )?; + + // Get tag id + let tag_id: String = conn.query_row( + "SELECT id FROM tags WHERE name = ?1;", + params![tag_name], + |row| row.get(0), + )?; + + // Insert association + conn.execute( + "INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);", + params![url_id, tag_id], + )?; + } + Ok(()) +} + +// Helper: Get tags for a URL +pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT t.name FROM tags t JOIN url_tags ut ON t.id = ut.tag_id WHERE ut.url_id = ?1 ORDER BY t.name;" + )?; + let rows = stmt.query_map(params![url_id], |row| row.get::<_, String>(0))?; + let mut tags = Vec::new(); + for tag in rows { + tags.push(tag?); + } + Ok(tags) +} + +pub fn create_url( + conn: &Connection, + code: &str, + destination: &str, + title: Option<&str>, + description: Option<&str>, + tags: &[String], +) -> rusqlite::Result { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let status = "healthy".to_string(); + + conn.execute( + "INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);", + params![id, code, destination, title, description, status, now, now], + )?; + + associate_tags(conn, &id, tags)?; + + Ok(Url { + id, + code: code.to_string(), + destination: destination.to_string(), + title: title.map(|s| s.to_string()), + description: description.map(|s| s.to_string()), + status, + created_at: now.clone(), + updated_at: now, + tags: tags.to_vec(), + }) +} + +pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;" + )?; + let mut rows = stmt.query(params![id])?; + + if let Some(row) = rows.next()? { + let url_id: String = row.get(0)?; + let tags = get_tags_for_url(conn, &url_id)?; + Ok(Some(Url { + id: url_id, + code: row.get(1)?, + destination: row.get(2)?, + title: row.get(3)?, + description: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + tags, + })) + } else { + Ok(None) + } +} + +pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;" + )?; + let mut rows = stmt.query(params![code])?; + + if let Some(row) = rows.next()? { + let url_id: String = row.get(0)?; + let tags = get_tags_for_url(conn, &url_id)?; + Ok(Some(Url { + id: url_id, + code: row.get(1)?, + destination: row.get(2)?, + title: row.get(3)?, + description: row.get(4)?, + status: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + tags, + })) + } else { + Ok(None) + } +} + +pub fn update_url( + conn: &Connection, + id: &str, + destination: &str, + title: Option<&str>, + description: Option<&str>, + status: &str, + tags: &[String], +) -> rusqlite::Result> { + let now = Utc::now().to_rfc3339(); + + let count = conn.execute( + "UPDATE urls SET destination = ?1, title = ?2, description = ?3, status = ?4, updated_at = ?5 WHERE id = ?6;", + params![destination, title, description, status, now, id], + )?; + + if count == 0 { + return Ok(None); + } + + associate_tags(conn, id, tags)?; + + get_url_by_id(conn, id) +} + +pub fn delete_url(conn: &Connection, id: &str) -> rusqlite::Result { + let count = conn.execute("DELETE FROM urls WHERE id = ?1;", params![id])?; + Ok(count > 0) +} + +pub fn list_urls( + conn: &Connection, + limit: i64, + offset: i64, + tag_filter: Option<&str>, +) -> rusqlite::Result> { + let mut urls = Vec::new(); + + if let Some(tag) = tag_filter { + let tag_name = tag.trim().to_lowercase(); + let mut stmt = conn.prepare( + "SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at + FROM urls u + JOIN url_tags ut ON u.id = ut.url_id + JOIN tags t ON ut.tag_id = t.id + WHERE t.name = ?1 + ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;" + )?; + let rows = stmt.query_map(params![tag_name, limit, offset], |row| { + let url_id: String = row.get(0)?; + Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?)) + })?; + + for r in rows { + let (url_id, code, destination, title, description, status, created_at, updated_at) = r?; + let tags = get_tags_for_url(conn, &url_id)?; + urls.push(Url { + id: url_id, + code, + destination, + title, + description, + status, + created_at, + updated_at, + tags, + }); + } + } else { + let mut stmt = conn.prepare( + "SELECT id, code, destination, title, description, status, created_at, updated_at + FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" + )?; + let rows = stmt.query_map(params![limit, offset], |row| { + let url_id: String = row.get(0)?; + Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?)) + })?; + + for r in rows { + let (url_id, code, destination, title, description, status, created_at, updated_at) = r?; + let tags = get_tags_for_url(conn, &url_id)?; + urls.push(Url { + id: url_id, + code, + destination, + title, + description, + status, + created_at, + updated_at, + tags, + }); + } + } + + Ok(urls) +} + +pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result> { + let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?; + let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + let mut res = Vec::new(); + for r in rows { + res.push(r?); + } + Ok(res) +} + +pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite::Result<()> { + let now = Utc::now().to_rfc3339(); + conn.execute( + "UPDATE urls SET status = ?1, updated_at = ?2 WHERE id = ?3;", + params![status, now, id], + )?; + Ok(()) +} + +pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> { + let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?; + let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?; + let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?; + Ok((total, active, dead)) +} + +pub fn create_landing_page( + conn: &Connection, + code: &str, + slug: &str, + title: &str, + html_content: &str, + state: &str, +) -> rusqlite::Result { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + + conn.execute( + "INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);", + params![id, code, slug, title, html_content, state, now, now], + )?; + + Ok(LandingPage { + id, + code: code.to_string(), + slug: slug.to_string(), + title: title.to_string(), + html_content: html_content.to_string(), + state: state.to_string(), + created_at: now.clone(), + updated_at: now, + }) +} + +pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;" + )?; + let mut rows = stmt.query(params![id])?; + + if let Some(row) = rows.next()? { + Ok(Some(LandingPage { + id: row.get(0)?, + code: row.get(1)?, + slug: row.get(2)?, + title: row.get(3)?, + html_content: row.get(4)?, + state: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + })) + } else { + Ok(None) + } +} + +pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;" + )?; + let mut rows = stmt.query(params![code])?; + + if let Some(row) = rows.next()? { + Ok(Some(LandingPage { + id: row.get(0)?, + code: row.get(1)?, + slug: row.get(2)?, + title: row.get(3)?, + html_content: row.get(4)?, + state: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + })) + } else { + Ok(None) + } +} + +pub fn update_landing_page( + conn: &Connection, + id: &str, + slug: &str, + title: &str, + html_content: &str, + state: &str, +) -> rusqlite::Result> { + let now = Utc::now().to_rfc3339(); + + let count = conn.execute( + "UPDATE landing_pages SET slug = ?1, title = ?2, html_content = ?3, state = ?4, updated_at = ?5 WHERE id = ?6;", + params![slug, title, html_content, state, now, id], + )?; + + if count == 0 { + return Ok(None); + } + + get_landing_page_by_id(conn, id) +} + +pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result { + let count = conn.execute("DELETE FROM landing_pages WHERE id = ?1;", params![id])?; + Ok(count > 0) +} + +pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result> { + let mut stmt = conn.prepare( + "SELECT id, code, slug, title, html_content, state, created_at, updated_at + FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" + )?; + let rows = stmt.query_map(params![limit, offset], |row| { + Ok(LandingPage { + id: row.get(0)?, + code: row.get(1)?, + slug: row.get(2)?, + title: row.get(3)?, + html_content: row.get(4)?, + state: row.get(5)?, + created_at: row.get(6)?, + updated_at: row.get(7)?, + }) + })?; + + let mut pages = Vec::new(); + for page in rows { + pages.push(page?); + } + Ok(pages) +} + +pub fn get_landing_page_count(conn: &Connection) -> rusqlite::Result { + conn.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0)) +} diff --git a/src/db/migrations.rs b/src/db/migrations.rs new file mode 100644 index 0000000..c36730a --- /dev/null +++ b/src/db/migrations.rs @@ -0,0 +1,294 @@ +use std::sync::Mutex; + +use chrono::Utc; +use rusqlite::Connection; +use tracing::info; +use uuid::Uuid; + +/// A single versioned migration with a human-readable name. +pub struct Migration { + pub version: u32, + pub name: &'static str, + pub sql: &'static str, +} + +/// Run all pending migrations against `conn`, recording audit entries in `system_db_opt`. +/// +/// Migrations are applied in order. Each migration runs inside a transaction, +/// and the schema version is bumped only after a successful commit. +pub fn run_migrations( + conn: &mut Connection, + db_name: &str, + migrations: &[Migration], + system_db_opt: Option<&Mutex>, +) -> Result<(), Box> { + let current_version = crate::db::sqlite::get_user_version(conn)?; + let target_version = migrations.last().map_or(0, |m| m.version); + + if current_version < target_version { + for m in migrations.iter().filter(|m| m.version > current_version) { + info!(database = db_name, version = m.version, name = m.name, "Applying migration"); + + let tx = conn.transaction()?; + tx.execute_batch(m.sql)?; + tx.commit()?; + + crate::db::sqlite::set_user_version(conn, m.version as i32)?; + + info!(database = db_name, version = m.version, name = m.name, "Migration completed"); + + // Write audit record to system.db.migrations + if let Some(sys_db_mutex) = system_db_opt { + if let Ok(sys_db) = sys_db_mutex.lock() { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let _ = sys_db.execute( + "INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![id, db_name, m.version as i32, now], + ); + } + } else if db_name == "system" { + // If migrating system.db itself, write directly to its own migrations table + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);", + rusqlite::params![id, db_name, m.version as i32, now], + ); + } + } + } else { + info!(database = db_name, version = current_version, "Database up to date"); + } + + Ok(()) +} + +/// Print a dry-run migration plan to stdout without applying any changes. +pub fn print_migration_plan( + conn: &Connection, + db_name: &str, + migrations: &[Migration], +) -> Result<(), Box> { + let current_version = crate::db::sqlite::get_user_version(conn)?; + let target_version = migrations.last().map_or(0, |m| m.version); + + println!("Database: {db_name}"); + println!(" Current version: {current_version}"); + println!(" Target version: {target_version}"); + + let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect(); + + if pending.is_empty() { + println!(" Status: up to date"); + } else { + for m in pending { + println!(" Would apply: v{} {}", m.version, m.name); + } + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Migration definitions +// --------------------------------------------------------------------------- + +pub const ADMIN_MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial_schema", + sql: r#" + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS api_keys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at TEXT NOT NULL, + last_used_at TEXT, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS audit_logs ( + id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + username TEXT NOT NULL, + action TEXT NOT NULL, + object_type TEXT, + object_id TEXT, + ip_address TEXT, + user_agent TEXT + ); + + CREATE TABLE IF NOT EXISTS config ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + "#, + }, +]; + +pub const CONTENT_MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial_schema", + sql: r#" + CREATE TABLE IF NOT EXISTS urls ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + destination TEXT NOT NULL, + title TEXT, + description TEXT, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS landing_pages ( + id TEXT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + slug TEXT NOT NULL, + title TEXT NOT NULL, + html_content TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS tags ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL UNIQUE + ); + + CREATE TABLE IF NOT EXISTS url_tags ( + url_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (url_id, tag_id), + FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code); + CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code); + "#, + }, +]; + +pub const ANALYTICS_MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial_schema", + sql: r#" + CREATE TABLE IF NOT EXISTS visits ( + id TEXT PRIMARY KEY, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + ip_address TEXT NOT NULL, + user_agent TEXT NOT NULL, + referer TEXT NOT NULL, + accept_language TEXT NOT NULL, + country TEXT NOT NULL, + status_code INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS daily_summaries ( + date TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + metric_type TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_value INTEGER NOT NULL, + PRIMARY KEY (date, target_type, target_id, metric_type, metric_key) + ); + + CREATE TABLE IF NOT EXISTS monthly_summaries ( + year_month TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + metric_type TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_value INTEGER NOT NULL, + PRIMARY KEY (year_month, target_type, target_id, metric_type, metric_key) + ); + + CREATE TABLE IF NOT EXISTS yearly_summaries ( + year TEXT NOT NULL, + target_type TEXT NOT NULL, + target_id TEXT NOT NULL, + metric_type TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_value INTEGER NOT NULL, + PRIMARY KEY (year, target_type, target_id, metric_type, metric_key) + ); + + CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp); + CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id); + "#, + }, +]; + +pub const SYSTEM_MIGRATIONS: &[Migration] = &[ + Migration { + version: 1, + name: "initial_schema", + sql: r#" + CREATE TABLE IF NOT EXISTS migrations ( + id TEXT PRIMARY KEY, + db_name TEXT NOT NULL, + version INTEGER NOT NULL, + applied_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS job_history ( + id TEXT PRIMARY KEY, + job_name TEXT NOT NULL, + status TEXT NOT NULL, + started_at TEXT NOT NULL, + finished_at TEXT, + error_message TEXT + ); + + CREATE TABLE IF NOT EXISTS health_checks ( + id TEXT PRIMARY KEY, + object_type TEXT NOT NULL, + object_id TEXT NOT NULL, + checked_at TEXT NOT NULL, + status_code INTEGER, + error_message TEXT, + is_healthy INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS backup_history ( + id TEXT PRIMARY KEY, + backup_path TEXT NOT NULL, + status TEXT NOT NULL, + created_at TEXT NOT NULL, + size_bytes INTEGER, + error_message TEXT + ); + + CREATE TABLE IF NOT EXISTS system_events ( + id TEXT PRIMARY KEY, + event_type TEXT NOT NULL, + timestamp TEXT NOT NULL, + details TEXT NOT NULL + ); + "#, + }, +]; diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..7c394c9 --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,126 @@ +use std::fs; +use std::sync::{Arc, Mutex}; +use rusqlite::Connection; +use crate::config::Config; +use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS}; +use crate::db::sqlite::{enable_foreign_keys, enable_wal}; + +pub mod migrations; +pub mod sqlite; +pub mod admin; +pub mod content; +pub mod analytics; + +#[derive(Clone)] +pub struct Db { + pub admin: Arc>, + pub content: Arc>, + pub analytics: Arc>, + pub system: Arc>, +} + +impl Db { + pub fn init(config: &Config) -> Result> { + // Ensure data directory exists + if !config.data_dir.exists() { + fs::create_dir_all(&config.data_dir)?; + } + + let admin_path = config.data_dir.join("admin.db"); + let content_path = config.data_dir.join("content.db"); + let analytics_path = config.data_dir.join("analytics.db"); + let system_path = config.data_dir.join("system.db"); + + use tracing::info; + + info!("Opening admin.db"); + let mut admin_conn = Connection::open(admin_path)?; + info!("Opening content.db"); + let mut content_conn = Connection::open(content_path)?; + info!("Opening analytics.db"); + let mut analytics_conn = Connection::open(analytics_path)?; + info!("Opening system.db"); + let mut system_conn = Connection::open(system_path)?; + + // Enable WAL mode for better concurrency and write performance + info!(database = "admin", "Enabling WAL mode on admin.db"); + enable_wal(&admin_conn, "admin")?; + info!(database = "content", "Enabling WAL mode on content.db"); + enable_wal(&content_conn, "content")?; + info!(database = "analytics", "Enabling WAL mode on analytics.db"); + enable_wal(&analytics_conn, "analytics")?; + info!(database = "system", "Enabling WAL mode on system.db"); + enable_wal(&system_conn, "system")?; + + // Enable foreign key support + info!(database = "admin", "Enabling foreign key enforcement on admin.db"); + enable_foreign_keys(&admin_conn, "admin")?; + info!(database = "content", "Enabling foreign key enforcement on content.db"); + enable_foreign_keys(&content_conn, "content")?; + info!(database = "analytics", "Enabling foreign key enforcement on analytics.db"); + enable_foreign_keys(&analytics_conn, "analytics")?; + info!(database = "system", "Enabling foreign key enforcement on system.db"); + enable_foreign_keys(&system_conn, "system")?; + + // 1. Run migrations for system.db first, as it receives secondary audit records + info!("Running system migrations"); + run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?; + + let system_arc = Arc::new(Mutex::new(system_conn)); + + // 2. Run migrations for other databases with system.db logging + info!("Running admin migrations"); + run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?; + info!("Running content migrations"); + run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?; + info!("Running analytics migrations"); + run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?; + + Ok(Self { + admin: Arc::new(Mutex::new(admin_conn)), + content: Arc::new(Mutex::new(content_conn)), + analytics: Arc::new(Mutex::new(analytics_conn)), + system: system_arc, + }) + } + + pub fn compact(&self) -> Result<(), rusqlite::Error> { + let admin = self.admin.lock().unwrap(); + admin.execute("VACUUM;", [])?; + + let content = self.content.lock().unwrap(); + content.execute("VACUUM;", [])?; + + let analytics = self.analytics.lock().unwrap(); + analytics.execute("VACUUM;", [])?; + + let system = self.system.lock().unwrap(); + system.execute("VACUUM;", [])?; + + Ok(()) + } +} + +#[cfg(test)] +mod db_init_tests { + use super::*; + use std::path::PathBuf; + + #[test] + fn test_db_init() { + let temp_dir = PathBuf::from("./temp_test_db_dir"); + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + let mut config = Config::load(); + config.data_dir = temp_dir.clone(); + let db = Db::init(&config); + + // Cleanup + if temp_dir.exists() { + let _ = std::fs::remove_dir_all(&temp_dir); + } + + assert!(db.is_ok(), "Failed to init DB: {:?}", db.err()); + } +} diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs new file mode 100644 index 0000000..f0eb4e6 --- /dev/null +++ b/src/db/sqlite.rs @@ -0,0 +1,191 @@ +//! Strongly-typed SQLite PRAGMA and configuration helpers. +//! +//! This module provides safe wrappers around common SQLite PRAGMAs using +//! rusqlite's type-safe APIs (`pragma_update`, `pragma_query_value`, `query_row`) +//! instead of raw `execute` calls. All functions use structured tracing for +//! observability. + +use rusqlite::Connection; +use serde::Serialize; +use tracing::info; + +/// Enables WAL (Write-Ahead Logging) journal mode on the given connection. +/// +/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns +/// the actual mode. Returns an error if the database does not confirm WAL mode. +pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> { + let actual_mode: String = + conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?; + + info!(database = db_name, mode = %actual_mode, "WAL mode configured"); + + if actual_mode.to_lowercase() != "wal" { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Ok(()) +} + +/// Enables foreign key constraint enforcement on the given connection. +/// +/// Sets `foreign_keys` to ON via `pragma_update`, then verifies the setting +/// was applied by reading it back with `pragma_query_value`. +pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> { + conn.pragma_update(None, "foreign_keys", "ON")?; + + let enabled: bool = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + + info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured"); + + if !enabled { + return Err(rusqlite::Error::QueryReturnedNoRows); + } + + Ok(()) +} + +/// Sets the schema user_version on the given connection. +/// +/// Uses `pragma_update` with the type-safe API — no `format!` string, no raw +/// `execute`. +pub fn set_user_version(conn: &Connection, version: i32) -> Result<(), rusqlite::Error> { + conn.pragma_update(None, "user_version", version) +} + +/// Returns the current schema user_version from the given connection. +pub fn get_user_version(conn: &Connection) -> Result { + conn.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0)) +} + +/// Runs `PRAGMA integrity_check` and returns `Ok(())` if the database reports "ok". +/// +/// If the integrity check returns any other value, the function returns an error +/// containing the integrity check message. +pub fn integrity_check(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> { + let result: String = + conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?; + + if result == "ok" { + info!(database = db_name, "Integrity check passed"); + Ok(()) + } else { + Err(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT), + Some(format!("Integrity check failed for {db_name}: {result}")), + )) + } +} + +/// Returns the current journal mode of the given connection. +pub fn get_journal_mode(conn: &Connection) -> Result { + conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0)) +} + +/// A snapshot of database health information collected from various PRAGMAs. +#[derive(Debug, Clone, Serialize)] +pub struct DatabaseHealthReport { + /// Name of the database (e.g. "admin", "content"). + pub database: String, + /// Current schema version (`user_version` PRAGMA). + pub schema_version: u32, + /// Active journal mode (e.g. "wal", "delete"). + pub journal_mode: String, + /// Whether foreign key enforcement is enabled. + pub foreign_keys_enabled: bool, + /// Whether `PRAGMA integrity_check` returned "ok". + pub integrity_ok: bool, +} + +/// Collects a [`DatabaseHealthReport`] by querying all relevant PRAGMAs. +/// +/// This function queries `user_version`, `journal_mode`, `foreign_keys`, and +/// `integrity_check` to build a comprehensive health snapshot. The report is +/// logged at `info` level with structured fields. +pub fn collect_health_report( + conn: &Connection, + db_name: &str, +) -> Result { + let schema_version = get_user_version(conn)?; + let journal_mode = get_journal_mode(conn)?; + + let foreign_keys_enabled: bool = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + + let integrity_result: String = + conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?; + let integrity_ok = integrity_result == "ok"; + + let report = DatabaseHealthReport { + database: db_name.to_owned(), + schema_version, + journal_mode, + foreign_keys_enabled, + integrity_ok, + }; + + info!( + database = %report.database, + version = report.schema_version, + journal_mode = %report.journal_mode, + foreign_keys = report.foreign_keys_enabled, + integrity = report.integrity_ok, + "Database health report collected" + ); + + Ok(report) +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::Connection; + + fn memory_conn() -> Connection { + Connection::open_in_memory().expect("Failed to open in-memory database") + } + + #[test] + fn test_enable_wal() { + let conn = memory_conn(); + // In-memory databases may not support WAL; we just verify no panic. + // On-disk databases would return "wal". + let _ = enable_wal(&conn, "test"); + } + + #[test] + fn test_enable_foreign_keys() { + let conn = memory_conn(); + enable_foreign_keys(&conn, "test").expect("Failed to enable foreign keys"); + } + + #[test] + fn test_user_version_roundtrip() { + let conn = memory_conn(); + set_user_version(&conn, 42).expect("Failed to set user_version"); + let v = get_user_version(&conn).expect("Failed to get user_version"); + assert_eq!(v, 42); + } + + #[test] + fn test_get_journal_mode() { + let conn = memory_conn(); + let mode = get_journal_mode(&conn).expect("Failed to get journal_mode"); + assert!(!mode.is_empty()); + } + + #[test] + fn test_integrity_check() { + let conn = memory_conn(); + integrity_check(&conn, "test").expect("Integrity check should pass on fresh db"); + } + + #[test] + fn test_collect_health_report() { + let conn = memory_conn(); + let report = + collect_health_report(&conn, "test").expect("Failed to collect health report"); + assert_eq!(report.database, "test"); + assert!(report.integrity_ok); + } +} diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..73dbc35 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,153 @@ +use axum::{ + response::{IntoResponse, Response}, + http::StatusCode, +}; +use std::fmt; +use std::path::PathBuf; + +/// Structured error type for database initialization and migration failures. +/// +/// Each variant provides actionable context about what went wrong during startup, +/// making diagnosis possible from logs alone without needing a debugger. +#[derive(Debug)] +pub enum DatabaseInitError { + /// Data directory could not be created or accessed + DataDirCreate { path: PathBuf, source: std::io::Error }, + /// SQLite connection could not be opened + ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error }, + /// PRAGMA configuration failed (WAL, foreign_keys, etc.) + PragmaConfig { database: String, pragma: String, source: rusqlite::Error }, + /// Migration execution failed + MigrationFailed { database: String, version: u32, name: String, source: Box }, + /// Database integrity check failed + IntegrityCheckFailed { database: String, message: String }, + /// WAL mode could not be enabled (returned unexpected mode) + WalModeFailed { database: String, actual_mode: String }, +} + +impl fmt::Display for DatabaseInitError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DataDirCreate { path, source } => { + write!(f, "Failed to create data directory {:?}: {}", path, source) + } + Self::ConnectionOpen { database, path, source } => { + write!(f, "Failed to open {}.db at {:?}: {}", database, path, source) + } + Self::PragmaConfig { database, pragma, source } => { + write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source) + } + Self::MigrationFailed { database, version, name, source } => { + write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source) + } + Self::IntegrityCheckFailed { database, message } => { + write!(f, "Integrity check failed on {}.db: {}", database, message) + } + Self::WalModeFailed { database, actual_mode } => { + write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode) + } + } + } +} + +impl std::error::Error for DatabaseInitError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::DataDirCreate { source, .. } => Some(source), + Self::ConnectionOpen { source, .. } => Some(source), + Self::PragmaConfig { source, .. } => Some(source), + Self::MigrationFailed { source, .. } => Some(source.as_ref()), + _ => None, + } + } +} + +#[derive(Debug)] +pub enum AppError { + Db(rusqlite::Error), + Auth(String), + Template(askama::Error), + Json(serde_json::Error), + Io(std::io::Error), + Http(String), + NotFound(String), + BadRequest(String), + Unauthorized(String), + Internal(String), +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + AppError::Db(e) => write!(f, "Database error: {}", e), + AppError::Auth(e) => write!(f, "Authentication error: {}", e), + AppError::Template(e) => write!(f, "Template render error: {}", e), + AppError::Json(e) => write!(f, "JSON processing error: {}", e), + AppError::Io(e) => write!(f, "IO error: {}", e), + AppError::Http(e) => write!(f, "HTTP request error: {}", e), + AppError::NotFound(e) => write!(f, "Not found: {}", e), + AppError::BadRequest(e) => write!(f, "Bad request: {}", e), + AppError::Unauthorized(e) => write!(f, "Unauthorized: {}", e), + AppError::Internal(e) => write!(f, "Internal error: {}", e), + } + } +} + +impl std::error::Error for AppError {} + +impl From for AppError { + fn from(err: rusqlite::Error) -> Self { + AppError::Db(err) + } +} + +impl From for AppError { + fn from(err: askama::Error) -> Self { + AppError::Template(err) + } +} + +impl From for AppError { + fn from(err: serde_json::Error) -> Self { + AppError::Json(err) + } +} + +impl From for AppError { + fn from(err: std::io::Error) -> Self { + AppError::Io(err) + } +} + +impl From for AppError { + fn from(err: argon2::password_hash::Error) -> Self { + AppError::Auth(err.to_string()) + } +} + +impl From for AppError { + fn from(err: reqwest::Error) -> Self { + AppError::Http(err.to_string()) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match &self { + AppError::NotFound(_) => StatusCode::NOT_FOUND, + AppError::BadRequest(_) => StatusCode::BAD_REQUEST, + AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED, + AppError::Auth(_) => StatusCode::UNAUTHORIZED, + _ => StatusCode::INTERNAL_SERVER_ERROR, + }; + + let message = self.to_string(); + if status == StatusCode::INTERNAL_SERVER_ERROR { + tracing::error!("AppError encountered: {:?}", self); + } + + // Return error details as text. Handlers requiring custom UI/JSON can catch errors + // or map them prior to calling into_response. + (status, message).into_response() + } +} diff --git a/src/jobs/aggregate.rs b/src/jobs/aggregate.rs new file mode 100644 index 0000000..9c9236f --- /dev/null +++ b/src/jobs/aggregate.rs @@ -0,0 +1,53 @@ +use std::time::Duration; +use tracing::{info, error}; + +use crate::db::Db; +use crate::analytics::aggregate_day; +use super::{log_job_start, log_job_end}; + +pub async fn run_aggregator(db: Db, interval_mins: u64) { + loop { + tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; + info!("Running background analytics aggregator..."); + + let job_id = log_job_start(&db.system, "analytics_aggregator"); + match perform_aggregation(&db).await { + Ok(_) => log_job_end(&db.system, &job_id, "success", None), + Err(e) => { + let err_str = e.to_string(); + error!("Error performing aggregation: {}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } + } + } +} + +pub async fn perform_aggregation(db: &Db) -> Result<(), Box> { + let date_range = { + let conn = db.analytics.lock().unwrap(); + crate::db::analytics::get_visits_date_range(&conn)? + }; + + if let Some((min_date, max_date)) = date_range { + let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?; + let max = chrono::NaiveDate::parse_from_str(&max_date, "%Y-%m-%d")?; + + let mut curr = min; + while curr <= max { + let date_str = curr.format("%Y-%m-%d").to_string(); + { + let mut conn = db.analytics.lock().unwrap(); + aggregate_day(&mut conn, &date_str)?; + } + if curr == max { + break; + } + if let Some(next) = curr.succ_opt() { + curr = next; + } else { + break; + } + } + } + Ok(()) +} diff --git a/src/jobs/backup.rs b/src/jobs/backup.rs new file mode 100644 index 0000000..c65ae08 --- /dev/null +++ b/src/jobs/backup.rs @@ -0,0 +1,81 @@ +use std::time::Duration; +use tracing::{info, error}; +use crate::db::Db; +use crate::config::Config; +use super::{log_job_start, log_job_end}; + +pub async fn run_backup_scheduler(db: Db, config: Config) { + if !config.backup_enabled { + info!("Background backup scheduler is disabled."); + return; + } + + info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins); + loop { + // Run backup every configured interval + tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await; + info!("Running background database backup..."); + + let job_id = log_job_start(&db.system, "database_backup"); + match perform_backup(&db, &config).await { + Ok(path) => { + info!("Backup created successfully at {}", path); + log_job_end(&db.system, &job_id, "success", None); + } + Err(e) => { + let err_str = e.to_string(); + error!("Error performing backup: {}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } + } + } +} + +pub async fn perform_backup(db: &Db, config: &Config) -> Result> { + use std::fs::File; + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::Builder; + use chrono::Utc; + use rusqlite::params; + use uuid::Uuid; + + let out_dir = config.backup_dir.clone(); + if !out_dir.exists() { + std::fs::create_dir_all(&out_dir)?; + } + + let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); + let tar_name = format!("{}-bzod-backup.tar.gz", date_str); + let tar_path = out_dir.join(tar_name); + + let file = File::create(&tar_path)?; + let enc = GzEncoder::new(file, Compression::default()); + let mut tar = Builder::new(enc); + + let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; + for f in files { + let db_file = config.data_dir.join(f); + if db_file.exists() { + tar.append_path_with_name(&db_file, f)?; + } + } + + tar.into_inner()?.finish()?; + let size_bytes = std::fs::metadata(&tar_path)?.len(); + let path_str = tar_path.to_string_lossy().to_string(); + + // Log to system.db.backup_history + { + let conn = db.system.lock().unwrap(); + let backup_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let _ = conn.execute( + "INSERT INTO backup_history (id, backup_path, status, created_at, size_bytes, error_message) + VALUES (?1, ?2, ?3, ?4, ?5, ?6);", + params![backup_id, path_str, "success", now, size_bytes as i64, None::], + ); + } + + Ok(path_str) +} diff --git a/src/jobs/healthcheck.rs b/src/jobs/healthcheck.rs new file mode 100644 index 0000000..fd6ff47 --- /dev/null +++ b/src/jobs/healthcheck.rs @@ -0,0 +1,94 @@ +use reqwest::Client; +use std::time::Duration; +use tracing::{info, error}; +use uuid::Uuid; +use chrono::Utc; +use rusqlite::params; + +use crate::db::Db; +use super::{log_job_start, log_job_end}; + +pub async fn run_link_checker(db: Db, interval_mins: u64) { + let client = Client::builder() + .timeout(Duration::from_secs(10)) + .user_agent("bzod-link-checker/0.1") + .build() + .unwrap_or_default(); + + loop { + // Sleep first to give server time to start up + tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; + info!("Running background link health check..."); + + let job_id = log_job_start(&db.system, "link_checker"); + match perform_link_check(&db, &client).await { + Ok(_) => log_job_end(&db.system, &job_id, "success", None), + Err(e) => { + let err_str = e.to_string(); + error!("Error performing link health check: {}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } + } + } +} + +pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box> { + let urls = { + let conn = db.content.lock().unwrap(); + crate::db::content::list_urls_for_health_check(&conn)? + }; + + for (id, dest) in urls { + let (status, status_code, err_msg) = check_url_health(client, &dest).await; + { + let conn = db.content.lock().unwrap(); + crate::db::content::update_url_health(&conn, &id, &status)?; + } + + // Log to system.db.health_checks + { + let conn = db.system.lock().unwrap(); + let hc_id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + let is_healthy = if status == "healthy" { 1 } else { 0 }; + let _ = conn.execute( + "INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", + params![hc_id, "url", id, now, status_code, err_msg, is_healthy], + ); + } + + // Rate limiting sleep between external requests + tokio::time::sleep(Duration::from_millis(200)).await; + } + Ok(()) +} + +async fn check_url_health(client: &Client, url: &str) -> (String, Option, Option) { + let res = client.get(url) + .timeout(Duration::from_secs(5)) + .send() + .await; + + match res { + Ok(response) => { + let status = response.status(); + let code = status.as_u16(); + if status.is_success() || status.is_redirection() { + ("healthy".to_string(), Some(code), None) + } else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { + ("dead".to_string(), Some(code), Some(format!("HTTP {}", code))) + } else { + ("suspect".to_string(), Some(code), Some(format!("HTTP {}", code))) + } + } + Err(err) => { + let err_str = err.to_string(); + if err.is_timeout() || err.is_connect() { + ("suspect".to_string(), None, Some(err_str)) + } else { + ("dead".to_string(), None, Some(err_str)) + } + } + } +} diff --git a/src/jobs/mod.rs b/src/jobs/mod.rs new file mode 100644 index 0000000..f55bbd8 --- /dev/null +++ b/src/jobs/mod.rs @@ -0,0 +1,35 @@ +use std::sync::Mutex; +use rusqlite::{Connection, params}; +use uuid::Uuid; +use chrono::Utc; + +pub mod healthcheck; +pub mod retention; +pub mod aggregate; +pub mod backup; + +pub use healthcheck::{run_link_checker, perform_link_check}; +pub use retention::run_retention_cleaner; +pub use aggregate::{run_aggregator, perform_aggregation}; + +pub fn log_job_start(conn: &Mutex, job_name: &str) -> String { + let id = Uuid::new_v4().to_string(); + let now = Utc::now().to_rfc3339(); + if let Ok(c) = conn.lock() { + let _ = c.execute( + "INSERT INTO job_history (id, job_name, status, started_at) VALUES (?1, ?2, ?3, ?4);", + params![id, job_name, "running", now], + ); + } + id +} + +pub fn log_job_end(conn: &Mutex, id: &str, status: &str, err_msg: Option<&str>) { + let now = Utc::now().to_rfc3339(); + if let Ok(c) = conn.lock() { + let _ = c.execute( + "UPDATE job_history SET status = ?1, finished_at = ?2, error_message = ?3 WHERE id = ?4;", + params![status, now, err_msg, id], + ); + } +} diff --git a/src/jobs/retention.rs b/src/jobs/retention.rs new file mode 100644 index 0000000..4dfe48b --- /dev/null +++ b/src/jobs/retention.rs @@ -0,0 +1,32 @@ +use std::time::Duration; +use tracing::{info, error}; + +use crate::db::Db; +use super::{log_job_start, log_job_end}; + +pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option) { + let retention_days = match retention_days_opt { + Some(days) => days, + None => return, + }; + + loop { + // Check once every 24 hours + tokio::time::sleep(Duration::from_secs(24 * 3600)).await; + info!("Running background data retention cleanup..."); + + let job_id = log_job_start(&db.system, "retention_cleaner"); + let conn = db.analytics.lock().unwrap(); + match crate::db::analytics::retention_cleanup(&conn, retention_days) { + Ok(count) => { + info!("Cleaned up {} expired visits from database", count); + log_job_end(&db.system, &job_id, "success", None); + } + Err(e) => { + let err_str = e.to_string(); + error!("Error running retention cleaner: {:?}", err_str); + log_job_end(&db.system, &job_id, "failed", Some(&err_str)); + } + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..47adf8c --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,14 @@ +pub mod config; +pub mod db; +pub mod auth; +pub mod analytics; +pub mod jobs; +pub mod services; +pub mod utils; +pub mod models; +pub mod templates; +pub mod charts; +pub mod state; +pub mod error; +pub mod web; +pub mod cli; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..84f4970 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,44 @@ +use clap::Parser; +use tracing_subscriber::EnvFilter; +use bzod::config::Config; +use bzod::cli::{Cli, Commands}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + // Set up tracing subscriber + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) + .init(); + + let cli = Cli::parse(); + let config = Config::load(); + + match cli.command { + Commands::Serve { host, port, data_dir } => { + bzod::cli::serve::run(host, port, data_dir, config).await?; + } + Commands::Backup { out, data_dir } => { + bzod::cli::backup::run(out, data_dir, config).await?; + } + Commands::Restore { file, data_dir } => { + bzod::cli::restore::run(file, data_dir, config).await?; + } + Commands::Migrate { data_dir, dry_run } => { + bzod::cli::migrate::run(data_dir, dry_run, config).await?; + } + Commands::Stats { data_dir } => { + bzod::cli::stats::run(data_dir, config).await?; + } + Commands::Validate { data_dir } => { + bzod::cli::validate::run(data_dir, config).await?; + } + Commands::CreateAdmin { username, data_dir } => { + bzod::cli::create_admin::run(username, data_dir, config).await?; + } + Commands::Doctor { data_dir } => { + bzod::cli::doctor::run(data_dir, config).await?; + } + } + + Ok(()) +} diff --git a/src/models/api_key.rs b/src/models/api_key.rs new file mode 100644 index 0000000..755f99f --- /dev/null +++ b/src/models/api_key.rs @@ -0,0 +1,11 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ApiKey { + pub id: String, + pub user_id: String, + pub key_hash: String, + pub name: String, + pub created_at: String, + pub last_used_at: Option, +} diff --git a/src/models/audit.rs b/src/models/audit.rs new file mode 100644 index 0000000..4e07008 --- /dev/null +++ b/src/models/audit.rs @@ -0,0 +1,13 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AuditLog { + pub id: String, + pub timestamp: String, + pub username: String, + pub action: String, + pub object_type: Option, + pub object_id: Option, + pub ip_address: Option, + pub user_agent: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..b818df9 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,13 @@ +pub mod user; +pub mod url; +pub mod page; +pub mod visit; +pub mod api_key; +pub mod audit; + +pub use user::{User, Session}; +pub use url::Url; +pub use page::LandingPage; +pub use visit::{VisitRecord, SummaryEntry}; +pub use api_key::ApiKey; +pub use audit::AuditLog; diff --git a/src/models/page.rs b/src/models/page.rs new file mode 100644 index 0000000..b2c9b70 --- /dev/null +++ b/src/models/page.rs @@ -0,0 +1,13 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct LandingPage { + pub id: String, + pub code: String, + pub slug: String, + pub title: String, + pub html_content: String, + pub state: String, // 'draft', 'published', 'archived' + pub created_at: String, + pub updated_at: String, +} diff --git a/src/models/url.rs b/src/models/url.rs new file mode 100644 index 0000000..fa8062a --- /dev/null +++ b/src/models/url.rs @@ -0,0 +1,14 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Url { + pub id: String, + pub code: String, + pub destination: String, + pub title: Option, + pub description: Option, + pub status: String, // 'healthy', 'suspect', 'dead' + pub created_at: String, + pub updated_at: String, + pub tags: Vec, +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..2535449 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,17 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct User { + pub id: String, + pub username: String, + pub password_hash: String, + pub created_at: String, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct Session { + pub id: String, + pub user_id: String, + pub expires_at: String, + pub created_at: String, +} diff --git a/src/models/visit.rs b/src/models/visit.rs new file mode 100644 index 0000000..dd9c5e7 --- /dev/null +++ b/src/models/visit.rs @@ -0,0 +1,25 @@ +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct VisitRecord { + pub id: String, + pub target_type: String, // 'url' or 'page' + pub target_id: String, + pub timestamp: String, + pub ip_address: String, + pub user_agent: String, + pub referer: String, + pub accept_language: String, + pub country: String, + pub status_code: u16, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct SummaryEntry { + pub date: String, + pub target_type: String, + pub target_id: String, + pub metric_type: String, + pub metric_key: String, + pub metric_value: i64, +} diff --git a/src/services/api_keys.rs b/src/services/api_keys.rs new file mode 100644 index 0000000..7e69f7b --- /dev/null +++ b/src/services/api_keys.rs @@ -0,0 +1,14 @@ +use crate::db::Db; +use crate::models::ApiKey; +use crate::error::AppError; + +pub fn create_api_key( + db: &Db, + user_id: &str, + name: &str, + key_hash: &str, +) -> Result { + let conn = db.admin.lock().unwrap(); + let key = crate::db::admin::create_api_key(&conn, user_id, name, key_hash)?; + Ok(key) +} diff --git a/src/services/audit.rs b/src/services/audit.rs new file mode 100644 index 0000000..4b9fd24 --- /dev/null +++ b/src/services/audit.rs @@ -0,0 +1,25 @@ +use crate::db::Db; +use crate::models::AuditLog; +use crate::error::AppError; + +pub fn log_action( + db: &Db, + username: &str, + action: &str, + object_type: Option<&str>, + object_id: Option<&str>, + ip_address: Option<&str>, + user_agent: Option<&str>, +) -> Result { + let conn = db.admin.lock().unwrap(); + let log = crate::db::admin::write_audit_log( + &conn, + username, + action, + object_type, + object_id, + ip_address, + user_agent, + )?; + Ok(log) +} diff --git a/src/services/landing_pages.rs b/src/services/landing_pages.rs new file mode 100644 index 0000000..fdf7075 --- /dev/null +++ b/src/services/landing_pages.rs @@ -0,0 +1,22 @@ +use crate::db::Db; +use crate::models::LandingPage; +use crate::error::AppError; + +pub fn create_landing_page( + db: &Db, + code: &str, + slug: &str, + title: &str, + html_content: &str, + state: &str, +) -> Result { + let conn = db.content.lock().unwrap(); + let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?; + Ok(page) +} + +pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result, AppError> { + let conn = db.content.lock().unwrap(); + let page = crate::db::content::get_landing_page_by_code(&conn, code)?; + Ok(page) +} diff --git a/src/services/mod.rs b/src/services/mod.rs new file mode 100644 index 0000000..f0f2654 --- /dev/null +++ b/src/services/mod.rs @@ -0,0 +1,4 @@ +pub mod shortener; +pub mod landing_pages; +pub mod api_keys; +pub mod audit; diff --git a/src/services/shortener.rs b/src/services/shortener.rs new file mode 100644 index 0000000..579484a --- /dev/null +++ b/src/services/shortener.rs @@ -0,0 +1,22 @@ +use crate::db::Db; +use crate::models::Url; +use crate::error::AppError; + +pub fn create_url( + db: &Db, + code: &str, + destination: &str, + title: Option<&str>, + description: Option<&str>, + tags: &[String], +) -> Result { + let conn = db.content.lock().unwrap(); + let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?; + Ok(url) +} + +pub fn get_url_by_code(db: &Db, code: &str) -> Result, AppError> { + let conn = db.content.lock().unwrap(); + let url = crate::db::content::get_url_by_code(&conn, code)?; + Ok(url) +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..4ad115d --- /dev/null +++ b/src/state.rs @@ -0,0 +1,28 @@ +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use rusqlite::Connection; +use crate::config::Config; +use crate::analytics::queue::AnalyticsQueue; +use crate::db::Db; + +#[derive(Clone)] +pub struct AppState { + pub admin_db: Arc>, + pub content_db: Arc>, + pub analytics_db: Arc>, + pub system_db: Arc>, + pub db: Db, + pub config: Config, + pub analytics_queue: AnalyticsQueue, + pub start_time: Instant, +} + +impl AppState { + pub fn db_compact(&self) -> Result<(), rusqlite::Error> { + self.admin_db.lock().unwrap().execute("VACUUM;", [])?; + self.content_db.lock().unwrap().execute("VACUUM;", [])?; + self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; + self.system_db.lock().unwrap().execute("VACUUM;", [])?; + Ok(()) + } +} diff --git a/src/templates/dashboard.rs b/src/templates/dashboard.rs new file mode 100644 index 0000000..ee04d80 --- /dev/null +++ b/src/templates/dashboard.rs @@ -0,0 +1,29 @@ +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; + +#[derive(Template)] +#[template(path = "dashboard.html")] +pub struct DashboardTemplate { + pub admin_username: String, + pub total_urls: i64, + pub total_pages: i64, + pub total_clicks: i64, + pub active_links: i64, + pub dead_links: i64, + pub traffic_chart: String, + pub countries_chart: String, + pub browsers_chart: String, + pub referrers_chart: String, +} + +impl IntoResponse for DashboardTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/templates/mod.rs b/src/templates/mod.rs new file mode 100644 index 0000000..78d3f68 --- /dev/null +++ b/src/templates/mod.rs @@ -0,0 +1,33 @@ +pub mod dashboard; +pub mod urls; +pub mod pages; +pub mod stats; +pub mod settings; + +pub use dashboard::DashboardTemplate; +pub use urls::UrlsTemplate; +pub use pages::PagesTemplate; +pub use stats::{StatusTemplate, AuditTemplate}; +pub use settings::SettingsTemplate; + +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; + +#[derive(Template)] +#[template(path = "login.html")] +pub struct LoginTemplate { + pub error: Option, + pub csrf_token: String, +} + +impl IntoResponse for LoginTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/templates/pages.rs b/src/templates/pages.rs new file mode 100644 index 0000000..750aa65 --- /dev/null +++ b/src/templates/pages.rs @@ -0,0 +1,24 @@ +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; +use crate::models::LandingPage; + +#[derive(Template)] +#[template(path = "pages.html")] +pub struct PagesTemplate { + pub admin_username: String, + pub pages: Vec, + pub csrf_token: String, + pub error: Option, +} + +impl IntoResponse for PagesTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/templates/settings.rs b/src/templates/settings.rs new file mode 100644 index 0000000..cd9e1e1 --- /dev/null +++ b/src/templates/settings.rs @@ -0,0 +1,26 @@ +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; +use crate::models::ApiKey; + +#[derive(Template)] +#[template(path = "settings.html")] +pub struct SettingsTemplate { + pub admin_username: String, + pub api_keys: Vec, + pub data_retention: String, + pub csrf_token: String, + pub success: Option, + pub error: Option, +} + +impl IntoResponse for SettingsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/templates/stats.rs b/src/templates/stats.rs new file mode 100644 index 0000000..69f16ed --- /dev/null +++ b/src/templates/stats.rs @@ -0,0 +1,44 @@ +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; +use crate::models::AuditLog; + +#[derive(Template)] +#[template(path = "status_ui.html")] +pub struct StatusTemplate { + pub admin_username: String, + pub app_status: &'static str, + pub db_status: String, + pub queue_size: usize, + pub memory_usage: String, + pub uptime: String, + pub version: &'static str, + pub git_commit: &'static str, +} + +#[derive(Template)] +#[template(path = "audit.html")] +pub struct AuditTemplate { + pub admin_username: String, + pub logs: Vec, +} + +impl IntoResponse for StatusTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} + +impl IntoResponse for AuditTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/templates/urls.rs b/src/templates/urls.rs new file mode 100644 index 0000000..c0daa00 --- /dev/null +++ b/src/templates/urls.rs @@ -0,0 +1,25 @@ +use askama::Template; +use axum::{ + response::{IntoResponse, Response, Html}, + http::StatusCode, +}; +use crate::models::Url; + +#[derive(Template)] +#[template(path = "urls.html")] +pub struct UrlsTemplate { + pub admin_username: String, + pub urls: Vec, + pub csrf_token: String, + pub error: Option, + pub tag_filter: Option, +} + +impl IntoResponse for UrlsTemplate { + fn into_response(self) -> Response { + match self.render() { + Ok(html) => Html(html).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), + } + } +} diff --git a/src/utils/hashing.rs b/src/utils/hashing.rs new file mode 100644 index 0000000..5a16b1a --- /dev/null +++ b/src/utils/hashing.rs @@ -0,0 +1,8 @@ +use sha2::{Sha256, Digest}; + +// General SHA-256 hash helper +pub fn sha256_hash(data: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(data.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs new file mode 100644 index 0000000..2f2e009 --- /dev/null +++ b/src/utils/mod.rs @@ -0,0 +1,11 @@ +pub mod time; +pub mod system; +pub mod hashing; +pub mod network; +pub mod random; + +pub use time::format_duration; +pub use system::{get_memory_usage, get_db_file_info}; +pub use hashing::sha256_hash; +pub use network::get_client_ip; +pub use random::generate_token; diff --git a/src/utils/network.rs b/src/utils/network.rs new file mode 100644 index 0000000..97ae35c --- /dev/null +++ b/src/utils/network.rs @@ -0,0 +1,24 @@ +use std::net::SocketAddr; +use axum::{ + extract::ConnectInfo, + http::HeaderMap, +}; + +// Extract client IP address from proxy headers or connection info +pub fn get_client_ip(headers: &HeaderMap, connect_info: Option>) -> String { + if let Some(ip) = headers.get("cf-connecting-ip").and_then(|h| h.to_str().ok()) { + return ip.to_string(); + } + if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) { + return ip.to_string(); + } + if let Some(ips) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) { + if let Some(ip) = ips.split(',').next() { + return ip.trim().to_string(); + } + } + if let Some(ConnectInfo(addr)) = connect_info { + return addr.ip().to_string(); + } + "127.0.0.1".to_string() +} diff --git a/src/utils/random.rs b/src/utils/random.rs new file mode 100644 index 0000000..c1408a1 --- /dev/null +++ b/src/utils/random.rs @@ -0,0 +1,8 @@ +use rand::{RngCore, thread_rng}; + +// Generate a secure random token (hex-encoded) +pub fn generate_token(bytes_len: usize) -> String { + let mut key = vec![0u8; bytes_len]; + thread_rng().fill_bytes(&mut key); + hex::encode(key) +} diff --git a/src/utils/system.rs b/src/utils/system.rs new file mode 100644 index 0000000..34c773f --- /dev/null +++ b/src/utils/system.rs @@ -0,0 +1,40 @@ +use std::path::Path; + +// Read memory usage on Linux +pub fn get_memory_usage() -> String { + if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") { + let fields: Vec<&str> = statm.split_whitespace().collect(); + if !fields.is_empty() { + if let Ok(pages) = fields[0].parse::() { + // Page size is usually 4096 bytes + let bytes = pages * 4096; + return format!("{:.2} MB", bytes as f64 / 1_048_576.0); + } + } + } + "N/A".to_string() +} + +// Generate diagnostic size report for the SQLite files +pub fn get_db_file_info(data_dir: &Path) -> String { + let mut stats = String::new(); + let files = vec![ + ("admin.db", "Admin DB"), + ("content.db", "Content DB"), + ("analytics.db", "Analytics DB"), + ("system.db", "System DB"), + ]; + + for (f, name) in files { + let path = data_dir.join(f); + if path.exists() { + if let Ok(metadata) = std::fs::metadata(&path) { + let size = metadata.len(); + stats.push_str(&format!("{}: {} bytes ({:.2} MB)\n", name, size, size as f64 / 1_048_576.0)); + } + } else { + stats.push_str(&format!("{}: File not created yet\n", name)); + } + } + stats +} diff --git a/src/utils/time.rs b/src/utils/time.rs new file mode 100644 index 0000000..8c349cc --- /dev/null +++ b/src/utils/time.rs @@ -0,0 +1,12 @@ +use std::time::Duration; + +// Formats a duration as "Xd Xh Xm Xs" +pub fn format_duration(d: Duration) -> String { + format!( + "{}d {}h {}m {}s", + d.as_secs() / 86400, + (d.as_secs() % 86400) / 3600, + (d.as_secs() % 3600) / 60, + d.as_secs() % 60 + ) +} diff --git a/src/web/admin.rs b/src/web/admin.rs new file mode 100644 index 0000000..ba45c5b --- /dev/null +++ b/src/web/admin.rs @@ -0,0 +1,911 @@ +use axum::{ + extract::{Path, State, Query, ConnectInfo}, + http::{HeaderMap, StatusCode}, + response::{Redirect, Response, IntoResponse}, + Form, +}; +use rusqlite::params; +use axum_extra::extract::CookieJar; +use axum_extra::extract::cookie::Cookie; +use serde::Deserialize; +use std::net::SocketAddr; +use chrono::Utc; +use tar::Builder; +use flate2::write::GzEncoder; +use flate2::Compression; + +use crate::db::admin::{ + create_user, get_user_count, get_user_by_username, create_session, delete_session, + write_audit_log, list_audit_logs, list_api_keys, create_api_key, delete_api_key, set_config, get_config +}; +use crate::db::content::{ + list_urls, create_url, delete_url, get_url_counts, get_landing_page_count, + list_landing_pages, create_landing_page, delete_landing_page +}; +use crate::db::analytics::{ + get_total_clicks, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw +}; +use crate::auth::{ + authenticate_session, verify_password, verify_sha256, hash_password, generate_token, + generate_csrf_token, verify_csrf +}; +use crate::charts::{generate_line_chart, generate_bar_chart}; +use crate::state::AppState; +use crate::models::User; +use crate::utils::{get_client_ip, get_memory_usage, get_db_file_info}; + +// Helper: Verify session and return user or redirect to login +async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> { + let conn = state.admin_db.lock().unwrap(); + match authenticate_session(&conn, jar) { + Ok(Some((user, session_id))) => Ok((user, session_id)), + _ => Err(Redirect::to("/admin/login")), + } +} + +// GET /admin +pub async fn admin_index( + State(state): State, + jar: CookieJar, +) -> Response { + match require_auth(&state, &jar).await { + Ok(_) => Redirect::to("/admin/dashboard").into_response(), + Err(redir) => redir.into_response(), + } +} + +// GET /admin/login +pub async fn login_get( + State(state): State, + jar: CookieJar, + Query(params): Query>, +) -> Response { + let error = params.get("error").cloned(); + let csrf_token = generate_token(16); + + let mut new_jar = jar.clone(); + new_jar = new_jar.add( + Cookie::build(("bzod_temp_csrf", csrf_token.clone())) + .path("/admin/login") + .secure(state.config.cookie_secure) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .build() + ); + + let template = crate::templates::LoginTemplate { error, csrf_token }; + (new_jar, template).into_response() +} + +#[derive(Deserialize)] +pub struct LoginForm { + pub username: String, + pub password: String, + pub csrf_token: String, +} + +// POST /admin/login +pub async fn login_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let temp_csrf = jar.get("bzod_temp_csrf").map(|c| c.value().to_string()).unwrap_or_default(); + if temp_csrf.is_empty() || temp_csrf != form.csrf_token { + return Redirect::to("/admin/login?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let user_count = { + let conn = state.admin_db.lock().unwrap(); + get_user_count(&conn).unwrap_or(0) + }; + + let user_opt = if user_count == 0 { + // Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256 + if form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.bootstrap_password_sha256) { + let hash = match hash_password(&form.password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/login?error=Internal hashing error").into_response(), + }; + + let conn = state.admin_db.lock().unwrap(); + match create_user(&conn, &form.username, &hash) { + Ok(u) => { + let _ = write_audit_log(&conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", Some("user"), Some(&u.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Some(u) + } + Err(_) => None, + } + } else { + None + } + } else { + // Standard DB Verification + let conn = state.admin_db.lock().unwrap(); + match get_user_by_username(&conn, &form.username) { + Ok(Some(u)) => { + if verify_password(&form.password, &u.password_hash) { + Some(u) + } else { + None + } + } + _ => None, + } + }; + + match user_opt { + Some(user) => { + let session_token = generate_token(32); + let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339(); + + { + let conn = state.admin_db.lock().unwrap(); + let _ = create_session(&conn, &session_token, &user.id, &expires); + let _ = write_audit_log(&conn, &user.username, "USER_LOGIN", Some("session"), Some(&session_token), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + + let cookie = Cookie::build(("bzod_session", session_token)) + .path("/") + .secure(state.config.cookie_secure) + .http_only(true) + .same_site(axum_extra::extract::cookie::SameSite::Strict) + .max_age(time::Duration::days(30)) + .build(); + + let clear_temp = Cookie::build("bzod_temp_csrf") + .path("/admin/login") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie).add(clear_temp); + + (response_jar, Redirect::to("/admin/dashboard")).into_response() + } + None => { + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn, "anonymous", "LOGIN_FAILED", None, None, Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + Redirect::to("/admin/login?error=Invalid username or password").into_response() + } + } +} + +// GET /admin/logout +pub async fn logout( + State(state): State, + jar: CookieJar, +) -> Response { + if let Ok((_, session_id)) = require_auth(&state, &jar).await { + let conn = state.admin_db.lock().unwrap(); + let _ = delete_session(&conn, &session_id); + } + + let cookie = Cookie::build("bzod_session") + .path("/") + .max_age(time::Duration::ZERO) + .build(); + + let mut response_jar = jar.clone(); + response_jar = response_jar.add(cookie); + + (response_jar, Redirect::to("/admin/login")).into_response() +} + +// GET /admin/dashboard +pub async fn dashboard_get( + State(state): State, + jar: CookieJar, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let (total_urls, active_links, dead_links) = { + let conn = state.content_db.lock().unwrap(); + get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + + let total_pages = { + let conn = state.content_db.lock().unwrap(); + get_landing_page_count(&conn).unwrap_or(0) + }; + + let total_clicks = { + let conn = state.analytics_db.lock().unwrap(); + get_total_clicks(&conn).unwrap_or(0) + }; + + let clicks_data = { + let conn = state.analytics_db.lock().unwrap(); + get_clicks_trend(&conn, "url", "all", 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30)) + .unwrap_or_default() + }; + + let mut trend_map = std::collections::BTreeMap::new(); + for i in (0..30).rev() { + let date_str = (Utc::now() - chrono::Duration::days(i)).format("%Y-%m-%d").to_string(); + trend_map.insert(date_str, 0i64); + } + for (d, c) in clicks_data { + trend_map.insert(d, c); + } + let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect(); + let traffic_chart = generate_line_chart(&formatted_trend); + + let countries_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "country", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5)) + .unwrap_or_default() + }; + let countries_chart = generate_bar_chart(&countries_data); + + let referrers_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "referrer", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5)) + .unwrap_or_default() + }; + let referrers_chart = generate_bar_chart(&referrers_data); + + let browsers_data = { + let conn = state.analytics_db.lock().unwrap(); + get_metric_rankings(&conn, "url", "all", "browser", 5) + .or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5)) + .unwrap_or_default() + }; + let browsers_chart = generate_bar_chart(&browsers_data); + + let template = crate::templates::DashboardTemplate { + admin_username: user.username, + total_urls, + total_pages, + total_clicks, + active_links, + dead_links, + traffic_chart, + countries_chart, + browsers_chart, + referrers_chart, + }; + + template.into_response() +} + +// GET /admin/urls +#[derive(Deserialize)] +pub struct UrlsQuery { + pub tag: Option, + pub error: Option, +} + +pub async fn urls_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let urls = { + let conn = state.content_db.lock().unwrap(); + list_urls(&conn, 100, 0, query.tag.as_deref()).unwrap_or_default() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::UrlsTemplate { + admin_username: user.username, + urls, + csrf_token, + error: query.error, + tag_filter: query.tag, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct CreateUrlForm { + pub destination: String, + pub code: String, + pub title: String, + pub description: String, + pub tags: String, + pub csrf_token: String, +} + +// POST /admin/urls/create +pub async fn urls_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let mut code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); + } else { + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters").into_response(); + } + } + + let tags_list: Vec = form.tags + .split(',') + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .collect(); + + let title_opt = if form.title.trim().is_empty() { None } else { Some(form.title.trim()) }; + let desc_opt = if form.description.trim().is_empty() { None } else { Some(form.description.trim()) }; + + let res = { + let conn = state.content_db.lock().unwrap(); + create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list) + }; + + match res { + Ok(url) => { + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn, &user.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + Redirect::to("/admin/urls").into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + Redirect::to("/admin/urls?error=Short code already exists").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/urls/delete/:id +pub async fn urls_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.content_db.lock().unwrap(); + match delete_url(&conn, &id) { + Ok(_) => { + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn_admin, &user.username, "URL_DELETION", Some("url"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + Redirect::to("/admin/urls").into_response() + } + Err(e) => Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response(), + } +} + +// GET /admin/pages +#[derive(Deserialize)] +pub struct PagesQuery { + pub error: Option, +} + +pub async fn pages_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let pages = { + let conn = state.content_db.lock().unwrap(); + list_landing_pages(&conn, 100, 0).unwrap_or_default() + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::PagesTemplate { + admin_username: user.username, + pages, + csrf_token, + error: query.error, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct CreatePageForm { + pub title: String, + pub slug: String, + pub code: String, + pub state: String, + pub html_content: String, + pub csrf_token: String, +} + +// POST /admin/pages/create +pub async fn pages_create( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let mut code = form.code.trim().to_lowercase(); + if code.is_empty() { + code = generate_token(2); + } else { + if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters").into_response(); + } + } + + let clean_slug = form.slug.trim().to_lowercase(); + if clean_slug.is_empty() { + return Redirect::to("/admin/pages?error=Slug is required").into_response(); + } + + let res = { + let conn = state.content_db.lock().unwrap(); + create_landing_page(&conn, &code, &clean_slug, &form.title, &form.html_content, &form.state) + }; + + match res { + Ok(page) => { + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn_admin, &user.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + Redirect::to("/admin/pages").into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + Redirect::to("/admin/pages?error=Short code already exists").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response() + } + } +} + +// POST /admin/pages/delete/:id +pub async fn pages_delete( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.content_db.lock().unwrap(); + match delete_landing_page(&conn, &id) { + Ok(_) => { + { + let conn_admin = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn_admin, &user.username, "PAGE_DELETION", Some("page"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + Redirect::to("/admin/pages").into_response() + } + Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e)).into_response(), + } +} + +// GET /admin/settings +#[derive(Deserialize)] +pub struct SettingsQuery { + pub success: Option, + pub error: Option, +} + +pub async fn settings_get( + State(state): State, + jar: CookieJar, + Query(query): Query, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let api_keys = { + let conn = state.admin_db.lock().unwrap(); + list_api_keys(&conn, &user.id).unwrap_or_default() + }; + + let data_retention = { + let conn = state.admin_db.lock().unwrap(); + get_config(&conn, "retention_days") + .unwrap_or(None) + .unwrap_or_else(|| state.config.data_retention_days.map(|d| d.to_string()).unwrap_or_else(|| "unlimited".to_string())) + }; + + let csrf_token = generate_csrf_token(&session_id); + + let template = crate::templates::SettingsTemplate { + admin_username: user.username, + api_keys, + data_retention, + csrf_token, + success: query.success, + error: query.error, + }; + + template.into_response() +} + +#[derive(Deserialize)] +pub struct ChangePasswordForm { + pub current_password: String, + pub new_password: String, + pub csrf_token: String, +} + +// POST /admin/settings/password +pub async fn change_password_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + if !verify_password(&form.current_password, &user.password_hash) { + let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + return Redirect::to("/admin/settings?error=Incorrect current password").into_response(); + } + + let new_hash = match hash_password(&form.new_password) { + Ok(h) => h, + Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(), + }; + + let res = conn.execute("UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id]); + match res { + Ok(_) => { + let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Redirect::to("/admin/settings?success=Password updated successfully").into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Failed to update password: {}", e)).into_response() + } + } +} + +#[derive(Deserialize)] +pub struct RetentionForm { + pub retention: String, + pub csrf_token: String, +} + +// POST /admin/settings/retention +pub async fn change_retention_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + match set_config(&conn, "retention_days", &form.retention) { + Ok(_) => { + let _ = write_audit_log(&conn, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Redirect::to("/admin/settings?success=Retention policy saved").into_response() + } + Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(), + } +} + +// POST /admin/settings/compact +pub async fn compact_db_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + match state.db_compact() { + Ok(_) => { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Redirect::to("/admin/settings?success=Database files compacted successfully").into_response() + } + Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(), + } +} + +// GET /admin/settings/backup +pub async fn download_backup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let ip = get_client_ip(&headers, connect_info); + + // Create tar.gz in memory + let mut buffer = Vec::new(); + let res = { + let enc = GzEncoder::new(&mut buffer, Compression::default()); + let mut tar = Builder::new(enc); + + let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; + let mut add_err = None; + for f in files { + let path = state.config.data_dir.join(f); + if path.exists() { + if let Err(e) = tar.append_path_with_name(&path, f) { + add_err = Some(e); + break; + } + } + } + + match add_err { + Some(e) => Err(e), + None => { + match tar.into_inner().and_then(|encoder| encoder.finish()) { + Ok(_) => Ok(()), + Err(e) => Err(e), + } + } + } + }; + + match res { + Ok(_) => { + { + let conn = state.admin_db.lock().unwrap(); + let _ = write_audit_log(&conn, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + } + + let date_str = Utc::now().format("%Y-%m-%d").to_string(); + let filename = format!("{}-bzod-backup.tar.gz", date_str); + + ( + StatusCode::OK, + [ + ("Content-Type", "application/gzip"), + ("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)), + ], + buffer, + ).into_response() + } + Err(e) => { + Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response() + } + } +} + +#[derive(Deserialize)] +pub struct CreateApiKeyForm { + pub key_name: String, + pub csrf_token: String, +} + +// POST /admin/settings/api-keys/create +pub async fn create_api_key_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Form(form): Form, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + if !verify_csrf(&session_id, &form.csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + let key_secret = format!("bzo_{}", generate_token(16)); + + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(key_secret.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + + let conn = state.admin_db.lock().unwrap(); + match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) { + Ok(api_key) => { + let _ = write_audit_log(&conn, &user.username, "API_KEY_CREATED", Some("api_key"), Some(&api_key.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Redirect::to(&format!( + "/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}", + key_secret + )).into_response() + } + Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(), + } +} + +// POST /admin/settings/api-keys/revoke/:id +pub async fn revoke_api_key_post( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + connect_info: Option>, + Path(id): Path, + Form(form): Form>, +) -> Response { + let (user, session_id) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let csrf_token = form.get("csrf_token").cloned().unwrap_or_default(); + if !verify_csrf(&session_id, &csrf_token) { + return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response(); + } + + let ip = get_client_ip(&headers, connect_info); + + let conn = state.admin_db.lock().unwrap(); + match delete_api_key(&conn, &id) { + Ok(_) => { + let _ = write_audit_log(&conn, &user.username, "API_KEY_REVOKED", Some("api_key"), Some(&id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok())); + Redirect::to("/admin/settings?success=API Token revoked").into_response() + } + Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to revoke key: {}", e)).into_response(), + } +} + +// GET /admin/audit +pub async fn audit_get( + State(state): State, + jar: CookieJar, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let logs = { + let conn = state.admin_db.lock().unwrap(); + list_audit_logs(&conn, 100, 0).unwrap_or_default() + }; + + let template = crate::templates::AuditTemplate { + admin_username: user.username, + logs, + }; + + template.into_response() +} + +// GET /admin/status +pub async fn status_get( + State(state): State, + jar: CookieJar, +) -> Response { + let (user, _) = match require_auth(&state, &jar).await { + Ok(u) => u, + Err(redir) => return redir.into_response(), + }; + + let app_status = "Healthy"; + + let db_status = { + let conn_ok = { + let conn = state.admin_db.lock().unwrap(); + get_user_count(&conn).is_ok() + }; + if conn_ok { + format!("Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir)) + } else { + "Degraded (Database connections failed)".to_string() + } + }; + + let queue_size = 0; + let memory_usage = get_memory_usage(); + + let uptime_duration = state.start_time.elapsed(); + let uptime = crate::utils::format_duration(uptime_duration); + + let template = crate::templates::StatusTemplate { + admin_username: user.username, + app_status, + db_status, + queue_size, + memory_usage, + uptime, + version: "0.1.0", + git_commit: "unknown", + }; + + template.into_response() +} diff --git a/src/web/api.rs b/src/web/api.rs new file mode 100644 index 0000000..256e12c --- /dev/null +++ b/src/web/api.rs @@ -0,0 +1,413 @@ +use axum::{ + extract::{Path, State, Query, ConnectInfo}, + http::{StatusCode, HeaderMap}, + response::{IntoResponse, Json, Response}, +}; +use serde::{Deserialize, Serialize}; +use std::net::SocketAddr; + +use crate::db::content::{ + list_urls, get_url_by_id, create_url, update_url, delete_url, + list_landing_pages, get_landing_page_by_id, create_landing_page, update_landing_page, delete_landing_page, + get_url_counts, get_landing_page_count +}; +use crate::db::analytics::{ + get_total_clicks, get_total_page_views, get_clicks_trend, get_clicks_trend_raw, + get_metric_rankings, get_metric_rankings_raw +}; +use crate::db::admin::write_audit_log; +use crate::utils::get_client_ip; +use crate::auth::generate_token; +use crate::auth::ApiUser; +use crate::state::AppState; + +// JSON Payload Structs +#[derive(Deserialize)] +pub struct CreateUrlRequest { + pub destination: String, + pub code: Option, + pub title: Option, + pub description: Option, + pub tags: Option>, +} + +#[derive(Deserialize)] +pub struct UpdateUrlRequest { + pub destination: String, + pub title: Option, + pub description: Option, + pub status: String, // 'healthy', 'suspect', 'dead' + pub tags: Option>, +} + +#[derive(Deserialize)] +pub struct CreatePageRequest { + pub slug: String, + pub title: String, + pub html_content: String, + pub state: String, // 'draft', 'published', 'archived' + pub code: Option, +} + +#[derive(Deserialize)] +pub struct UpdatePageRequest { + pub slug: String, + pub title: String, + pub html_content: String, + pub state: String, +} + +// Error JSON Response +#[derive(Serialize)] +pub struct ApiError { + pub error: String, +} + +// --- URL Endpoints --- + +// POST /api/v1/urls +pub async fn api_create_url( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Json(payload): Json, +) -> Response { + let mut code = payload.code.unwrap_or_default().trim().to_lowercase(); + if code.is_empty() { + code = generate_token(3); // 6 hex + } else { + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 6 hex characters".to_string() })).into_response(); + } + } + + let tags = payload.tags.unwrap_or_default(); + let conn = state.content_db.lock().unwrap(); + match create_url(&conn, &code, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &tags) { + Ok(url) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), user_agent); + (StatusCode::CREATED, Json(url)).into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + (StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// GET /api/v1/urls +#[derive(Deserialize)] +pub struct ListQuery { + pub limit: Option, + pub offset: Option, + pub tag: Option, +} + +pub async fn api_list_urls( + State(state): State, + _user: ApiUser, + Query(query): Query, +) -> Response { + let limit = query.limit.unwrap_or(100); + let offset = query.offset.unwrap_or(0); + + let conn = state.content_db.lock().unwrap(); + match list_urls(&conn, limit, offset, query.tag.as_deref()) { + Ok(urls) => Json(urls).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// GET /api/v1/urls/:uuid +pub async fn api_get_url( + State(state): State, + _user: ApiUser, + Path(uuid): Path, +) -> Response { + let conn = state.content_db.lock().unwrap(); + match get_url_by_id(&conn, &uuid) { + Ok(Some(url)) => Json(url).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// PUT /api/v1/urls/:uuid +pub async fn api_update_url( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Path(uuid): Path, + Json(payload): Json, +) -> Response { + let tags = payload.tags.unwrap_or_default(); + let conn = state.content_db.lock().unwrap(); + match update_url(&conn, &uuid, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &payload.status, &tags) { + Ok(Some(url)) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_UPDATE", Some("url"), Some(&uuid), Some(&ip), user_agent); + Json(url).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// DELETE /api/v1/urls/:uuid +pub async fn api_delete_url( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Path(uuid): Path, +) -> Response { + let conn = state.content_db.lock().unwrap(); + match delete_url(&conn, &uuid) { + Ok(true) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_DELETION", Some("url"), Some(&uuid), Some(&ip), user_agent); + StatusCode::NO_CONTENT.into_response() + } + Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// --- Landing Page Endpoints --- + +// POST /api/v1/pages +pub async fn api_create_page( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Json(payload): Json, +) -> Response { + let mut code = payload.code.unwrap_or_default().trim().to_lowercase(); + if code.is_empty() { + code = generate_token(2); // 4 hex + } else { + if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 4 hex characters".to_string() })).into_response(); + } + } + + let conn = state.content_db.lock().unwrap(); + match create_landing_page(&conn, &code, &payload.slug, &payload.title, &payload.html_content, &payload.state) { + Ok(page) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), user_agent); + (StatusCode::CREATED, Json(page)).into_response() + } + Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => { + (StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response() + } + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// GET /api/v1/pages +pub async fn api_list_pages( + State(state): State, + _user: ApiUser, + Query(query): Query, +) -> Response { + let limit = query.limit.unwrap_or(100); + let offset = query.offset.unwrap_or(0); + + let conn = state.content_db.lock().unwrap(); + match list_landing_pages(&conn, limit, offset) { + Ok(pages) => Json(pages).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// GET /api/v1/pages/:uuid +pub async fn api_get_page( + State(state): State, + _user: ApiUser, + Path(uuid): Path, +) -> Response { + let conn = state.content_db.lock().unwrap(); + match get_landing_page_by_id(&conn, &uuid) { + Ok(Some(page)) => Json(page).into_response(), + Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// PUT /api/v1/pages/:uuid +pub async fn api_update_page( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Path(uuid): Path, + Json(payload): Json, +) -> Response { + let conn = state.content_db.lock().unwrap(); + match update_landing_page(&conn, &uuid, &payload.slug, &payload.title, &payload.html_content, &payload.state) { + Ok(Some(page)) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_UPDATE", Some("page"), Some(&uuid), Some(&ip), user_agent); + Json(page).into_response() + } + Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// DELETE /api/v1/pages/:uuid +pub async fn api_delete_page( + State(state): State, + headers: HeaderMap, + connect_info: Option>, + user: ApiUser, + Path(uuid): Path, +) -> Response { + let conn = state.content_db.lock().unwrap(); + match delete_landing_page(&conn, &uuid) { + Ok(true) => { + let ip = get_client_ip(&headers, connect_info); + let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); + let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_DELETION", Some("page"), Some(&uuid), Some(&ip), user_agent); + StatusCode::NO_CONTENT.into_response() + } + Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(), + Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(), + } +} + +// --- Statistics Endpoints --- + +#[derive(Serialize)] +pub struct OverallStatsResponse { + pub total_urls: i64, + pub total_pages: i64, + pub total_clicks: i64, + pub total_page_views: i64, + pub active_links: i64, + pub dead_links: i64, +} + +// GET /api/v1/stats +pub async fn api_overall_stats( + State(state): State, + _user: ApiUser, +) -> Response { + let (total_urls, active_links, dead_links) = { + let conn = state.content_db.lock().unwrap(); + get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + let total_pages = { + let conn = state.content_db.lock().unwrap(); + get_landing_page_count(&conn).unwrap_or(0) + }; + let (total_clicks, total_page_views) = { + let conn = state.analytics_db.lock().unwrap(); + ( + get_total_clicks(&conn).unwrap_or(0), + get_total_page_views(&conn).unwrap_or(0) + ) + }; + + Json(OverallStatsResponse { + total_urls, + total_pages, + total_clicks, + total_page_views, + active_links, + dead_links, + }).into_response() +} + +#[derive(Serialize)] +pub struct DetailStatsResponse { + pub target_id: String, + pub clicks: Vec<(String, i64)>, + pub top_countries: Vec<(String, i64)>, + pub top_referrers: Vec<(String, i64)>, + pub top_browsers: Vec<(String, i64)>, +} + +// GET /api/v1/stats/url/:uuid +pub async fn api_url_stats( + State(state): State, + _user: ApiUser, + Path(uuid): Path, +) -> Response { + // Verify URL exists + { + let conn = state.content_db.lock().unwrap(); + if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(); + } + } + + let conn = state.analytics_db.lock().unwrap(); + let clicks = get_clicks_trend(&conn, "url", &uuid, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30)) + .unwrap_or_default(); + let top_countries = get_metric_rankings(&conn, "url", &uuid, "country", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "country", 10)) + .unwrap_or_default(); + let top_referrers = get_metric_rankings(&conn, "url", &uuid, "referrer", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "referrer", 10)) + .unwrap_or_default(); + let top_browsers = get_metric_rankings(&conn, "url", &uuid, "browser", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "browser", 10)) + .unwrap_or_default(); + + Json(DetailStatsResponse { + target_id: uuid, + clicks, + top_countries, + top_referrers, + top_browsers, + }).into_response() +} + +// GET /api/v1/stats/page/:uuid +pub async fn api_page_stats( + State(state): State, + _user: ApiUser, + Path(uuid): Path, +) -> Response { + // Verify Page exists + { + let conn = state.content_db.lock().unwrap(); + if get_landing_page_by_id(&conn, &uuid).unwrap_or(None).is_none() { + return (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(); + } + } + + let conn = state.analytics_db.lock().unwrap(); + let clicks = get_clicks_trend(&conn, "page", &uuid, 30) + .or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30)) + .unwrap_or_default(); + let top_countries = get_metric_rankings(&conn, "page", &uuid, "country", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "country", 10)) + .unwrap_or_default(); + let top_referrers = get_metric_rankings(&conn, "page", &uuid, "referrer", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "referrer", 10)) + .unwrap_or_default(); + let top_browsers = get_metric_rankings(&conn, "page", &uuid, "browser", 10) + .or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "browser", 10)) + .unwrap_or_default(); + + Json(DetailStatsResponse { + target_id: uuid, + clicks, + top_countries, + top_referrers, + top_browsers, + }).into_response() +} diff --git a/src/web/middleware.rs b/src/web/middleware.rs new file mode 100644 index 0000000..b034bd7 --- /dev/null +++ b/src/web/middleware.rs @@ -0,0 +1 @@ +// General web middleware placeholder diff --git a/src/web/mod.rs b/src/web/mod.rs new file mode 100644 index 0000000..db17ea1 --- /dev/null +++ b/src/web/mod.rs @@ -0,0 +1,9 @@ +pub mod routes; +pub mod middleware; +pub mod redirect; +pub mod pages; +pub mod admin; +pub mod api; +pub mod system; + +pub use routes::create_router; diff --git a/src/web/pages.rs b/src/web/pages.rs new file mode 100644 index 0000000..de79905 --- /dev/null +++ b/src/web/pages.rs @@ -0,0 +1,76 @@ +use axum::{ + extract::{Path, State, ConnectInfo}, + http::{HeaderMap, StatusCode}, + response::{Response, Html, IntoResponse}, +}; +use std::net::SocketAddr; +use uuid::Uuid; +use chrono::Utc; + +use crate::state::AppState; +use crate::models::VisitRecord; +use crate::utils::get_client_ip; +use crate::analytics::get_client_country; +use crate::services::landing_pages::get_landing_page_by_code; + +// GET /p/:code and GET /p/:code/*slug +// Resolve and render landing page +pub async fn resolve_page( + State(state): State, + Path(code): Path, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::NOT_FOUND, "Not Found").into_response(); + } + + let page_opt = match get_landing_page_by_code(&state.db, &code) { + Ok(page) => page, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + match page_opt { + Some(page) => { + // Check state + if page.state == "archived" { + return (StatusCode::GONE, "This landing page has been archived").into_response(); + } + + // Record view analytics + let ip = get_client_ip(&headers, connect_info); + let country = get_client_country(&headers); + let user_agent = headers.get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Unknown") + .to_string(); + let referer = headers.get("referer") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Direct") + .to_string(); + let accept_language = headers.get("accept-language") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Unknown") + .to_string(); + + let record = VisitRecord { + id: Uuid::new_v4().to_string(), + target_type: "page".to_string(), + target_id: page.id.clone(), + timestamp: Utc::now().to_rfc3339(), + ip_address: ip, + user_agent, + referer, + accept_language, + country, + status_code: 200, + }; + + state.analytics_queue.push(record); + + // Render raw HTML + Html(page.html_content).into_response() + } + None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(), + } +} diff --git a/src/web/redirect.rs b/src/web/redirect.rs new file mode 100644 index 0000000..f0530db --- /dev/null +++ b/src/web/redirect.rs @@ -0,0 +1,73 @@ +use axum::{ + extract::{Path, State, ConnectInfo}, + http::{HeaderMap, StatusCode}, + response::{Redirect, Response, IntoResponse}, +}; +use std::net::SocketAddr; +use uuid::Uuid; +use chrono::Utc; + +use crate::state::AppState; +use crate::models::VisitRecord; +use crate::utils::get_client_ip; +use crate::analytics::get_client_country; +use crate::services::shortener::get_url_by_code; + +// GET /:code +// Resolve and redirect +pub async fn resolve_redirect( + State(state): State, + Path(code): Path, + headers: HeaderMap, + connect_info: Option>, +) -> Response { + // Basic validation of code (must be 6 hex characters) + if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::NOT_FOUND, "Not Found").into_response(); + } + + let url_opt = match get_url_by_code(&state.db, &code) { + Ok(url) => url, + Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), + }; + + match url_opt { + Some(url) => { + // Asynchronously record analytics + let ip = get_client_ip(&headers, connect_info); + let country = get_client_country(&headers); + let user_agent = headers.get("user-agent") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Unknown") + .to_string(); + let referer = headers.get("referer") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Direct") + .to_string(); + let accept_language = headers.get("accept-language") + .and_then(|h| h.to_str().ok()) + .unwrap_or("Unknown") + .to_string(); + + let record = VisitRecord { + id: Uuid::new_v4().to_string(), + target_type: "url".to_string(), + target_id: url.id.clone(), + timestamp: Utc::now().to_rfc3339(), + ip_address: ip, + user_agent, + referer, + accept_language, + country, + status_code: 302, + }; + + // Push to memory queue (non-blocking) + state.analytics_queue.push(record); + + // Perform redirect + Redirect::temporary(&url.destination).into_response() + } + None => (StatusCode::NOT_FOUND, "Short code not found").into_response(), + } +} diff --git a/src/web/routes.rs b/src/web/routes.rs new file mode 100644 index 0000000..8ed85fa --- /dev/null +++ b/src/web/routes.rs @@ -0,0 +1,55 @@ +use axum::{ + Router, + routing::{get, post}, +}; +use crate::state::AppState; +use crate::web::{redirect, pages, admin, api, system}; + +pub fn create_router(state: AppState) -> Router { + Router::new() + // --- Public Redirection Routes --- + .route("/:code", get(redirect::resolve_redirect)) + .route("/p/:code", get(pages::resolve_page)) + .route("/p/:code/*slug", get(pages::resolve_page)) + + // --- System Health & Diagnostics --- + .route("/status", get(system::status_endpoint)) + .route("/metrics", get(system::metrics_endpoint)) + + // --- Admin UI Login/Logout --- + .route("/admin", get(admin::admin_index)) + .route("/admin/login", get(admin::login_get).post(admin::login_post)) + .route("/admin/logout", get(admin::logout)) + + // --- Admin UI Pages --- + .route("/admin/dashboard", get(admin::dashboard_get)) + .route("/admin/urls", get(admin::urls_get)) + .route("/admin/urls/create", post(admin::urls_create)) + .route("/admin/urls/delete/:id", post(admin::urls_delete)) + .route("/admin/pages", get(admin::pages_get)) + .route("/admin/pages/create", post(admin::pages_create)) + .route("/admin/pages/delete/:id", post(admin::pages_delete)) + .route("/admin/settings", get(admin::settings_get)) + .route("/admin/settings/password", post(admin::change_password_post)) + .route("/admin/settings/retention", post(admin::change_retention_post)) + .route("/admin/settings/compact", post(admin::compact_db_post)) + .route("/admin/settings/backup", get(admin::download_backup)) + .route("/admin/settings/api-keys/create", post(admin::create_api_key_post)) + .route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post)) + .route("/admin/audit", get(admin::audit_get)) + .route("/admin/status", get(admin::status_get)) + + // --- REST API v1 JSON Endpoints --- + .route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls)) + .route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url)) + .route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages)) + .route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page)) + .route("/api/v1/stats", get(api::api_overall_stats)) + .route("/api/v1/stats/url/:uuid", get(api::api_url_stats)) + .route("/api/v1/stats/page/:uuid", get(api::api_page_stats)) + + // --- Static Asset Stub --- + .route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") })) + + .with_state(state) +} diff --git a/src/web/system.rs b/src/web/system.rs new file mode 100644 index 0000000..6c9144a --- /dev/null +++ b/src/web/system.rs @@ -0,0 +1,172 @@ +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::{IntoResponse, Response, Json}, +}; +use axum_extra::extract::CookieJar; +use serde::Serialize; + +use crate::db::admin::get_user_count; +use crate::state::AppState; +use crate::utils::{get_memory_usage, get_db_file_info}; +use crate::auth::{authenticate_session, authenticate_api_key}; + +// Helper: authenticate system request via header or session cookie +fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool { + // 1. Try Authorization header + if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) { + let conn = state.admin_db.lock().unwrap(); + if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) { + return true; + } + } + + // 2. Try cookie session + let conn = state.admin_db.lock().unwrap(); + if let Ok(Some(_)) = authenticate_session(&conn, jar) { + return true; + } + + false +} + +#[derive(Serialize)] +pub struct StatusResponse { + pub application: &'static str, + pub database: String, + pub queue_size: usize, + pub memory_usage: String, + pub uptime_seconds: u64, + pub version: &'static str, + pub git_commit: &'static str, +} + +// GET /status +pub async fn status_endpoint( + State(state): State, + jar: CookieJar, + headers: HeaderMap, +) -> Response { + if !authenticate_request(&state, &jar, &headers) { + // Return public basic status for container/load-balancer health checks + return ( + StatusCode::OK, + Json(serde_json::json!({ "application": "Healthy" })) + ).into_response(); + } + + let is_db_ok = { + let conn = state.admin_db.lock().unwrap(); + get_user_count(&conn).is_ok() + }; + + let db_status = if is_db_ok { + format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir)) + } else { + "Disconnected".to_string() + }; + + let uptime = state.start_time.elapsed().as_secs(); + + Json(StatusResponse { + application: "Healthy", + database: db_status, + queue_size: 0, + memory_usage: get_memory_usage(), + uptime_seconds: uptime, + version: "0.1.0", + git_commit: "unknown", + }).into_response() +} + +// GET /metrics +pub async fn metrics_endpoint( + State(state): State, + jar: CookieJar, + headers: HeaderMap, +) -> Response { + if !authenticate_request(&state, &jar, &headers) { + return StatusCode::UNAUTHORIZED.into_response(); + } + + // 1. Gather stats from DBs + let (total_urls, active_links, dead_links) = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0)) + }; + + let total_pages = { + let conn = state.content_db.lock().unwrap(); + crate::db::content::get_landing_page_count(&conn).unwrap_or(0) + }; + + let (total_clicks, total_page_views) = { + let conn = state.analytics_db.lock().unwrap(); + ( + crate::db::analytics::get_total_clicks(&conn).unwrap_or(0), + crate::db::analytics::get_total_page_views(&conn).unwrap_or(0) + ) + }; + + // Calculate memory in bytes + let mut mem_bytes = 0; + if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") { + if let Some(pages_str) = statm.split_whitespace().next() { + if let Ok(pages) = pages_str.parse::() { + mem_bytes = pages * 4096; + } + } + } + + let uptime = state.start_time.elapsed().as_secs(); + + // 2. Format as Prometheus metrics text + let metrics_text = format!( + r#"# HELP bzod_urls_total Total number of registered short URLs +# TYPE bzod_urls_total gauge +bzod_urls_total {total_urls} + +# HELP bzod_active_urls Total number of active/healthy short URLs +# TYPE bzod_active_urls gauge +bzod_active_urls {active_links} + +# HELP bzod_dead_urls Total number of dead short URLs +# TYPE bzod_dead_urls gauge +bzod_dead_urls {dead_links} + +# HELP bzod_pages_total Total number of registered landing pages +# TYPE bzod_pages_total gauge +bzod_pages_total {total_pages} + +# HELP bzod_clicks_total Total number of URL clicks recorded +# TYPE bzod_clicks_total counter +bzod_clicks_total {total_clicks} + +# HELP bzod_page_views_total Total number of page views recorded +# TYPE bzod_page_views_total counter +bzod_page_views_total {total_page_views} + +# HELP bzod_memory_bytes Memory usage of the bzod process in bytes +# TYPE bzod_memory_bytes gauge +bzod_memory_bytes {mem_bytes} + +# HELP bzod_uptime_seconds Uptime of the bzod process in seconds +# TYPE bzod_uptime_seconds counter +bzod_uptime_seconds {uptime} +"#, + total_urls = total_urls, + active_links = active_links, + dead_links = dead_links, + total_pages = total_pages, + total_clicks = total_clicks, + total_page_views = total_page_views, + mem_bytes = mem_bytes, + uptime = uptime + ); + + ( + StatusCode::OK, + [("Content-Type", "text/plain; version=0.0.4; charset=utf-8")], + metrics_text, + ).into_response() +} diff --git a/templates/audit.html b/templates/audit.html new file mode 100644 index 0000000..7ec9437 --- /dev/null +++ b/templates/audit.html @@ -0,0 +1,71 @@ +{% extends "layout.html" %} + +{% block title %}Audit Logs - BZOD{% endblock %} + +{% block active_audit %}active{% endblock %} + +{% block header_title %}Security Audit Logs{% endblock %} + +{% block content %} +
+
+

+ + System Action Log +

+
+ +
+ + + + + + + + + + + + {% if logs.is_empty() %} + + + + {% else %} + {% for log in logs %} + + + + + + + + {% endfor %} + {% endif %} + +
TimestampActionOperator (User)Source IP AddressLog Details / Context
+ No audit records logged yet. +
+ {{ log.timestamp[0..19].replace("T", " ") }} + + + {{ log.action }} + + + {{ log.username }} + + {{ log.ip_address.as_deref().unwrap_or("Unknown") }} + + {% if let Some(obj_type) = log.object_type %} + Type: {{ obj_type }} + {% endif %} + {% if let Some(obj_id) = log.object_id %} + | ID: {{ obj_id }} + {% endif %} + {% if let Some(ua) = log.user_agent %} +
UA: {{ ua }}
+ {% endif %} +
+
+
+{% endblock %} diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..61989c9 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,80 @@ +{% extends "layout.html" %} + +{% block title %}Dashboard - BZOD{% endblock %} + +{% block active_dashboard %}active{% endblock %} + +{% block header_title %}Dashboard Overview{% endblock %} + +{% block content %} + +
+
+ Total Short URLs + {{ total_urls }} +
+
+ Total Landing Pages + {{ total_pages }} +
+
+ Total Visits / Clicks + {{ total_clicks }} +
+
+ Active / Dead Links +
+ {{ active_links }} + / + {{ dead_links }} +
+
+
+ + +
+

+ + Click Traffic Trend (Last 30 Days) +

+
+ {{ traffic_chart|safe }} +
+
+ + +
+ +
+

+ + Geographic Analysis (Top Countries) +

+
+ {{ countries_chart|safe }} +
+
+ + +
+

+ + Referrer Channels (Top Referrers) +

+
+ {{ referrers_chart|safe }} +
+
+ + +
+

+ + User Agent breakdown (Top Browsers) +

+
+ {{ browsers_chart|safe }} +
+
+
+{% endblock %} diff --git a/templates/layout.html b/templates/layout.html new file mode 100644 index 0000000..97842a3 --- /dev/null +++ b/templates/layout.html @@ -0,0 +1,441 @@ + + + + + + {% block title %}BZOD Admin{% endblock %} + + + + + + + + +
+
+

{% block header_title %}{% endblock %}

+
+ {% block header_actions %}{% endblock %} +
+
+ + {% block content %}{% endblock %} +
+ + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..380c899 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,174 @@ + + + + + + Login - BZOD Platform + + + + + + + + diff --git a/templates/pages.html b/templates/pages.html new file mode 100644 index 0000000..58a2278 --- /dev/null +++ b/templates/pages.html @@ -0,0 +1,137 @@ +{% extends "layout.html" %} + +{% block title %}Manage Landing Pages - BZOD{% endblock %} + +{% block active_pages %}active{% endblock %} + +{% block header_title %}Landing Page Registry{% endblock %} + +{% block content %} +{% if let Some(err) = error %} +
+ {{ err }} +
+{% endif %} + +
+ +
+

+ + Create a New Landing Page +

+ +
+ + +
+
+ + +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+ + +
+
+ + +
+
+

+ + Registered Pages +

+
+ +
+ + + + + + + + + + + + + {% if pages.is_empty() %} + + + + {% else %} + {% for page in pages %} + + + + + + + + + {% endfor %} + {% endif %} + +
Page TitleShort PathSEO Preview PathStatusCreatedAction
+ No landing pages registered. Create one to get started! +
+
+ {{ page.title }} +
+
+ UUID: {{ page.id[0..8] }}... +
+
+ + /p/{{ page.code }} + + + + /p/{{ page.code }}/{{ page.slug }} + + + + {{ page.state }} + + + {{ page.created_at[0..10] }} + +
+ + +
+
+
+
+
+{% endblock %} diff --git a/templates/settings.html b/templates/settings.html new file mode 100644 index 0000000..2326624 --- /dev/null +++ b/templates/settings.html @@ -0,0 +1,188 @@ +{% extends "layout.html" %} + +{% block title %}Settings - BZOD{% endblock %} + +{% block active_settings %}active{% endblock %} + +{% block header_title %}System Settings{% endblock %} + +{% block content %} +{% if let Some(msg) = success %} +
+ {{ msg }} +
+{% endif %} +{% if let Some(err) = error %} +
+ {{ err }} +
+{% endif %} + +
+ + +
+ + +
+

+ + Change Password +

+ +
+ + +
+ + +
+ +
+ + +
+ + +
+
+ + +
+

+ + Analytics Retention Policy +

+ +
+ + +
+ + +
+ +

+ Old visits logs are cleaned up daily. Changing this policy does not affect aggregated monthly/yearly summaries. +

+ + +
+
+ + +
+

+ + Maintenance & DB Utilities +

+ +
+
+
+ + +
+

+ Reclaims unused space and defragments all SQLite database files. +

+
+ +
+ + + Download Backup (.tar.gz) + +

+ Generates a tarball of admin.db, content.db, and analytics.db. +

+
+
+
+ +
+ + +
+
+

+ + REST API Tokens +

+
+ +
+
+ +
+ +
+ + +
+
+
+
+ +
+ + + + + + + + + + + {% if api_keys.is_empty() %} + + + + {% else %} + {% for key in api_keys %} + + + + + + + {% endfor %} + {% endif %} + +
Key DescriptionCreatedLast UsedAction
+ No active API tokens generated yet. +
+ {{ key.name }} +
+ Key Prefix: {{ key.key_hash[0..8] }}... +
+
+ {{ key.created_at[0..10] }} + + {% if let Some(last) = key.last_used_at %} + {{ last[0..16].replace("T", " ") }} + {% else %} + Never + {% endif %} + +
+ + +
+
+
+
+ +
+{% endblock %} diff --git a/templates/status_ui.html b/templates/status_ui.html new file mode 100644 index 0000000..e4c026b --- /dev/null +++ b/templates/status_ui.html @@ -0,0 +1,69 @@ +{% extends "layout.html" %} + +{% block title %}System Diagnostics - BZOD{% endblock %} + +{% block active_status %}active{% endblock %} + +{% block header_title %}Server Status & Diagnostics{% endblock %} + +{% block content %} +
+ + +
+

+ + System Status +

+ +
+
+ Application Status + {{ app_status }} +
+ +
+ Uptime + {{ uptime }} +
+ +
+ Memory Usage + {{ memory_usage }} +
+ +
+ Analytics Memory Queue Size + + {{ queue_size }} items + +
+ +
+ Build Version + v{{ version }} +
+ +
+ Git Commit Hash + {{ git_commit }} +
+
+
+ + +
+

+ + Database Status +

+ +
{{ db_status }}
+ +

+ All SQLite databases are running with Write-Ahead Logging (WAL) enabled, providing concurrent reads and high transactional throughput. +

+
+ +
+{% endblock %} diff --git a/templates/urls.html b/templates/urls.html new file mode 100644 index 0000000..13d25a3 --- /dev/null +++ b/templates/urls.html @@ -0,0 +1,146 @@ +{% extends "layout.html" %} + +{% block title %}Manage Short URLs - BZOD{% endblock %} + +{% block active_urls %}active{% endblock %} + +{% block header_title %}Short URL Registry{% endblock %} + +{% block content %} +{% if let Some(err) = error %} +
+ {{ err }} +
+{% endif %} + +
+ +
+

+ + Shorten a New URL +

+ +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ + +
+
+

+ + Registered Links +

+ {% if let Some(tag) = tag_filter %} + + Filtered by tag: {{ tag }} + × + + {% endif %} +
+ +
+ + + + + + + + + + + + + {% if urls.is_empty() %} + + + + {% else %} + {% for url in urls %} + + + + + + + + + {% endfor %} + {% endif %} + +
Short LinkDestinationHealthTagsCreatedAction
+ No shortened URLs registered. Create one to get started! +
+ + bzo.in/{{ url.code }} + +
+ {{ url.title.as_deref().unwrap_or("") }} +
+
+ + {{ url.destination }} + + {% if let Some(desc) = url.description.as_deref() %} + {% if !desc.is_empty() %} +
+ {{ desc }} +
+ {% endif %} + {% endif %} +
+ + {{ url.status }} + + +
+ {% for t in url.tags %} + + {{ t }} + + {% endfor %} +
+
+ {{ url.created_at[0..10] }} + +
+ + +
+
+
+
+
+{% endblock %} diff --git a/tests/security_tests.rs b/tests/security_tests.rs new file mode 100644 index 0000000..374229e --- /dev/null +++ b/tests/security_tests.rs @@ -0,0 +1,150 @@ +use rusqlite::Connection; +use chrono::Utc; +use axum_extra::extract::CookieJar; +use axum_extra::extract::cookie::Cookie; + +use bzod::auth::{ + verify_csrf, generate_csrf_token, authenticate_session, authenticate_api_key, + hash_password, verify_sha256, verify_password +}; +use bzod::db::admin::{ + create_user, create_session, get_user_count, create_api_key +}; +use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS}; + +// Helper to set up an in-memory admin.db connection with migrations applied +fn setup_test_db() -> Connection { + let mut conn = Connection::open_in_memory().unwrap(); + run_migrations(&mut conn, "admin", ADMIN_MIGRATIONS, None).unwrap(); + conn +} + +#[test] +fn test_csrf_tampering_prevention() { + let session_id = "secret_session_id_123456"; + let valid_token = generate_csrf_token(session_id); + + // Mismatched token must fail + assert!(!verify_csrf(session_id, "different_token_value")); + + // Valid token must pass + assert!(verify_csrf(session_id, &valid_token)); + + // Mismatched session id must fail even if token matches the original session id + assert!(!verify_csrf("different_session_id_789", &valid_token)); +} + +#[test] +fn test_api_key_sql_injection_resistance() { + let conn = setup_test_db(); + + // Create an API key + let user_hash = hash_password("admin_pass").unwrap(); + let user = create_user(&conn, "admin", &user_hash).unwrap(); + + // Generate valid API key + let key_secret = "bzo_validkey1234567890abcdef"; + use sha2::{Sha256, Digest}; + let mut hasher = Sha256::new(); + hasher.update(key_secret.as_bytes()); + let hashed_key = hex::encode(hasher.finalize()); + create_api_key(&conn, &user.id, "my-key", &hashed_key).unwrap(); + + // 1. Test valid key passes + let valid_auth = format!("Bearer {}", key_secret); + let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap(); + assert!(auth_res.is_some()); + assert_eq!(auth_res.unwrap().username, "admin"); + + // 2. Test SQL Injection attempt in the header does not succeed or crash + let sql_inj_auth1 = "Bearer ' OR 1=1 --"; + let res = authenticate_api_key(&conn, sql_inj_auth1).unwrap(); + assert!(res.is_none()); + + let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --"; + let res = authenticate_api_key(&conn, sql_inj_auth2).unwrap(); + assert!(res.is_none()); + + // 3. Test malformed header + let malformed_auth = "Bearer"; + let res = authenticate_api_key(&conn, malformed_auth).unwrap(); + assert!(res.is_none()); + + let wrong_scheme = "Basic admin:pass"; + let res = authenticate_api_key(&conn, wrong_scheme).unwrap(); + assert!(res.is_none()); +} + +#[test] +fn test_expired_session_invalidation() { + let conn = setup_test_db(); + + let user_hash = hash_password("pass").unwrap(); + let user = create_user(&conn, "admin", &user_hash).unwrap(); + + // 1. Session in the future must be valid + let future_expiry = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339(); + let session_id_future = "future_session_token"; + create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap(); + + let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future)); + let auth_future = authenticate_session(&conn, &jar_future).unwrap(); + assert!(auth_future.is_some()); + assert_eq!(auth_future.unwrap().0.id, user.id); + + // 2. Session in the past must be rejected + let past_expiry = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339(); + let session_id_past = "expired_session_token"; + create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap(); + + let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past)); + let auth_past = authenticate_session(&conn, &jar_past).unwrap(); + assert!(auth_past.is_none()); +} + +#[test] +fn test_bootstrap_credentials_deactivation() { + let conn = setup_test_db(); + + let bootstrap_sha = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; // SHA-256 of "admin" + + // 1. Initially, no users exist in database + assert_eq!(get_user_count(&conn).unwrap(), 0); + // Bootstrap validation is allowed + assert!(verify_sha256("admin", bootstrap_sha)); + + // 2. Provision a user in database (either via bootstrap login or CLI) + let user_hash = hash_password("new_secure_admin_password").unwrap(); + create_user(&conn, "admin", &user_hash).unwrap(); + + // Check that database now has users + assert_eq!(get_user_count(&conn).unwrap(), 1); + + // Standard credential validation must pass + let user_opt = bzod::db::admin::get_user_by_username(&conn, "admin").unwrap(); + assert!(user_opt.is_some()); + assert!(verify_password("new_secure_admin_password", &user_opt.unwrap().password_hash)); + + // The bootstrap credentials MUST be ignored now (the application logic checks users count, + // which is 1, so it bypasses the bootstrap check and verifies ONLY against the database). +} + +#[test] +fn test_path_traversal_rejection() { + // Standard Axum router exact path matching prevents path traversal on endpoints. + // If a request has /../admin, standard HTTP parsers and Axum router resolve it as /admin + // (which checks session cookies) or return 404 for unresolved paths. + // Here we verify that code inputs containing traversal strings are parsed as invalid codes. + + let invalid_codes = vec!["../foo", "..%2ff", "/admin", "a/b/c", "1234567"]; + + for code in invalid_codes { + // Validate redirect code must be exactly 6 hex digits + let is_valid_redirect_code = code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit()); + assert!(!is_valid_redirect_code, "Code '{}' should be rejected as a valid redirect shortcode", code); + + // Validate landing page code must be exactly 4 hex digits + let is_valid_page_code = code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit()); + assert!(!is_valid_page_code, "Code '{}' should be rejected as a valid landing page shortcode", code); + } +}