28 Commits
Author SHA1 Message Date
thakares 84c48fa5c1 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:35:59 +05:30
thakares 0e111d61ff docs: add Docker deployment guide 2026-06-14 21:02:36 +05:30
thakares 81eb8950dd ci: add automatic Docker image push to GHCR on main 2026-06-14 20:53:32 +05:30
thakares 914563e883 ci: fix Docker image loading in CI health check 2026-06-14 20:49:59 +05:30
thakares 8b521f1a71 ci: fix YAML structure in GitHub workflow 2026-06-14 20:46:55 +05:30
thakares f1d72c8cbe Update rust.yml 2026-06-14 20:40:22 +05:30
thakares d9979ba04c docs: refine README and project documentation 2026-06-14 19:50:12 +05:30
thakares 6f42b43608 docs: refine README and project documentation 2026-06-14 19:44:36 +05:30
thakares 3e9dc47604 docs: refine README and project documentation 2026-06-14 19:43:46 +05:30
thakares e2e61fc412 docs: refine README and project documentation 2026-06-14 19:42:35 +05:30
thakares a0a73b918c docs: refine README and project documentation 2026-06-14 19:36:29 +05:30
thakares d2174aa111 docs: refine README and project documentation 2026-06-14 19:26:21 +05:30
thakares 6a45474e97 docs: refine README and project documentation 2026-06-14 19:24:38 +05:30
thakares c6486763e3 Add custom slugs, restore UI, UTM builder, and CLI link tools 2026-06-14 19:11:21 +05:30
thakares 02a26cfd94 style: fix cargo fmt issues in pages.rs and root_landing_tests.rs 2026-06-13 22:29:03 +05:30
thakares 3c0a1bdd91 ci: enhance GitHub workflow with Docker build + better Rust CI 2026-06-13 22:24:37 +05:30
thakares ee6d3135d5 docker: optimize build cache with better layer ordering 2026-06-13 22:20:25 +05:30
thakares 671370571a chore: add .dockerignore for cleaner Docker builds 2026-06-13 22:13:28 +05:30
thakares 42a2df33dd Add root landing page support and package static assets 2026-06-13 21:47:14 +05:30
thakares 80038fb851 docs: improve testing guide and add README documentation links 2026-06-13 18:48:26 +05:30
thakares 9c5e3e4d58 Add TESTING.md with validation and recovery procedures 2026-06-13 18:35:05 +05:30
thakares 27c4ad6805 Update README for v0.2.0 feature set 2026-06-12 20:17:18 +05:30
thakares 592154e961 Update README for v0.2.0 feature set 2026-06-12 20:13:22 +05:30
thakares bcbcc90d98 Add QR codes, link expiry, password protection, previews, audit trail and bulk operations 2026-06-12 19:57:53 +05:30
thakares 157aa81252 Add static web assets 2026-06-11 22:12:12 +05:30
thakares a55f40dc29 Fix Docker build for Rust 2024 edition 2026-06-11 22:11:09 +05:30
thakares a516ecedb0 Update README with screenshots 2026-06-11 20:52:28 +05:30
thakares 50718e57f0 Add admin interface screenshots 2026-06-11 20:49:01 +05:30
112 changed files with 9859 additions and 951 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Dependencies & Build artifacts
target/
**/target/
# Environment & secrets
.env
.env.*
*.key
*.pem
# Development & testing files
.git/
.gitignore
.github/
.gitattributes
# Documentation & notes
README*.md
docs/
CONTRIBUTING.md
CHANGELOG.md
LICENSE
# Logs & temporary files
*.log
*.tmp
data/
backups/
# Editor & IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Docker related
Dockerfile*
.dockerignore
docker-compose*.yml
.docker/
# Test files
tests/
__tests__/
*.test.*
*.spec.*
# Other unnecessary files
node_modules/
dist/
build/
+65 -8
View File
@@ -1,4 +1,4 @@
name: Rust name: Rust CI
on: on:
push: push:
@@ -8,15 +8,72 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
docker:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write # Required for GHCR push
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build
run: cargo build --verbose - name: Log in to GitHub Container Registry
- name: Run tests uses: docker/login-action@v3
run: cargo test --verbose with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1
View File
@@ -5,3 +5,4 @@ data/
*.db-shm *.db-shm
.env .env
.idea/
Generated
+709 -1
View File
@@ -29,6 +29,24 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -94,6 +112,26 @@ version = "1.0.102"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -106,6 +144,21 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "askama" name = "askama"
version = "0.12.1" version = "0.12.1"
@@ -147,7 +200,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [ dependencies = [
"nom", "nom 7.1.3",
] ]
[[package]] [[package]]
@@ -173,6 +226,49 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom 8.0.0",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.7.9" version = "0.7.9"
@@ -193,6 +289,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@@ -285,12 +382,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitstream-io"
version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
dependencies = [
"no_std_io2",
]
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.10.6"
@@ -309,12 +421,30 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytemuck"
version = "1.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
@@ -333,7 +463,10 @@ dependencies = [
"clap", "clap",
"dotenvy", "dotenvy",
"flate2", "flate2",
"futures-util",
"hex", "hex",
"image",
"qrcode",
"rand 0.8.6", "rand 0.8.6",
"reqwest", "reqwest",
"rusqlite", "rusqlite",
@@ -347,6 +480,7 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip",
] ]
[[package]] [[package]]
@@ -356,6 +490,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"jobserver",
"libc",
"shlex", "shlex",
] ]
@@ -425,6 +561,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -466,6 +608,37 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -485,6 +658,17 @@ dependencies = [
"powerfmt", "powerfmt",
] ]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@@ -513,6 +697,12 @@ version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -522,6 +712,26 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -538,6 +748,21 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -556,6 +781,21 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]] [[package]]
name = "filetime" name = "filetime"
version = "0.2.29" version = "0.2.29"
@@ -612,6 +852,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@@ -625,6 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
@@ -680,6 +932,27 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -972,6 +1245,46 @@ dependencies = [
"icu_properties", "icu_properties",
] ]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -984,6 +1297,17 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -996,12 +1320,31 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jobserver"
version = "0.1.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
dependencies = [
"getrandom 0.3.4",
"libc",
]
[[package]] [[package]]
name = "js-sys" name = "js-sys"
version = "0.3.100" version = "0.3.100"
@@ -1025,12 +1368,28 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@@ -1075,6 +1434,15 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1096,6 +1464,16 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.1"
@@ -1145,6 +1523,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -1162,6 +1550,21 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1172,6 +1575,21 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1181,12 +1599,53 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1242,6 +1701,18 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1260,6 +1731,19 @@ version = "0.3.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@@ -1303,6 +1787,55 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "profiling"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
dependencies = [
"profiling-procmacros",
]
[[package]]
name = "profiling-procmacros"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
dependencies = [
"quote",
"syn",
]
[[package]]
name = "pxfm"
version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "qrcode"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
dependencies = [
"image",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1438,6 +1971,76 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -1502,6 +2105,12 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -1725,6 +2334,15 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1842,6 +2460,20 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -2157,6 +2789,17 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -2326,6 +2969,12 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -2660,6 +3309,12 @@ dependencies = [
"rustix", "rustix",
] ]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -2763,8 +3418,61 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "zip"
version = "2.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
dependencies = [
"arbitrary",
"crc32fast",
"crossbeam-utils",
"displaydoc",
"flate2",
"indexmap",
"memchr",
"thiserror",
"zopfli",
]
[[package]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.21" version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
"zune-core",
]
+5 -2
View File
@@ -5,7 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["cookie"] } axum-extra = { version = "0.9", features = ["cookie"] }
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -26,5 +26,8 @@ chrono = { version = "0.4", features = ["serde"] }
hex = "0.4" hex = "0.4"
time = "0.3" time = "0.3"
toml = "0.8" toml = "0.8"
qrcode = "0.14"
image = "0.25"
zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3"
+31 -25
View File
@@ -1,7 +1,7 @@
# ========================================== # ==========================================
# Stage 1: Build # Stage 1: Builder (with optimized caching)
# ========================================== # ==========================================
FROM rust:1.82-slim-bookworm AS builder FROM rust:1.89-bookworm AS builder
WORKDIR /app WORKDIR /app
@@ -9,33 +9,33 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy configuration files # Copy only Cargo files first (best caching)
COPY Cargo.toml ./ COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them # Create dummy source for dependency caching
RUN mkdir src && echo "fn main() {}" > src/main.rs RUN mkdir -p src && \
RUN cargo build --release echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
RUN rm -rf src cargo build --release && \
rm -rf src target/release/deps/bzod*
# Copy source and templates # Copy real source code + assets
COPY src ./src COPY src ./src
COPY templates ./templates COPY templates ./templates
COPY www ./www
# Trigger rebuilding with actual source # Build the real application
RUN touch src/main.rs
RUN cargo build --release RUN cargo build --release
# ========================================== # ==========================================
# Stage 2: Runner # Stage 2: Runtime (slim)
# ========================================== # ==========================================
FROM debian:bookworm-slim FROM debian:bookworm-slim
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Runtime dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
openssl \ openssl \
ca-certificates \ ca-certificates \
@@ -45,23 +45,29 @@ RUN apt-get update && apt-get install -y \
# Copy binary from builder # Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Create non-root user and data directory # Copy assets
RUN groupadd -g 10001 bzod && \ COPY --from=builder /app/templates ./templates
useradd -u 10001 -g bzod -m -s /bin/bash bzod COPY --from=builder /app/www ./www
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data # Create non-root user
RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod
# Create data directory
RUN mkdir -p /app/data && \
chown -R bzod:bzod /app
USER bzod USER bzod
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data \
ENV PORT=8080 PORT=8654 \
ENV HOST=0.0.0.0 HOST=0.0.0.0 \
ENV COOKIE_SECURE=true COOKIE_SECURE=true
EXPOSE 8080 EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1 CMD curl -f http://localhost:${PORT}/status || exit 1
ENTRYPOINT ["bzod"] ENTRYPOINT ["bzod"]
CMD ["serve"] CMD ["serve"]
+459 -70
View File
@@ -1,10 +1,57 @@
# nx9-url-shortner # nx9-url-shortener (the BZOD daemon)
A lightweight, self-hosted URL shortener and landing page platform written in Rust. **A lightweight, self-hosted URL management 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. BZOD combines URL shortening, landing pages, QR code generation, password-protected links, smart preview pages, analytics, audit logging, lifecycle management, backup/restore, and API automation into a single self-hosted application with zero external service dependencies.
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. Built with Rust, SQLite, Axum, and Askama, BZOD is designed for individuals, organizations, homelab operators, government agencies, and businesses that want complete ownership of their links, analytics, and branding.
---
## Highlights
### v0.3.0
* Human-readable custom slugs
* Root landing page support
* Landing page custom slugs
* UTM campaign builder
* Built-in backup and restore
* CLI shorten command
* CLI expand command
* QR code generation (PNG/SVG)
* Password-protected links
* Smart preview pages
* Analytics dashboard
* Audit logging
* Health monitoring
---
## Feature Matrix
| Feature | Status |
|---------------------------| ------ |
| URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ |
| QR Analytics | ✅ |
| Password-Protected Links | ✅ |
| Smart Preview Pages | ✅ |
| Link Expiration | ✅ |
| One/Multi-Time Links | ✅ |
| Audit Trail | ✅ |
| Analytics Dashboard | ✅ |
| Health Monitoring | ✅ |
| REST API | ✅ |
| Backup & Restore | ✅ |
| UTM Campaign Builder | ✅ |
| CLI Automation | ✅ |
| Geo Analytics | 🚧 |
| Multi-User Administration | 🚧 |
| SSO | 🚧 |
--- ---
@@ -12,32 +59,207 @@ Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing p
### URL Shortening ### URL Shortening
Create short links using compact hexadecimal identifiers. Create compact short URLs using automatically generated hexadecimal identifiers.
Example: Example:
```text ```text
https://<your-domain>/1bb170 https://your-domain/1bb170
```
Redirects to:
```text
https://very-long-domain-name.com
``` ```
--- ---
## Design Principles
### Landing Pages BZOD is intentionally designed around a few principles:
Create standalone landing pages using dedicated page identifiers. - Self-hosted first
- SQLite-first architecture
- Minimal operational complexity
- Recovery over convenience
- Human-readable administration
- No external service dependencies
- No vendor lock-in
Example: Features are added only when they improve usability without increasing architectural complexity.
### Custom Slugs
Create memorable human-readable links.
Examples:
```text ```text
https://<your-domain>/p/1a2b https://your-domain/!office
https://your-domain/!home
https://your-domain/!site
https://your-domain/!project-alpha
``` ```
Features:
* Case-insensitive uniqueness
* Lowercase normalization
* Human-readable URLs
* No database schema changes
* Fully compatible with existing short codes
Examples:
```text
!office
!home
!warehouse
!meeting-room
!client_a
```
## Practical Examples
Generated URL
```
https://bzo.in/1b926e
```
Custom Slug
```
https://bzo.in/!office
```
Landing Page
```
https://bzo.in/p/!company-profile
```
---
## CLI
```
bzod shorten https://example.com
bzod shorten https://example.com --slug !office
bzod expand !office
```
---
### Landing Pages
Create standalone landing pages hosted directly by BZOD.
Generated page:
```text
https://your-domain/p/1a2b
```
Custom slug page:
```text
https://your-domain/p/!company-profile
https://your-domain/p/!product-launch
```
Features:
* Raw HTML support
* SEO slug support
* Published / Draft states
* Custom paths
* Open Graph metadata
---
### CLI Commands
Core commands
```bash
bzod serve
bzod create-admin
bzod doctor
bzod stats
```
URL management
```bash
bzod shorten https://example.com
bzod shorten https://example.com --slug !campaign
bzod expand 1bb170
bzod expand !campaign
```
Maintenance
```bash
bzod backup
bzod restore --file backup_20250614.tar.gz
bzod migrate
```
### QR Code Generation
Generate QR codes for every short URL.
Supported formats:
* PNG
* SVG
Features:
* Downloadable QR assets
* QR scan analytics
* Bulk QR export
* Print-friendly SVG output
---
### Password-Protected Links
Protect sensitive links using Argon2id password hashing.
Features:
* Password gate
* Secure session handling
* Access restrictions
* Audit logging
---
### Smart Preview Pages
Display branded preview pages before redirecting visitors.
Features:
* Custom title
* Description
* Logo support
* Open Graph metadata
* Social sharing previews
---
### Link Lifecycle Management
Control link validity.
Features:
* Expiration dates
* One-time links
* Access limits
* Administrative disable
* Automated expiry jobs
---
### UTM Campaign Builder
Append campaign tracking parameters when creating links.
Supported parameters:
```text
utm_source
utm_medium
utm_campaign
```
Example output:
```text
https://example.com/page?utm_source=email&utm_medium=newsletter&utm_campaign=launch
```
No additional database schema changes are required.
--- ---
### Analytics ### Analytics
@@ -45,7 +267,8 @@ https://<your-domain>/p/1a2b
Track: Track:
* Total visits * Total visits
* Country statistics * QR scans
* Countries
* Referrers * Referrers
* User agents * User agents
* Daily statistics * Daily statistics
@@ -54,37 +277,139 @@ Track:
--- ---
### Administrative Dashboard ### Audit Trail
Web-based administration interface featuring: Track administrative activity.
* URL management Recorded events include:
* Landing page management
* API token management * Login
* Audit logs * Logout
* Health checks * URL creation
* Analytics dashboard * URL updates
* SVG charts * URL deletion
* Backup creation
* Restore operations
* QR exports
* Configuration changes
--- ---
### API Support ### Administrative Dashboard
REST API endpoints for automation and integration. Web-based management interface.
Features:
* URL registry
* Landing pages
* QR management
* Analytics
* API token management
* Audit logs
* Backup utilities
* Restore utilities
* Health monitoring
* Server diagnostics
---
### REST API
REST API support for automation and integrations.
Endpoint prefix:
```text ```text
/api/v1/* /api/v1/*
``` ```
Supports:
* URL creation
* URL management
* Landing pages
* QR generation
* Analytics access
---
### CLI Automation
Create and manage links directly from the command line.
Examples:
Create automatic code:
```bash
bzod shorten https://example.com
```
Create custom slug:
```bash
bzod shorten https://example.com --slug !office
```
Expand code:
```bash
bzod expand 1bb170
```
Expand custom slug:
```bash
bzod expand !office
```
---
### Backup & Restore
BZOD includes integrated backup and restore functionality through both the CLI and Web UI.
CLI:
```bash
bzod backup
bzod restore --file backup.tar.gz
```
Web UI:
```text
Settings → Maintenance & DB Utilities
```
Features:
* Compressed tar.gz backups
* Full database restoration
* Backup validation
* Disaster recovery support
* No external tools required
Protected databases:
* admin.db
* content.db
* analytics.db
* system.db
--- ---
### Security ### Security
* Password-protected administration interface * Password-protected administration
* Session management * Password-protected links
* Argon2id password hashing
* CSRF protection * CSRF protection
* Session management
* API token authentication * API token authentication
* Audit logging * Audit logging
* Access controls
--- ---
@@ -103,8 +428,10 @@ No:
* React * React
* Node.js * Node.js
* Redis * Redis
* PostgreSQL
* MongoDB
* Kubernetes * Kubernetes
* External databases * SaaS dependencies
--- ---
@@ -112,45 +439,101 @@ No:
### Databases ### Databases
The application uses four SQLite databases. BZOD uses four SQLite databases.
| Database | Purpose | | Database | Purpose |
| ------------ | ---------------------------------------- | | ------------ | ------------------------------ |
| admin.db | Users, sessions, API keys, audit logs | | admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, tags | | content.db | URLs, landing pages, metadata |
| analytics.db | Visits and statistics | | analytics.db | Visits, QR scans, statistics |
| system.db | Jobs, migrations, backups, health checks | | system.db | Audit events, jobs, monitoring |
--- ---
## Default Credentials ## Initial Setup
Initial login: ### Native Installation
```text ```bash
Username: admin cargo run -- create-admin
Password: admin
``` ```
Change the password immediately after first login. ### Docker
```bash
docker exec -it bzod bzod create-admin
```
---
## Disaster Recovery Validation
The backup and restore system has been validated through a complete recovery workflow.
Validation procedure:
1. Create backup archive
2. Stop application
3. Restore backup
4. Restart application
5. Verify application integrity
Verified components:
* URL registry
* Landing pages
* Analytics
* Audit logs
* API tokens
* QR assets
* Settings
* Health monitoring
Expected outcome:
The application returns to a fully operational state without data loss.
---
## Screenshots
### Dashboard
![Dashboard](screenshots/dashboard.png)
### URL Management
![URL Management](screenshots/short-url-panel.png)
### Landing Pages
![Landing Pages](screenshots/landing-page-panel.png)
### Settings
![Settings](screenshots/settings.png)
### Server Status
![Server Status](screenshots/server-status.png)
--- ---
## Docker Deployment ## Docker Deployment
### Build Build:
```bash ```bash
docker compose build docker compose build
``` ```
### Start Start:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
### View Logs Logs:
```bash ```bash
docker logs -f bzod docker logs -f bzod
@@ -184,31 +567,25 @@ services:
## Development ## Development
### Build Build:
```bash ```bash
cargo build cargo build
``` ```
### Run Run:
```bash ```bash
cargo run -- serve cargo run -- serve
``` ```
### Run Migrations Create administrator:
```bash
cargo run -- migrate
```
### Create Admin User
```bash ```bash
cargo run -- create-admin cargo run -- create-admin
``` ```
### Run Tests Run tests:
```bash ```bash
cargo test cargo test
@@ -216,6 +593,18 @@ cargo test
--- ---
## Development & Testing
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure ## Project Structure
```text ```text
@@ -239,20 +628,17 @@ src/
Planned features: Planned features:
* QR code generation * Geo analytics
* Link expiration
* Link disabling
* CSV exports
* Bulk URL import
* GeoIP integration
* Multi-user administration * Multi-user administration
* SSO support * SSO integration
* Signed temporary links
* OpenAPI documentation
--- ---
## Production Deployment ## Production Deployment
Recommended stack: Recommended architecture:
```text ```text
Internet Internet
@@ -261,7 +647,7 @@ Internet
Nginx Proxy Manager Nginx Proxy Manager
│ │
▼ ▼
nx9-url-shortner BZOD
│ │
▼ ▼
SQLite SQLite
@@ -269,6 +655,12 @@ SQLite
HTTPS is strongly recommended. HTTPS is strongly recommended.
---
## Documentation
- [Testing Guide](docs/TESTING.md)
- [Docker Deployment Guide](docs/DOCKER-Deploy.md)
--- ---
## License ## License
@@ -281,7 +673,4 @@ Apache License 2.0
Sunil Purushottam Thakare Sunil Purushottam Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software. Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
=======
# nx9-url-shortener
A simple, self-hosted URL shortener built with Rust and SQLite: no Node.js, no Redis, no external dependencies.
+53 -14
View File
@@ -1,28 +1,67 @@
name: app-bzod
services: services:
bzod: bzod:
image: nx9-url-shortener:v0.1.0
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: bzod container_name: bzod
restart: unless-stopped environment:
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- HOST=0.0.0.0
- PORT=8654
- RUST_LOG=info
ports: ports:
- "8654:8654" - mode: ingress
target: 8654
published: "8654"
protocol: tcp
restart: unless-stopped
volumes: volumes:
- /DATA/AppData/bzod/data:/app/data - type: bind
- /DATA/AppData/bzod/config:/app/config source: /DATA/AppData/bzod/data
target: /app/data
bind:
create_host_path: true
environment: - type: bind
HOST: 0.0.0.0 source: /DATA/AppData/bzod/config
PORT: 8654 target: /app/config
DATA_DIR: /app/data bind:
COOKIE_SECURE: "false" create_host_path: true
healthcheck: networks:
test: ["CMD", "curl", "-f", "http://localhost:8654/status"] - default
interval: 30s
timeout: 5s hostname: bzod
retries: 3 privileged: false
cpu_shares: 90
deploy:
resources:
limits:
memory: 31940M
networks:
default:
name: app_default
x-casaos:
hostname: ""
scheme: http
index: /
port_map: "8654"
author: self
category: self
icon: ""
title:
custom: nx9-url-shortener
+520
View File
@@ -0,0 +1,520 @@
# Docker Deployment Guide for BZOD
This guide covers deployment, upgrades, backup, restore, troubleshooting, and production best practices for **BZOD (nx9-url-shortener)** using Docker.
---
# Overview
BZOD is a lightweight self-hosted URL shortener and landing page platform written in Rust.
Features include:
* URL shortening
* Human-readable custom slugs (`!office`, `!home`, etc.)
* Landing pages
* QR code generation
* Analytics
* Audit logging
* API access
* Backup and restore
* SQLite-based storage
* Docker deployment
BZOD is designed to remain simple:
* No PostgreSQL
* No Redis
* No external dependencies
* No vendor lock-in
---
# Quick Start
## Clone Repository
```bash
git clone https://github.com/thakares/nx9-url-shortener.git
cd nx9-url-shortener
```
## Build and Start
```bash
docker compose up -d --build
```
## Create Administrator
```bash
docker exec -it bzod bzod create-admin
```
Open:
```text
http://SERVER-IP:8654
```
Admin panel:
```text
http://SERVER-IP:8654/admin
```
---
# Docker Compose
Example:
```yaml
services:
bzod:
container_name: bzod
build: .
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
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "./bzod", "doctor"]
interval: 30s
timeout: 10s
retries: 3
```
Start:
```bash
docker compose up -d
```
Verify:
```bash
docker ps
docker logs -f bzod
```
---
# Directory Layout
Typical deployment:
```text
bzod/
├── docker-compose.yml
├── Dockerfile
├── config/
├── data/
│ ├── admin.db
│ ├── content.db
│ ├── analytics.db
│ └── system.db
└── backups/
```
---
# Root Landing Page
BZOD can serve a static landing page from:
```text
www/index.html
```
This page is available at:
```text
https://your-domain/
```
Examples:
```text
https://bzo.in/
https://short.example.com/
```
The root landing page is packaged automatically inside the Docker image.
---
# Reverse Proxy Configuration
BZOD is intended to run behind a reverse proxy.
Example Nginx configuration:
```nginx
server {
server_name bzo.in;
location / {
proxy_pass http://127.0.0.1:8654;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Example deployment:
```text
Internet
↓
Nginx Proxy Manager
↓
BZOD Docker Container
```
---
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
Production recommendation:
```text
COOKIE_SECURE=true
```
when HTTPS is enabled.
---
# Backup
## Web UI
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Click:
```text
Download Backup
```
A compressed archive containing all databases will be downloaded.
---
## CLI Backup
Create backup:
```bash
docker exec -it bzod bzod backup
```
Example output:
```text
backup-2026-06-14.tar.gz
```
---
# Restore
## Web UI Restore
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Upload:
```text
backup.tar.gz
```
Type:
```text
RESTORE
```
Confirm restore.
The system will:
1. Validate archive contents
2. Restore databases
3. Reinitialize database access
4. Redirect to login
---
## CLI Restore
Copy backup archive into container or mounted volume.
Run:
```bash
docker exec -it bzod bash
cd /app/data
bzod restore --file backup.tar.gz
```
---
# Disaster Recovery
Example recovery procedure:
```bash
docker compose down
# Restore backup archive
docker compose up -d
```
Verify:
```bash
docker exec -it bzod bzod doctor
docker exec -it bzod bzod validate
```
Check:
* URLs
* Landing pages
* Analytics
* Audit logs
* Settings
---
# Useful CLI Commands
Health:
```bash
docker exec -it bzod bzod doctor
```
Statistics:
```bash
docker exec -it bzod bzod stats
```
Validate databases:
```bash
docker exec -it bzod bzod validate
```
Create admin:
```bash
docker exec -it bzod bzod create-admin
```
Shorten URL:
```bash
docker exec -it bzod bzod shorten https://example.com
```
Custom slug:
```bash
docker exec -it bzod bzod shorten https://example.com --slug !office
```
Expand URL:
```bash
docker exec -it bzod bzod expand !office
```
---
# Upgrading
Pull latest source:
```bash
git pull
```
Rebuild:
```bash
docker compose build --no-cache
```
Restart:
```bash
docker compose up -d
```
Verify:
```bash
docker logs -f bzod
```
---
# Troubleshooting
## Read-Only SQLite Database
Symptoms:
```text
attempt to write a readonly database
```
Check ownership:
```bash
ls -lah data/
```
Fix permissions:
```bash
docker exec -u 0 -it bzod bash
chown -R bzod:bzod /app/data
```
Restart:
```bash
docker compose restart bzod
```
---
## Missing Root Landing Page
Symptoms:
```text
404 on /
```
Verify:
```bash
docker exec -it bzod ls -lah /app/www
```
Expected:
```text
/app/www/index.html
```
Rebuild image if necessary:
```bash
docker compose build --no-cache
docker compose up -d
```
---
## Health Check Failure
Inspect logs:
```bash
docker logs bzod
```
Run:
```bash
docker exec -it bzod bzod doctor
```
---
## Port Already In Use
Change host port mapping:
```yaml
ports:
- "8080:8654"
```
Access:
```text
http://SERVER-IP:8080
```
---
# Production Recommendations
* Use HTTPS
* Run behind Nginx Proxy Manager or Nginx
* Use strong administrator credentials
* Schedule regular backups
* Periodically test restore procedures
* Monitor disk space
* Keep Docker images updated
---
# Validation Checklist
After deployment verify:
* [ ] Admin login works
* [ ] URL shortening works
* [ ] Custom slugs work
* [ ] Landing pages work
* [ ] QR generation works
* [ ] Analytics recorded
* [ ] Backup download works
* [ ] Restore workflow works
* [ ] Root landing page loads
* [ ] `bzod doctor` reports healthy
A deployment should not be considered production-ready until backup and restore procedures have been successfully tested.
+421
View File
@@ -0,0 +1,421 @@
# TESTING.md
# BZOD Test Procedures
This document describes the official verification procedures for BZOD.
The objective is not merely to confirm that code compiles, but to ensure that the complete platform can be built, deployed, backed up, restored, migrated, and recovered successfully.
---
# Philosophy
BZOD prioritizes:
1. Data Integrity
2. Operational Simplicity
3. Recovery Capability
4. Deployment Reproducibility
5. Functional Correctness
A passing unit test suite alone is insufficient.
A release is considered valid only if backup, restore, migration, and recovery procedures have been verified.
---
# Test Categories
## 1. Build Verification
Verify the application compiles successfully.
```bash
cargo check
cargo build
cargo build --release
```
Expected Result:
* No compiler errors
* No panics during startup
* Release binary generated successfully
---
## 2. Static Analysis
```bash
cargo fmt --check
cargo clippy --all-targets
```
Expected Result:
* Formatting passes
* No significant Clippy warnings
---
## 3. Unit Tests
```bash
cargo test
```
Expected Result:
* All tests pass
* No ignored critical tests
---
## 4. Database Initialization
Create a clean environment.
```bash
rm -rf data
./bzod stats
```
Expected Result:
* Databases are automatically created
* Migrations applied successfully
Verify:
```bash
./bzod doctor
```
Expected Result:
```text
Overall status: HEALTHY
```
---
## 5. Migration Verification
Run migrations repeatedly.
```bash
./bzod migrate
./bzod migrate
./bzod migrate
```
Expected Result:
* No duplicate migrations
* No errors
* Schema remains stable
---
## 6. Administrator Creation
Create an administrator account.
```bash
./bzod create-admin
```
Expected Result:
* User created successfully
* Authentication works
Attempt duplicate creation:
```bash
./bzod create-admin
```
Expected Result:
* Duplicate username rejected
---
## 7. Backup Verification
Create backup archive.
```bash
./bzod backup
```
Expected Result:
* Backup archive generated
* Archive contains all databases
Verify:
```bash
tar -tzf backup-*.tar.gz
```
Expected Result:
```text
admin.db
content.db
analytics.db
system.db
```
---
## 8. Restore Verification
Create sample data.
Generate:
* Administrator
* URL records
* Landing pages
* Analytics records
Create backup:
```bash
./bzod backup
```
Delete databases:
```bash
rm -rf data
```
Restore:
```bash
./bzod restore --file backup.tar.gz
```
Expected Result:
* Restore completes successfully
* All records preserved
Verify:
```bash
./bzod doctor
./bzod stats
```
Expected Result:
```text
Overall status: HEALTHY
```
and original record counts preserved.
---
## 9. Disaster Recovery Scenario
1. Create backup
2. Stop container
3. Delete databases
4. Restore from backup
5. Fix permissions
6. Restart container
7. Validate:
- URLs
- Landing pages
- Audit logs
- Settings
- Analytics
- Status page
Expected Result:
System fully restored without data loss.
## 10. Disaster Recovery Test
This is the most important test.
Procedure:
1. Backup system.
2. Delete entire data directory.
3. Restore backup.
4. Start server.
5. Login to Admin UI.
Commands:
```bash
./bzod backup
rm -rf data
./bzod restore --file backup.tar.gz
./bzod serve
```
Expected Result:
* System fully operational
* No manual database repair required
---
## 11. Database Health Verification
Run:
```bash
./bzod doctor
```
Expected Result:
For every database:
```text
Integrity: ok
Foreign keys: enabled
Journal mode: wal
```
Final result:
```text
Overall status: HEALTHY
```
---
## 12. SQLite Integrity Checks
Manual verification.
```bash
sqlite3 data/admin.db "PRAGMA integrity_check;"
sqlite3 data/content.db "PRAGMA integrity_check;"
sqlite3 data/analytics.db "PRAGMA integrity_check;"
sqlite3 data/system.db "PRAGMA integrity_check;"
```
Expected Result:
```text
ok
```
for all databases.
---
## 13. Web Interface Verification
Start server.
```bash
./bzod serve
```
Verify:
* Homepage loads
* Redirects function
* Landing pages render
* Admin login works
* Dashboard loads
* API endpoints respond
---
## 14. Docker Verification
Build image.
```bash
docker compose build --no-cache
```
Start service.
```bash
docker compose up -d
```
Verify:
```bash
docker compose logs -f
```
Expected Result:
```text
Listening for requests
```
Verify:
```bash
./bzod doctor
```
inside container.
---
## 15. Upgrade Verification
1. Create backup.
2. Upgrade binary.
3. Run migration.
4. Start service.
```bash
./bzod backup
./bzod migrate
./bzod serve
```
Expected Result:
* Existing data preserved
* No migration failures
---
# Release Acceptance Criteria
A release is considered production-ready only if:
* Build verification passes
* Static analysis passes
* Unit tests pass
* Backup verification passes
* Restore verification passes
* Disaster recovery verification passes
* Doctor reports HEALTHY
* Docker deployment succeeds
* Web UI functions correctly
Failure of backup, restore, or disaster recovery tests is considered a release blocker.
---
# Guiding Principle
A successful release is not merely one that starts.
A successful release is one that can be recovered.
Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+67 -24
View File
@@ -1,6 +1,6 @@
use rusqlite::{Connection, params}; use crate::db::analytics::{clean_referrer, parse_ua};
use rusqlite::{params, Connection};
use std::collections::HashMap; use std::collections::HashMap;
use crate::db::analytics::{parse_ua, clean_referrer};
// Run aggregation for a specific day // Run aggregation for a specific day
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> { pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
@@ -10,7 +10,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;" "SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?; )?;
struct RawVisit { struct RawVisit {
target_type: String, target_type: String,
target_id: String, target_id: String,
@@ -18,7 +18,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
referer: String, referer: String,
country: String, country: String,
} }
let rows = stmt.query_map(params![date], |row| { let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit { Ok(RawVisit {
target_type: row.get(0)?, target_type: row.get(0)?,
@@ -28,7 +28,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
country: row.get(4)?, country: row.get(4)?,
}) })
})?; })?;
for r in rows { for r in rows {
visits.push(r?); visits.push(r?);
} }
@@ -46,7 +46,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits { for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent); let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer); let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() }; let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![ let targets = vec![
(v.target_type.clone(), v.target_id.clone()), (v.target_type.clone(), v.target_id.clone()),
@@ -55,22 +59,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets { for (t_type, t_id) in targets {
// Clicks // Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country // Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser // Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS // OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1; *aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device // Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer // Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
} }
} }
@@ -78,7 +119,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
// Delete old aggregates for this day // Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?; tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare( let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value) "INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -86,14 +130,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?; )?;
for ((t_type, t_id, m_type, m_key), value) in aggregates { for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![ insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
} }
} }
tx.commit()?; tx.commit()?;
@@ -108,7 +145,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> { pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?; tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute( tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value) "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) SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -125,7 +165,10 @@ pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> ru
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> { pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?; tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute( tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value) "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) SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AnalyticsEvent { pub enum AnalyticsEvent {
+6 -6
View File
@@ -1,10 +1,10 @@
pub mod aggregate;
pub mod events;
pub mod location;
pub mod queue; pub mod queue;
pub mod worker; 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}; pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
pub use events::AnalyticsEvent;
pub use location::get_client_country;
pub use queue::AnalyticsQueue;
+2 -2
View File
@@ -1,6 +1,6 @@
use tokio::sync::mpsc;
use crate::models::VisitRecord;
use crate::db::Db; use crate::db::Db;
use crate::models::VisitRecord;
use tokio::sync::mpsc;
#[derive(Clone)] #[derive(Clone)]
pub struct AnalyticsQueue { pub struct AnalyticsQueue {
+3 -3
View File
@@ -1,11 +1,11 @@
use std::time::Duration;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior}; use tokio::time::{interval, MissedTickBehavior};
use std::time::Duration; use tracing::{error, info};
use tracing::{info, error};
use crate::db::analytics::insert_visits_batch;
use crate::db::Db; use crate::db::Db;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::db::analytics::insert_visits_batch;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) { pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
let mut batch = Vec::new(); let mut batch = Vec::new();
+1 -1
View File
@@ -1,4 +1,4 @@
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
// Deterministic CSRF token derived from session token // Deterministic CSRF token derived from session token
pub fn generate_csrf_token(session_id: &str) -> String { pub fn generate_csrf_token(session_id: &str) -> String {
+6 -5
View File
@@ -1,10 +1,10 @@
use crate::auth::session::authenticate_api_key;
use crate::models::User;
use crate::state::AppState;
use axum::{ use axum::{
extract::{FromRequestParts, FromRef}, extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode}, 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 // Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User); pub struct ApiUser(pub User);
@@ -19,7 +19,8 @@ where
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> { async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state); let app_state = AppState::from_ref(state);
let auth_header = parts.headers let auth_header = parts
.headers
.get("Authorization") .get("Authorization")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
+4 -4
View File
@@ -1,9 +1,9 @@
pub mod password;
pub mod session;
pub mod csrf; pub mod csrf;
pub mod middleware; pub mod middleware;
pub mod password;
pub mod session;
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 csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser; pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{authenticate_api_key, authenticate_session, generate_token};
+7 -3
View File
@@ -1,21 +1,25 @@
use sha2::{Sha256, Digest};
use argon2::{ use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2, Argon2,
}; };
use sha2::{Digest, Sha256};
// Hashing password with Argon2id // Hashing password with Argon2id
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> { pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng); let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default(); let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string(); let password_hash = argon2
.hash_password(password.as_bytes(), &salt)?
.to_string();
Ok(password_hash) Ok(password_hash)
} }
// Verifying Argon2id password hash // Verifying Argon2id password hash
pub fn verify_password(password: &str, hash: &str) -> bool { pub fn verify_password(password: &str, hash: &str) -> bool {
if let Ok(parsed_hash) = PasswordHash::new(hash) { if let Ok(parsed_hash) = PasswordHash::new(hash) {
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok() Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
} else { } else {
false false
} }
+16 -14
View File
@@ -1,10 +1,12 @@
use sha2::{Sha256, Digest}; use crate::db::admin::{
use rand::{RngCore, thread_rng}; get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
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; use crate::models::User;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rand::{thread_rng, RngCore};
use rusqlite::Connection;
use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded) // Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String { pub fn generate_token(bytes_len: usize) -> String {
@@ -22,13 +24,13 @@ pub fn authenticate_session(
Some(c) => c, Some(c) => c,
None => return Ok(None), None => return Ok(None),
}; };
let session_id = cookie.value(); let session_id = cookie.value();
let session = match get_session(conn, session_id)? { let session = match get_session(conn, session_id)? {
Some(s) => s, Some(s) => s,
None => return Ok(None), None => return Ok(None),
}; };
// Check expiration // Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) { if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() { if expires.with_timezone(&Utc) < Utc::now() {
@@ -38,7 +40,7 @@ pub fn authenticate_session(
} else { } else {
return Ok(None); return Ok(None);
} }
// Get user // Get user
if let Some(user) = get_user_by_id(conn, &session.user_id)? { if let Some(user) = get_user_by_id(conn, &session.user_id)? {
Ok(Some((user, session.id))) Ok(Some((user, session.id)))
@@ -55,26 +57,26 @@ pub fn authenticate_api_key(
if !auth_header.starts_with("Bearer ") { if !auth_header.starts_with("Bearer ") {
return Ok(None); return Ok(None);
} }
let key = auth_header.trim_start_matches("Bearer ").trim(); let key = auth_header.trim_start_matches("Bearer ").trim();
if key.is_empty() { if key.is_empty() {
return Ok(None); return Ok(None);
} }
// Hash the API key using SHA-256 to compare with stored hash // Hash the API key using SHA-256 to compare with stored hash
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
hasher.update(key.as_bytes()); hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize()); let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? { if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
// Update last used timestamp // Update last used timestamp
update_api_key_last_used(conn, &api_key_rec.id)?; update_api_key_last_used(conn, &api_key_rec.id)?;
// Get user // Get user
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
return Ok(Some(user)); return Ok(Some(user));
} }
} }
Ok(None) Ok(None)
} }
+2 -2
View File
@@ -14,7 +14,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
let pad_left = 130.0; let pad_left = 130.0;
let pad_right = 70.0; let pad_right = 70.0;
let width = 600.0; let width = 600.0;
let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap; let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap;
let chart_w = width - pad_left - pad_right; let chart_w = width - pad_left - pad_right;
@@ -27,7 +27,7 @@ pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
for (i, (label, val)) in data.iter().enumerate() { for (i, (label, val)) in data.iter().enumerate() {
let y = pad_top + (i as f64 * (bar_height + gap)); let y = pad_top + (i as f64 * (bar_height + gap));
let bar_w = (*val as f64 / max_x) * chart_w; let bar_w = (*val as f64 / max_x) * chart_w;
let pct = if total_count > 0 { let pct = if total_count > 0 {
(*val as f64 / total_count as f64) * 100.0 (*val as f64 / total_count as f64) * 100.0
} else { } else {
+16 -4
View File
@@ -37,7 +37,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
// Coordinates calculations // Coordinates calculations
let count = data.len(); let count = data.len();
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w }; let step_x = if count > 1 {
chart_w / (count - 1) as f64
} else {
chart_w
};
let mut points = Vec::new(); let mut points = Vec::new();
for (i, &(_, val)) in data.iter().enumerate() { for (i, &(_, val)) in data.iter().enumerate() {
@@ -70,15 +74,23 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
for (i, (label, _)) in data.iter().enumerate() { for (i, (label, _)) in data.iter().enumerate() {
if i % label_step == 0 || i == count - 1 { if i % label_step == 0 || i == count - 1 {
let x = points[i].0; let x = points[i].0;
let short_label = if label.len() == 10 { &label[5..] } else { label }; let short_label = if label.len() == 10 {
&label[5..]
} else {
label
};
x_labels.push_str(&format!( x_labels.push_str(&format!(
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##, r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
)); ));
x_labels.push_str(&format!( x_labels.push_str(&format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##, r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR x,
pad_top + chart_h,
x,
pad_top + chart_h + 5.0,
DEFAULT_GRID_COLOR
)); ));
} }
} }
+3 -3
View File
@@ -1,10 +1,10 @@
pub mod svg;
pub mod line;
pub mod bar; pub mod bar;
pub mod line;
pub mod pie; pub mod pie;
pub mod svg;
pub mod timeseries; pub mod timeseries;
pub use line::generate_line_chart;
pub use bar::generate_bar_chart; pub use bar::generate_bar_chart;
pub use line::generate_line_chart;
pub use pie::generate_pie_chart; pub use pie::generate_pie_chart;
pub use timeseries::generate_timeseries_chart; pub use timeseries::generate_timeseries_chart;
+9 -5
View File
@@ -1,20 +1,24 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use crate::jobs::backup::perform_backup; use crate::jobs::backup::perform_backup;
use std::path::PathBuf;
use tracing::info;
pub async fn run( pub async fn run(
out: Option<String>, out: Option<String>,
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
if let Some(o) = out { config.backup_dir = PathBuf::from(o); } 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 // Init DB connections to ensure databases exist and migrate if needed
let db = Db::init(&config)?; let db = Db::init(&config)?;
info!("Starting database backup..."); info!("Starting database backup...");
let backup_path = perform_backup(&db, &config).await?; let backup_path = perform_backup(&db, &config).await?;
info!("Database backup generated successfully: {}", backup_path); info!("Database backup generated successfully: {}", backup_path);
+11 -6
View File
@@ -1,16 +1,18 @@
use std::path::PathBuf; use crate::auth::hash_password;
use std::io::{self, Write};
use tracing::{info, error};
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use crate::auth::hash_password; use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run( pub async fn run(
username: Option<String>, username: Option<String>,
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?; let db = Db::init(&config)?;
let final_username = match username { let final_username = match username {
@@ -32,7 +34,10 @@ pub async fn run(
let hash = hash_password(&password).map_err(|e| e.to_string())?; let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap(); let conn = db.admin.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
info!("Successfully created admin user: {} (ID: {})", u.username, u.id); info!(
"Successfully created admin user: {} (ID: {})",
u.username, u.id
);
Ok(()) Ok(())
} }
+30 -20
View File
@@ -1,8 +1,8 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config; use crate::config::Config;
use crate::db::sqlite; use crate::db::sqlite;
use rusqlite::Connection; use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
/// Run comprehensive database diagnostics. /// Run comprehensive database diagnostics.
/// ///
@@ -12,7 +12,9 @@ pub async fn run(
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Running BZOD database diagnostics..."); info!("Running BZOD database diagnostics...");
println!("BZOD Database Doctor"); println!("BZOD Database Doctor");
@@ -35,27 +37,35 @@ pub async fn run(
} }
match Connection::open(&db_path) { match Connection::open(&db_path) {
Ok(conn) => { Ok(conn) => match sqlite::collect_health_report(&conn, db_name) {
match sqlite::collect_health_report(&conn, db_name) { Ok(report) => {
Ok(report) => { println!("Database: {}", report.database);
println!("Database: {}", report.database); println!(" Path: {:?}", db_path);
println!(" Path: {:?}", db_path); println!(" Schema version: {}", report.schema_version);
println!(" Schema version: {}", report.schema_version); println!(" Journal mode: {}", report.journal_mode);
println!(" Journal mode: {}", report.journal_mode); println!(
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" }); " Foreign keys: {}",
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" }); if report.foreign_keys_enabled {
"enabled"
if !report.integrity_ok || !report.foreign_keys_enabled { } else {
all_healthy = false; "DISABLED"
} }
} );
Err(e) => { println!(
println!("Database: {}", db_name); " Integrity: {}",
println!(" Status: ERROR collecting health report: {}", e); if report.integrity_ok { "ok" } else { "FAILED" }
);
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false; all_healthy = false;
} }
} }
} Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
all_healthy = false;
}
},
Err(e) => { Err(e) => {
println!("Database: {}", db_name); println!("Database: {}", db_name);
println!(" Status: FAILED to open: {}", e); println!(" Status: FAILED to open: {}", e);
+32
View File
@@ -0,0 +1,32 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
code: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let normalized_code = code.trim().to_lowercase();
if !crate::utils::validation::validate_redirect_code(&normalized_code) {
return Err("Invalid short code or custom slug format".into());
}
let url_opt = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)?
};
match url_opt {
Some(url) => {
println!("{}", url.destination);
Ok(())
}
None => Err(format!("Short code not found: {}", normalized_code).into()),
}
}
+5 -3
View File
@@ -1,14 +1,16 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use std::path::PathBuf;
use tracing::info;
pub async fn run( pub async fn run(
data_dir: Option<String>, data_dir: Option<String>,
dry_run: bool, dry_run: bool,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if dry_run { if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied."); info!("Dry run enabled: pending database migrations will be reported but not applied.");
+24 -5
View File
@@ -1,13 +1,15 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
pub mod serve;
pub mod backup; pub mod backup;
pub mod restore;
pub mod migrate;
pub mod stats;
pub mod validate;
pub mod create_admin; pub mod create_admin;
pub mod doctor; pub mod doctor;
pub mod expand;
pub mod migrate;
pub mod restore;
pub mod serve;
pub mod shorten;
pub mod stats;
pub mod validate;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bzod")] #[command(name = "bzod")]
@@ -72,4 +74,21 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Shorten a URL (Feature 3)
Shorten {
/// The destination URL to shorten
target_url: String,
/// Custom slug (starting with ! followed by a-z, 0-9, -, _)
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Expand a shortened code or custom slug to its destination URL (Feature 4)
Expand {
/// The short code or custom slug to expand
code: String,
#[arg(long)]
data_dir: Option<String>,
},
} }
+53 -11
View File
@@ -1,17 +1,59 @@
use std::path::PathBuf; use crate::config::Config;
use flate2::read::GzDecoder;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
use tracing::{info, error}; use std::path::PathBuf;
use flate2::read::GzDecoder;
use tar::Archive; use tar::Archive;
use crate::config::Config; use tracing::{error, info};
pub fn perform_restore(
file_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive
let f = File::open(file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
// 2. Validate that the archive contains the expected BZOD database files
let mut has_admin = false;
let mut has_content = false;
let mut has_analytics = false;
let mut has_system = false;
for entry_res in archive.entries()? {
let entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"admin.db" => has_admin = true,
"content.db" => has_content = true,
"analytics.db" => has_analytics = true,
"system.db" => has_system = true,
_ => {}
}
}
if !has_admin || !has_content || !has_analytics || !has_system {
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
}
// 3. Unpack archive to data_dir
let f2 = File::open(file_path)?;
let tar_gz2 = GzDecoder::new(f2);
let mut archive2 = Archive::new(tar_gz2);
archive2.unpack(data_dir)?;
Ok(())
}
pub async fn run( pub async fn run(
file: String, file: String,
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file); let file_path = PathBuf::from(file);
if !file_path.exists() { if !file_path.exists() {
@@ -19,12 +61,15 @@ pub async fn run(
return Ok(()); return Ok(());
} }
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir); info!(
"WARNING: Restoring will overwrite existing databases in {:?}",
config.data_dir
);
print!("Are you sure you want to restore? (y/N): "); print!("Are you sure you want to restore? (y/N): ");
let _ = io::stdout().flush(); let _ = io::stdout().flush();
let mut confirm = String::new(); let mut confirm = String::new();
let _ = io::stdin().read_line(&mut confirm); let _ = io::stdin().read_line(&mut confirm);
if !confirm.trim().eq_ignore_ascii_case("y") { if !confirm.trim().eq_ignore_ascii_case("y") {
info!("Restore cancelled."); info!("Restore cancelled.");
return Ok(()); return Ok(());
@@ -35,10 +80,7 @@ pub async fn run(
} }
info!("Restoring backup from: {:?}", file_path); info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?; perform_restore(&file_path, &config.data_dir)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
info!("Database files successfully restored."); info!("Database files successfully restored.");
Ok(()) Ok(())
+21 -10
View File
@@ -1,11 +1,11 @@
use crate::analytics::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use crate::state::AppState;
use crate::web::create_router;
use std::path::PathBuf; use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use tracing::info; 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( pub async fn run(
host: Option<String>, host: Option<String>,
@@ -13,16 +13,22 @@ pub async fn run(
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(h) = host { config.host = h; } if let Some(h) = host {
if let Some(p) = port { config.port = p; } config.host = h;
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } }
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!("Starting BZOD server on {}:{}", config.host, config.port);
info!("Database directory: {:?}", config.data_dir); info!("Database directory: {:?}", config.data_dir);
// Init DBs // Init DBs
let db = Db::init(&config)?; let db = Db::init(&config)?;
// Init Queue // Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000); let queue = AnalyticsQueue::new(db.clone(), 1000);
@@ -52,6 +58,11 @@ pub async fn run(
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await; crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
}); });
let expiry_db = db.clone();
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),
@@ -67,7 +78,7 @@ pub async fn run(
let router = create_router(state); let router = create_router(state);
let addr = format!("{}:{}", config.host, config.port); let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Listening for requests on http://{}", addr); info!("Listening for requests on http://{}", addr);
axum::serve(listener, router).await?; axum::serve(listener, router).await?;
+73
View File
@@ -0,0 +1,73 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
target_url: String,
slug: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Basic URL validation
if reqwest::Url::parse(&target_url).is_err() {
return Err("Invalid destination URL format".into());
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// 2. Validate/normalize slug/code
let code = match slug {
Some(s) => {
let normalized = s.trim().to_lowercase();
if !crate::utils::validation::validate_custom_slug(&normalized) {
return Err(
"Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _"
.into(),
);
}
normalized
}
None => crate::utils::random::generate_token(3),
};
// 3. Persist URL
let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended(
&conn,
&code,
&target_url,
None,
None,
&[],
None,
None,
None,
);
match res {
Ok(_) => {
let proto = if config.cookie_secure {
"https"
} else {
"http"
};
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, config.port));
// Output only the shortened URL as requested
println!("{}/{}", base_url, code);
Ok(())
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err("Short code/slug already exists".into())
}
Err(e) => Err(e.into()),
}
}
+16 -6
View File
@@ -1,23 +1,30 @@
use std::path::PathBuf;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use std::path::PathBuf;
pub async fn run( pub async fn run(
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?; let db = Db::init(&config)?;
println!("=== BZOD Database Stats ==="); println!("=== BZOD Database Stats ===");
println!("Storage Directory: {:?}", config.data_dir); println!("Storage Directory: {:?}", config.data_dir);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files { for f in files {
let p = config.data_dir.join(f); let p = config.data_dir.join(f);
if p.exists() { if p.exists() {
let sz = std::fs::metadata(&p)?.len(); let sz = std::fs::metadata(&p)?.len();
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0); println!(
" File: {} - Size: {} bytes ({:.2} MB)",
f,
sz,
sz as f64 / 1_048_576.0
);
} }
} }
@@ -31,7 +38,10 @@ pub async fn run(
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)? crate::db::content::get_url_counts(&conn)?
}; };
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead); println!(
"Shortened URLs: {} total ({} active / {} dead)",
urls_total, urls_active, urls_dead
);
let pages_count = { let pages_count = {
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
+9 -7
View File
@@ -1,24 +1,26 @@
use std::path::PathBuf;
use tracing::info;
use reqwest::Client;
use std::time::Duration;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use reqwest::Client;
use std::path::PathBuf;
use std::time::Duration;
use tracing::info;
pub async fn run( pub async fn run(
data_dir: Option<String>, data_dir: Option<String>,
mut config: Config, mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); } if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?; let db = Db::init(&config)?;
info!("Running one-shot link validation..."); info!("Running one-shot link validation...");
let client = Client::builder() let client = Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.user_agent("bzod-cli-checker/0.1") .user_agent("bzod-cli-checker/0.1")
.build()?; .build()?;
crate::jobs::perform_link_check(&db, &client).await?; crate::jobs::perform_link_check(&db, &client).await?;
info!("Link validation complete."); info!("Link validation complete.");
+86 -27
View File
@@ -1,7 +1,7 @@
use std::path::PathBuf; use serde::Deserialize;
use std::env; use std::env;
use std::fs; use std::fs;
use serde::Deserialize; use std::path::PathBuf;
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Config { pub struct Config {
@@ -18,6 +18,7 @@ pub struct Config {
pub backup_enabled: bool, pub backup_enabled: bool,
pub backup_interval_mins: u64, pub backup_interval_mins: u64,
pub backup_dir: PathBuf, pub backup_dir: PathBuf,
pub base_url: Option<String>,
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
@@ -33,6 +34,7 @@ struct TomlConfig {
link_check_interval_mins: Option<u64>, link_check_interval_mins: Option<u64>,
aggregation_interval_mins: Option<u64>, aggregation_interval_mins: Option<u64>,
backup: Option<TomlBackupConfig>, backup: Option<TomlBackupConfig>,
base_url: Option<String>,
} }
#[derive(Deserialize, Default)] #[derive(Deserialize, Default)]
@@ -50,7 +52,8 @@ impl Config {
let mut data_dir = PathBuf::from("./data"); let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string(); let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".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 session_secret =
"bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut cookie_secure = true; let mut cookie_secure = true;
let mut data_retention_days = None; let mut data_retention_days = None;
let mut link_check_interval_mins = 60u64; let mut link_check_interval_mins = 60u64;
@@ -58,6 +61,7 @@ impl Config {
let mut backup_enabled = false; let mut backup_enabled = false;
let mut backup_interval_mins = 1440u64; // Default: once per day let mut backup_interval_mins = 1440u64; // Default: once per day
let mut backup_dir = PathBuf::from("./backups"); let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists // 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string(); let mut toml_path = "bzod.toml".to_string();
@@ -66,13 +70,27 @@ impl Config {
} }
if let Ok(toml_content) = fs::read_to_string(&toml_path) { if let Ok(toml_content) = fs::read_to_string(&toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) { if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host { host = h; } if let Some(h) = toml_config.host {
if let Some(p) = toml_config.port { port = p; } host = h;
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(p) = toml_config.port {
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; } port = p;
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(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 let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") { if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None; data_retention_days = None;
@@ -80,12 +98,25 @@ impl Config {
data_retention_days = Some(parsed); data_retention_days = Some(parsed);
} }
} }
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; } if let Some(lc) = toml_config.link_check_interval_mins {
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; } 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(b) = toml_config.backup {
if let Some(be) = b.enabled { backup_enabled = be; } if let Some(be) = b.enabled {
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; } backup_enabled = be;
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); } }
if let Some(bi) = b.interval_mins {
backup_interval_mins = bi;
}
if let Some(bo) = b.out_dir {
backup_dir = PathBuf::from(bo);
}
}
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
} }
} }
} }
@@ -97,16 +128,30 @@ impl Config {
} }
// 4. Load from Environment Variables (taking highest precedence) // 4. Load from Environment Variables (taking highest precedence)
if let Ok(h) = env::var("HOST") { host = h; } if let Ok(h) = env::var("HOST") {
host = h;
}
if let Ok(p_str) = env::var("PORT") { if let Ok(p_str) = env::var("PORT") {
if let Ok(p) = p_str.parse::<u16>() { port = p; } if let Ok(p) = p_str.parse::<u16>() {
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(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_str) = env::var("COOKIE_SECURE") {
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; } if let Ok(c) = c_str.parse::<bool>() {
cookie_secure = c;
}
} }
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") { if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
if ret_str.eq_ignore_ascii_case("unlimited") { if ret_str.eq_ignore_ascii_case("unlimited") {
@@ -116,18 +161,31 @@ impl Config {
} }
} }
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") { if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; } if let Ok(lc) = lc_str.parse::<u64>() {
link_check_interval_mins = lc;
}
} }
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") { if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; } if let Ok(ag) = ag_str.parse::<u64>() {
aggregation_interval_mins = ag;
}
} }
if let Ok(be_str) = env::var("BACKUP_ENABLED") { if let Ok(be_str) = env::var("BACKUP_ENABLED") {
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; } if let Ok(be) = be_str.parse::<bool>() {
backup_enabled = be;
}
} }
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") { if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; } if let Ok(bi) = bi_str.parse::<u64>() {
backup_interval_mins = bi;
}
}
if let Ok(bo_str) = env::var("BACKUP_DIR") {
backup_dir = PathBuf::from(bo_str);
}
if let Ok(bu) = env::var("BASE_URL") {
base_url = Some(bu);
} }
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
Self { Self {
host, host,
@@ -143,6 +201,7 @@ impl Config {
backup_enabled, backup_enabled,
backup_interval_mins, backup_interval_mins,
backup_dir, backup_dir,
base_url,
} }
} }
} }
+40 -25
View File
@@ -1,17 +1,21 @@
use rusqlite::{Connection, params}; use crate::models::{ApiKey, AuditLog, Session, User};
use uuid::Uuid;
use chrono::Utc; use chrono::Utc;
use crate::models::{User, Session, ApiKey, AuditLog}; use rusqlite::{params, Connection};
use uuid::Uuid;
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> { pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<User> {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
conn.execute( conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);", "INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, username, password_hash, created_at], params![id, username, password_hash, created_at],
)?; )?;
Ok(User { Ok(User {
id, id,
username: username.to_string(), username: username.to_string(),
@@ -21,9 +25,11 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
} }
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> { pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?; let mut stmt = conn.prepare(
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
)?;
let mut rows = stmt.query(params![username])?; let mut rows = stmt.query(params![username])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
Ok(Some(User { Ok(Some(User {
id: row.get(0)?, id: row.get(0)?,
@@ -37,9 +43,10 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
} }
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> { pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?; let mut stmt =
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut rows = stmt.query(params![id])?; let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
Ok(Some(User { Ok(Some(User {
id: row.get(0)?, id: row.get(0)?,
@@ -63,12 +70,12 @@ pub fn create_session(
expires_at_rfc3339: &str, expires_at_rfc3339: &str,
) -> rusqlite::Result<Session> { ) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
conn.execute( conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", "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], params![session_id, user_id, expires_at_rfc3339, created_at],
)?; )?;
Ok(Session { Ok(Session {
id: session_id.to_string(), id: session_id.to_string(),
user_id: user_id.to_string(), user_id: user_id.to_string(),
@@ -78,9 +85,10 @@ pub fn create_session(
} }
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> { pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?; 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])?; let mut rows = stmt.query(params![session_id])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
Ok(Some(Session { Ok(Some(Session {
id: row.get(0)?, id: row.get(0)?,
@@ -112,12 +120,12 @@ pub fn create_api_key(
) -> rusqlite::Result<ApiKey> { ) -> rusqlite::Result<ApiKey> {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
conn.execute( conn.execute(
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);", "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], params![id, user_id, key_hash, name, created_at],
)?; )?;
Ok(ApiKey { Ok(ApiKey {
id, id,
user_id: user_id.to_string(), user_id: user_id.to_string(),
@@ -133,7 +141,7 @@ pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Resul
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;" "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])?; let mut rows = stmt.query(params![key_hash])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
Ok(Some(ApiKey { Ok(Some(ApiKey {
id: row.get(0)?, id: row.get(0)?,
@@ -162,7 +170,7 @@ pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<A
last_used_at: row.get(5)?, last_used_at: row.get(5)?,
}) })
})?; })?;
let mut keys = Vec::new(); let mut keys = Vec::new();
for key in rows { for key in rows {
keys.push(key?); keys.push(key?);
@@ -177,7 +185,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> { pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?; conn.execute(
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(()) Ok(())
} }
@@ -192,13 +203,13 @@ pub fn write_audit_log(
) -> rusqlite::Result<AuditLog> { ) -> rusqlite::Result<AuditLog> {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
let timestamp = Utc::now().to_rfc3339(); let timestamp = Utc::now().to_rfc3339();
conn.execute( conn.execute(
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent) "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);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent], params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
)?; )?;
Ok(AuditLog { Ok(AuditLog {
id, id,
timestamp, timestamp,
@@ -211,10 +222,14 @@ pub fn write_audit_log(
}) })
} }
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> { pub fn list_audit_logs(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<AuditLog>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent "SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;" FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
)?; )?;
let rows = stmt.query_map(params![limit, offset], |row| { let rows = stmt.query_map(params![limit, offset], |row| {
Ok(AuditLog { Ok(AuditLog {
@@ -228,7 +243,7 @@ pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::
user_agent: row.get(7)?, user_agent: row.get(7)?,
}) })
})?; })?;
let mut logs = Vec::new(); let mut logs = Vec::new();
for log in rows { for log in rows {
logs.push(log?); logs.push(log?);
@@ -247,7 +262,7 @@ pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> { pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?; let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
let mut rows = stmt.query(params![key])?; let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
let val: String = row.get(0)?; let val: String = row.get(0)?;
Ok(Some(val)) Ok(Some(val))
+388 -68
View File
@@ -1,11 +1,11 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use rusqlite::{params, Connection};
use std::collections::HashMap;
// Custom User-Agent parser to avoid bloated dependencies // Custom User-Agent parser to avoid bloated dependencies
pub fn parse_ua(ua: &str) -> (String, String, String) { pub fn parse_ua(ua: &str) -> (String, String, String) {
let ua_lower = ua.to_lowercase(); let ua_lower = ua.to_lowercase();
let os = if ua_lower.contains("windows") { let os = if ua_lower.contains("windows") {
"Windows".to_string() "Windows".to_string()
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") { } else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Android".to_string() "Android".to_string()
} else if ua_lower.contains("linux") { } else if ua_lower.contains("linux") {
"Linux".to_string() "Linux".to_string()
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") { } else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
{
"iOS".to_string() "iOS".to_string()
} else { } else {
"Other".to_string() "Other".to_string()
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
"Other".to_string() "Other".to_string()
}; };
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") { let device = if ua_lower.contains("mobile")
|| ua_lower.contains("android")
|| ua_lower.contains("iphone")
|| ua_lower.contains("ipod")
{
"Mobile".to_string() "Mobile".to_string()
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") { } else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
"Tablet".to_string() "Tablet".to_string()
@@ -54,15 +59,17 @@ pub fn clean_referrer(referer: &str) -> String {
if referer.is_empty() || referer == "direct" { if referer.is_empty() || referer == "direct" {
return "Direct".to_string(); return "Direct".to_string();
} }
if let Ok(url) = reqwest::Url::parse(referer) { if let Ok(url) = reqwest::Url::parse(referer) {
if let Some(host) = url.host_str() { if let Some(host) = url.host_str() {
return host.trim_start_matches("www.").to_string(); return host.trim_start_matches("www.").to_string();
} }
} }
// Fallback if not a valid URL // Fallback if not a valid URL
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://"); let cleaned = referer
.trim_start_matches("https://")
.trim_start_matches("http://");
let cleaned = cleaned.split('/').next().unwrap_or("Direct"); let cleaned = cleaned.split('/').next().unwrap_or("Direct");
if cleaned.is_empty() { if cleaned.is_empty() {
"Direct".to_string() "Direct".to_string()
@@ -78,7 +85,7 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code) "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);" VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
)?; )?;
for r in records { for r in records {
stmt.execute(params![ stmt.execute(params![
r.id, r.id,
@@ -99,16 +106,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
} }
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> { pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0)) 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<i64> { pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0)) conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
[],
|row| row.get(0),
)
} }
// Get the date range of visits in the DB // Get the date range of visits in the DB
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> { pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?; let mut stmt =
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut rows = stmt.query([])?; let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
let min_date: Option<String> = row.get(0)?; let min_date: Option<String> = row.get(0)?;
@@ -128,7 +144,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;" "SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?; )?;
struct RawVisit { struct RawVisit {
target_type: String, target_type: String,
target_id: String, target_id: String,
@@ -136,7 +152,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
referer: String, referer: String,
country: String, country: String,
} }
let rows = stmt.query_map(params![date], |row| { let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit { Ok(RawVisit {
target_type: row.get(0)?, target_type: row.get(0)?,
@@ -146,7 +162,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
country: row.get(4)?, country: row.get(4)?,
}) })
})?; })?;
for r in rows { for r in rows {
visits.push(r?); visits.push(r?);
} }
@@ -156,7 +172,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
return Ok(()); return Ok(());
} }
// 2. Compute metrics in-memory // 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count // Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new(); let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
@@ -165,7 +180,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for v in visits { for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent); let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer); let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() }; let country = if v.country.is_empty() {
"Unknown".to_string()
} else {
v.country.clone()
};
let targets = vec![ let targets = vec![
(v.target_type.clone(), v.target_id.clone()), (v.target_type.clone(), v.target_id.clone()),
@@ -174,22 +193,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
for (t_type, t_id) in targets { for (t_type, t_id) in targets {
// Clicks // Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"clicks".to_string(),
"".to_string(),
))
.or_insert(0) += 1;
// Country // Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"country".to_string(),
country.clone(),
))
.or_insert(0) += 1;
// Browser // Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"browser".to_string(),
browser.clone(),
))
.or_insert(0) += 1;
// OS // OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1; *aggregates
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
.or_insert(0) += 1;
// Device // Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"device".to_string(),
device.clone(),
))
.or_insert(0) += 1;
// Referrer // Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1; *aggregates
.entry((
t_type.clone(),
t_id.clone(),
"referrer".to_string(),
referrer.clone(),
))
.or_insert(0) += 1;
} }
} }
@@ -197,7 +253,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
// Delete old aggregates for this day // Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?; tx.execute(
"DELETE FROM daily_summaries WHERE date = ?1;",
params![date],
)?;
let mut insert_stmt = tx.prepare( let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value) "INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
@@ -205,14 +264,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
)?; )?;
for ((t_type, t_id, m_type, m_key), value) in aggregates { for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![ insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
} }
} }
tx.commit()?; tx.commit()?;
@@ -227,7 +279,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> { fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?; tx.execute(
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
params![year_month],
)?;
tx.execute( tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value) "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) SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -244,7 +299,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> { fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?; tx.execute(
"DELETE FROM yearly_summaries WHERE year = ?1;",
params![year],
)?;
tx.execute( tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value) "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) SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
@@ -262,7 +320,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> { pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days); let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
let limit_str = limit_date.to_rfc3339(); let limit_str = limit_date.to_rfc3339();
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?; let count = conn.execute(
"DELETE FROM visits WHERE timestamp < ?1;",
params![limit_str],
)?;
Ok(count) Ok(count)
} }
@@ -274,18 +335,20 @@ pub fn get_clicks_trend(
target_id: &str, target_id: &str,
limit_days: i64, limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> { ) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string(); let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
.format("%Y-%m-%d")
.to_string();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT date, SUM(metric_value) FROM daily_summaries "SELECT date, SUM(metric_value) FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3 WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
GROUP BY date ORDER BY date ASC;" GROUP BY date ORDER BY date ASC;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
let mut res = Vec::new(); let mut res = Vec::new();
for r in rows { for r in rows {
res.push(r?); res.push(r?);
@@ -301,17 +364,17 @@ pub fn get_clicks_trend_raw(
limit_days: i64, limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> { ) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339(); let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT date(timestamp) as d, COUNT(*) FROM visits "SELECT date(timestamp) as d, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3 WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY d ORDER BY d ASC;" GROUP BY d ORDER BY d ASC;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
let mut res = Vec::new(); let mut res = Vec::new();
for r in rows { for r in rows {
res.push(r?); res.push(r?);
@@ -326,18 +389,18 @@ pub fn get_hourly_trend_raw(
limit_days: i64, limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> { ) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339(); let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
// SQLite strftime('%H', timestamp) extracts the hour // SQLite strftime('%H', timestamp) extracts the hour
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits "SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3 WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY h ORDER BY h ASC;" GROUP BY h ORDER BY h ASC;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| { let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
let mut res = Vec::new(); let mut res = Vec::new();
for r in rows { for r in rows {
res.push(r?); res.push(r?);
@@ -355,13 +418,13 @@ pub fn get_metric_rankings(
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries "SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3 WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;" GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| { let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
let mut res = Vec::new(); let mut res = Vec::new();
for r in rows { for r in rows {
res.push(r?); res.push(r?);
@@ -378,30 +441,32 @@ pub fn get_metric_rankings_raw(
) -> rusqlite::Result<Vec<(String, i64)>> { ) -> rusqlite::Result<Vec<(String, i64)>> {
// Falls back to direct query on visits // Falls back to direct query on visits
let mut res = Vec::new(); let mut res = Vec::new();
match metric_type { match metric_type {
"country" => { "country" => {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT country, COUNT(*) as c FROM visits "SELECT country, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2 WHERE target_type = ?1 AND target_id = ?2
GROUP BY country ORDER BY c DESC LIMIT ?3;" GROUP BY country ORDER BY c DESC LIMIT ?3;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| { let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
for r in rows { res.push(r?); } for r in rows {
res.push(r?);
}
} }
"referrer" => { "referrer" => {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT referer, COUNT(*) as c FROM visits "SELECT referer, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2 WHERE target_type = ?1 AND target_id = ?2
GROUP BY referer ORDER BY c DESC LIMIT ?3;" GROUP BY referer ORDER BY c DESC LIMIT ?3;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| { let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
let raw_ref: String = row.get(0)?; let raw_ref: String = row.get(0)?;
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?)) Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
})?; })?;
// Re-aggregate because clean_referrer might group different referrers // Re-aggregate because clean_referrer might group different referrers
let mut grouped: HashMap<String, i64> = HashMap::new(); let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows { for r in rows {
@@ -416,12 +481,12 @@ pub fn get_metric_rankings_raw(
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT user_agent, COUNT(*) as c FROM visits "SELECT user_agent, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2 WHERE target_type = ?1 AND target_id = ?2
GROUP BY user_agent;" GROUP BY user_agent;",
)?; )?;
let rows = stmt.query_map(params![target_type, target_id], |row| { let rows = stmt.query_map(params![target_type, target_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)) Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?; })?;
let mut grouped: HashMap<String, i64> = HashMap::new(); let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows { for r in rows {
let (ua, count) = r?; let (ua, count) = r?;
@@ -439,34 +504,289 @@ pub fn get_metric_rankings_raw(
} }
_ => {} _ => {}
} }
Ok(res) Ok(res)
} }
/// Returns the raw visit counts for a specific target ID.
pub fn get_target_visit_count(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Returns the distinct IP count (Unique Visitors) for a specific target ID.
pub fn get_target_unique_visitors(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(DISTINCT ip_address) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Fetches the monthly clicks trend for a specific target ID, falling back to a raw visits query if monthly_summaries are empty.
pub fn get_monthly_clicks_trend(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_months: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT year_month, SUM(metric_value) FROM monthly_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks'
GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
if res.is_empty() {
// Fallback to raw visits
let mut stmt = conn.prepare(
"SELECT strftime('%Y-%m', timestamp) as m, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY m ORDER BY m ASC LIMIT ?3;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows {
res.push(r?);
}
}
Ok(res)
}
pub fn get_visits_schema_columns(conn: &Connection) -> rusqlite::Result<std::collections::HashSet<String>> {
let mut columns = std::collections::HashSet::new();
let mut stmt = conn.prepare("PRAGMA table_info(visits);")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get("name")?;
columns.insert(name);
}
Ok(columns)
}
pub fn get_target_visits_paginated(
conn: &Connection,
target_type: &str,
target_id: &str,
limit: i64,
offset: i64,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d"))));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC LIMIT ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(limit));
sql.push_str(" OFFSET ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(offset));
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visits_all_in_memory(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d"))));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visit_total_filtered(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<i64> {
if date_from.is_none() && date_to.is_none() {
return get_target_visit_count(conn, target_type, target_id);
}
let mut sql = "SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", next_day.format("%Y-%m-%d"))));
}
}
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.query_row(&sql, rusqlite::params_from_iter(param_refs), |row| row.get(0))
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test] #[test]
fn test_parse_ua_browsers() { fn test_parse_ua_browsers() {
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0"; 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 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 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"; 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!(
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string())); parse_ua(firefox_linux),
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())); "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] #[test]
fn test_clean_referrer() { fn test_clean_referrer() {
assert_eq!(clean_referrer("direct"), "Direct"); assert_eq!(clean_referrer("direct"), "Direct");
assert_eq!(clean_referrer(""), "Direct"); assert_eq!(clean_referrer(""), "Direct");
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com"); assert_eq!(
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com"); 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"); assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
} }
} }
+74
View File
@@ -0,0 +1,74 @@
use crate::models::AuditEvent;
use chrono::Utc;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Write an audit event to the system.db audit_events table.
pub fn write_audit_event(
conn: &Connection,
actor: &str,
action: &str,
object_type: &str,
object_id: &str,
metadata: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![id, actor, action, object_type, object_id, now, metadata],
)?;
Ok(())
}
/// List audit events with optional filtering by actor or action.
pub fn list_audit_events(
conn: &Connection,
limit: i64,
offset: i64,
actor_filter: Option<&str>,
action_filter: Option<&str>,
) -> rusqlite::Result<Vec<AuditEvent>> {
let mut events = Vec::new();
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
(Some(actor), Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(Some(actor), None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
),
(None, Some(action)) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
),
(None, None) => (
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
vec![Box::new(limit), Box::new(offset)],
),
};
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
params_vec.iter().map(|p| p.as_ref()).collect();
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params_refs.as_slice(), |row| {
Ok(AuditEvent {
id: row.get(0)?,
actor: row.get(1)?,
action: row.get(2)?,
object_type: row.get(3)?,
object_id: row.get(4)?,
timestamp: row.get(5)?,
metadata: row.get(6)?,
})
})?;
for event in rows {
events.push(event?);
}
Ok(events)
}
+261 -94
View File
@@ -1,7 +1,7 @@
use rusqlite::{Connection, params}; use crate::models::Url;
use uuid::Uuid;
use chrono::Utc; use chrono::Utc;
use crate::models::{Url, LandingPage}; use rusqlite::{params, Connection};
use uuid::Uuid;
// Helper: Associate tags with a URL // Helper: Associate tags with a URL
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> { fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
@@ -11,20 +11,20 @@ fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite:
if tag_name.is_empty() { if tag_name.is_empty() {
continue; continue;
} }
// Insert tag if it doesn't exist // Insert tag if it doesn't exist
conn.execute( conn.execute(
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);", "INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
params![Uuid::new_v4().to_string(), tag_name], params![Uuid::new_v4().to_string(), tag_name],
)?; )?;
// Get tag id // Get tag id
let tag_id: String = conn.query_row( let tag_id: String = conn.query_row(
"SELECT id FROM tags WHERE name = ?1;", "SELECT id FROM tags WHERE name = ?1;",
params![tag_name], params![tag_name],
|row| row.get(0), |row| row.get(0),
)?; )?;
// Insert association // Insert association
conn.execute( conn.execute(
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);", "INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
@@ -47,6 +47,44 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags) Ok(tags)
} }
/// Get the total count of URLs associated with a specific tag name.
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
let tag_name = tag.trim().to_lowercase();
conn.query_row(
"SELECT COUNT(*) 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;",
params![tag_name],
|row| row.get(0),
)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
/// Build a Url struct from a row containing URL_COLUMNS in order.
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
Ok(Url {
id: row.get(0)?,
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)?,
expires_at: row.get(8)?,
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
password_hash: row.get(10)?,
last_status: row.get(11)?,
last_latency_ms: row.get(12)?,
max_access_count: row.get(13)?,
access_count: row.get::<_, i64>(14).unwrap_or(0),
tags: Vec::new(), // filled after query
})
}
pub fn create_url( pub fn create_url(
conn: &Connection, conn: &Connection,
code: &str, code: &str,
@@ -77,54 +115,84 @@ pub fn create_url(
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
tags: tags.to_vec(), tags: tags.to_vec(),
expires_at: None,
expired: false,
password_hash: None,
last_status: None,
last_latency_ms: None,
max_access_count: None,
access_count: 0,
})
}
/// Create a URL with all extended options.
#[allow(clippy::too_many_arguments)]
pub fn create_url_extended(
conn: &Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
expires_at: Option<&str>,
password_hash: Option<&str>,
max_access_count: Option<i64>,
) -> rusqlite::Result<Url> {
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, expires_at, password_hash, max_access_count)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
)?;
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(),
expires_at: expires_at.map(|s| s.to_string()),
expired: false,
password_hash: password_hash.map(|s| s.to_string()),
last_status: None,
last_latency_ms: None,
max_access_count,
access_count: 0,
}) })
} }
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> { pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare( let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;" let mut stmt = conn.prepare(&sql)?;
)?;
let mut rows = stmt.query(params![id])?; let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?; let mut url = url_from_row(row)?;
let tags = get_tags_for_url(conn, &url_id)?; url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(Url { 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 { } else {
Ok(None) Ok(None)
} }
} }
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> { pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare( let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;" let mut stmt = conn.prepare(&sql)?;
)?;
let mut rows = stmt.query(params![code])?; let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? { if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?; let mut url = url_from_row(row)?;
let tags = get_tags_for_url(conn, &url_id)?; url.tags = get_tags_for_url(conn, &url.id)?;
Ok(Some(Url { 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 { } else {
Ok(None) Ok(None)
} }
@@ -167,69 +235,60 @@ pub fn list_urls(
tag_filter: Option<&str>, tag_filter: Option<&str>,
) -> rusqlite::Result<Vec<Url>> { ) -> rusqlite::Result<Vec<Url>> {
let mut urls = Vec::new(); let mut urls = Vec::new();
if let Some(tag) = tag_filter { if let Some(tag) = tag_filter {
let tag_name = tag.trim().to_lowercase(); let tag_name = tag.trim().to_lowercase();
let mut stmt = conn.prepare( let sql = format!(
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at "SELECT u.{} FROM urls u
FROM urls u JOIN url_tags ut ON u.id = ut.url_id
JOIN url_tags ut ON u.id = ut.url_id JOIN tags t ON ut.tag_id = t.id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1 WHERE t.name = ?1
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;" ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
)?; URL_COLUMNS
let rows = stmt.query_map(params![tag_name, limit, offset], |row| { .replace("id,", "u.id,")
let url_id: String = row.get(0)?; .replace(", code", ", u.code")
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?)) .replace(", destination", ", u.destination")
})?; .replace(", title", ", u.title")
.replace(", description", ", u.description")
.replace(", status", ", u.status")
.replace(", created_at", ", u.created_at")
.replace(", updated_at", ", u.updated_at")
.replace(", expires_at", ", u.expires_at")
.replace(", expired", ", u.expired")
.replace(", password_hash", ", u.password_hash")
.replace(", last_status", ", u.last_status")
.replace(", last_latency_ms", ", u.last_latency_ms")
.replace(", max_access_count", ", u.max_access_count")
.replace(", access_count", ", u.access_count")
);
let mut stmt = conn.prepare(&sql)?;
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
for r in rows { for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?; let mut url = r?;
let tags = get_tags_for_url(conn, &url_id)?; url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(Url { urls.push(url);
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
} }
} else { } else {
let mut stmt = conn.prepare( let sql = format!(
"SELECT id, code, destination, title, description, status, created_at, updated_at "SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" URL_COLUMNS
)?; );
let rows = stmt.query_map(params![limit, offset], |row| { let mut stmt = conn.prepare(&sql)?;
let url_id: String = row.get(0)?; let rows = stmt.query_map(params![limit, offset], url_from_row)?;
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 { for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?; let mut url = r?;
let tags = get_tags_for_url(conn, &url_id)?; url.tags = get_tags_for_url(conn, &url.id)?;
urls.push(Url { urls.push(url);
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
} }
} }
Ok(urls) Ok(urls)
} }
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> { pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?; let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new(); let mut res = Vec::new();
for r in rows { for r in rows {
@@ -247,13 +306,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
Ok(()) Ok(())
} }
/// Update URL health with extended status and latency information.
pub fn update_url_health_extended(
conn: &Connection,
id: &str,
status: &str,
last_status: &str,
latency_ms: Option<i64>,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
params![status, last_status, latency_ms, now, id],
)?;
Ok(())
}
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> { 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 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 active: i64 = conn.query_row(
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?; "SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
[],
|row| row.get(0),
)?;
let dead: i64 = conn.query_row(
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
[],
|row| row.get(0),
)?;
Ok((total, active, dead)) Ok((total, active, dead))
} }
/// Mark all URLs with expires_at < now as expired.
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
params![now],
)?;
Ok(count)
}
/// Set a password hash on a URL.
pub fn set_url_password(
conn: &Connection,
id: &str,
password_hash: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
params![password_hash, now, id],
)?;
Ok(count > 0)
}
/// Remove the password from a URL.
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(count > 0)
}
/// Atomically increment the access count and return the new value.
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
conn.execute(
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
params![id],
)?;
conn.query_row(
"SELECT access_count FROM urls WHERE id = ?1;",
params![id],
|row| row.get(0),
)
}
/// Set expiry on a URL.
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
params![expires_at, now, id],
)?;
Ok(count > 0)
}
/// Get health status summary across all URLs.
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
)?;
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)
}
// --- Landing Page Operations (unchanged) ---
use crate::models::LandingPage;
pub fn create_landing_page( pub fn create_landing_page(
conn: &Connection, conn: &Connection,
code: &str, code: &str,
@@ -283,7 +440,10 @@ pub fn create_landing_page(
}) })
} }
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> { pub fn get_landing_page_by_id(
conn: &Connection,
id: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;" "SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
)?; )?;
@@ -305,7 +465,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
} }
} }
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> { pub fn get_landing_page_by_code(
conn: &Connection,
code: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;" "SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
)?; )?;
@@ -354,10 +517,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
Ok(count > 0) Ok(count > 0)
} }
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> { pub fn list_landing_pages(
conn: &Connection,
limit: i64,
offset: i64,
) -> rusqlite::Result<Vec<LandingPage>> {
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at "SELECT id, code, slug, title, html_content, state, created_at, updated_at
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;" FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
)?; )?;
let rows = stmt.query_map(params![limit, offset], |row| { let rows = stmt.query_map(params![limit, offset], |row| {
Ok(LandingPage { Ok(LandingPage {
+104 -11
View File
@@ -27,7 +27,12 @@ pub fn run_migrations(
if current_version < target_version { if current_version < target_version {
for m in migrations.iter().filter(|m| m.version > current_version) { for m in migrations.iter().filter(|m| m.version > current_version) {
info!(database = db_name, version = m.version, name = m.name, "Applying migration"); info!(
database = db_name,
version = m.version,
name = m.name,
"Applying migration"
);
let tx = conn.transaction()?; let tx = conn.transaction()?;
tx.execute_batch(m.sql)?; tx.execute_batch(m.sql)?;
@@ -35,7 +40,12 @@ pub fn run_migrations(
crate::db::sqlite::set_user_version(conn, m.version as i32)?; crate::db::sqlite::set_user_version(conn, m.version as i32)?;
info!(database = db_name, version = m.version, name = m.name, "Migration completed"); info!(
database = db_name,
version = m.version,
name = m.name,
"Migration completed"
);
// Write audit record to system.db.migrations // Write audit record to system.db.migrations
if let Some(sys_db_mutex) = system_db_opt { if let Some(sys_db_mutex) = system_db_opt {
@@ -58,7 +68,11 @@ pub fn run_migrations(
} }
} }
} else { } else {
info!(database = db_name, version = current_version, "Database up to date"); info!(
database = db_name,
version = current_version,
"Database up to date"
);
} }
Ok(()) Ok(())
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
println!(" Current version: {current_version}"); println!(" Current version: {current_version}");
println!(" Target version: {target_version}"); println!(" Target version: {target_version}");
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect(); let pending: Vec<&Migration> = migrations
.iter()
.filter(|m| m.version > current_version)
.collect();
if pending.is_empty() { if pending.is_empty() {
println!(" Status: up to date"); println!(" Status: up to date");
@@ -94,11 +111,10 @@ pub fn print_migration_plan(
// Migration definitions // Migration definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
Migration { version: 1,
version: 1, name: "initial_schema",
name: "initial_schema", sql: r#"
sql: r#"
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -140,8 +156,7 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
value TEXT NOT NULL value TEXT NOT NULL
); );
"#, "#,
}, }];
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[ pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration { Migration {
@@ -187,6 +202,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code); CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
"#, "#,
}, },
Migration {
version: 2,
name: "features_expansion",
sql: r#"
-- Expiring Links
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
-- Password Protected Links
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
-- Link Health Dashboard (extended columns)
ALTER TABLE urls ADD COLUMN last_status TEXT;
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
-- One-Time Links
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
-- Smart Landing Pages / Link Preview
CREATE TABLE IF NOT EXISTS link_preview (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL UNIQUE,
title TEXT,
description TEXT,
logo_url TEXT,
button_text TEXT DEFAULT 'Continue',
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
-- QR Code style metadata
CREATE TABLE IF NOT EXISTS qr_codes (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
style TEXT NOT NULL DEFAULT 'default',
created_at TEXT NOT NULL,
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
"#,
},
]; ];
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
@@ -241,6 +299,22 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id); CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
"#, "#,
}, },
Migration {
version: 2,
name: "qr_access_log",
sql: r#"
CREATE TABLE IF NOT EXISTS qr_access_log (
id TEXT PRIMARY KEY,
url_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ip TEXT,
user_agent TEXT
);
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#,
},
]; ];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -291,4 +365,23 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
); );
"#, "#,
}, },
Migration {
version: 2,
name: "audit_events",
sql: r#"
CREATE TABLE IF NOT EXISTS audit_events (
id TEXT PRIMARY KEY,
actor TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
metadata TEXT
);
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#,
},
]; ];
+48 -16
View File
@@ -1,15 +1,20 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs; use std::fs;
use std::sync::{Arc, Mutex}; 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 admin;
pub mod content;
pub mod analytics; pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod sqlite;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -53,13 +58,25 @@ impl Db {
enable_wal(&system_conn, "system")?; enable_wal(&system_conn, "system")?;
// Enable foreign key support // Enable foreign key support
info!(database = "admin", "Enabling foreign key enforcement on admin.db"); info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?; enable_foreign_keys(&admin_conn, "admin")?;
info!(database = "content", "Enabling foreign key enforcement on content.db"); info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?; enable_foreign_keys(&content_conn, "content")?;
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db"); info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?; enable_foreign_keys(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling foreign key enforcement on system.db"); info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?; enable_foreign_keys(&system_conn, "system")?;
// 1. Run migrations for system.db first, as it receives secondary audit records // 1. Run migrations for system.db first, as it receives secondary audit records
@@ -70,11 +87,26 @@ impl Db {
// 2. Run migrations for other databases with system.db logging // 2. Run migrations for other databases with system.db logging
info!("Running admin migrations"); info!("Running admin migrations");
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?; run_migrations(
&mut admin_conn,
"admin",
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations"); info!("Running content migrations");
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?; run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running analytics migrations"); info!("Running analytics migrations");
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?; run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&system_arc),
)?;
Ok(Self { Ok(Self {
admin: Arc::new(Mutex::new(admin_conn)), admin: Arc::new(Mutex::new(admin_conn)),
@@ -115,12 +147,12 @@ mod db_init_tests {
let mut config = Config::load(); let mut config = Config::load();
config.data_dir = temp_dir.clone(); config.data_dir = temp_dir.clone();
let db = Db::init(&config); let db = Db::init(&config);
// Cleanup // Cleanup
if temp_dir.exists() { if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir); let _ = std::fs::remove_dir_all(&temp_dir);
} }
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err()); assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
} }
} }
+62
View File
@@ -0,0 +1,62 @@
use crate::models::LinkPreview;
use rusqlite::{params, Connection};
use uuid::Uuid;
/// Insert or update a link preview for a URL.
pub fn upsert_preview(
conn: &Connection,
url_id: &str,
title: Option<&str>,
description: Option<&str>,
logo_url: Option<&str>,
button_text: Option<&str>,
) -> rusqlite::Result<LinkPreview> {
let id = Uuid::new_v4().to_string();
let btn = button_text.unwrap_or("Continue");
conn.execute(
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(url_id) DO UPDATE SET
title = excluded.title,
description = excluded.description,
logo_url = excluded.logo_url,
button_text = excluded.button_text;",
params![id, url_id, title, description, logo_url, btn],
)?;
// Return the current state (may have been an update with a different id)
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
}
/// Get the link preview for a URL.
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
let mut stmt = conn.prepare(
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
)?;
let mut rows = stmt.query(params![url_id])?;
if let Some(row) = rows.next()? {
Ok(Some(LinkPreview {
id: row.get(0)?,
url_id: row.get(1)?,
title: row.get(2)?,
description: row.get(3)?,
logo_url: row.get(4)?,
button_text: row
.get::<_, Option<String>>(5)?
.unwrap_or_else(|| "Continue".to_string()),
}))
} else {
Ok(None)
}
}
/// Delete the link preview for a URL.
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
let count = conn.execute(
"DELETE FROM link_preview WHERE url_id = ?1;",
params![url_id],
)?;
Ok(count > 0)
}
+90
View File
@@ -0,0 +1,90 @@
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use uuid::Uuid;
/// Log a QR code access event to the analytics database.
pub fn log_qr_access(
conn: &Connection,
url_id: &str,
ip: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<()> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, url_id, now, ip, user_agent],
)?;
Ok(())
}
/// Get QR scan count for a URL.
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
}
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
pub fn get_qr_stats_for_url(
conn: &Connection,
url_id: &str,
) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare(
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
)?;
let rows = stmt.query_map(params![url_id], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
))
})?;
let mut results = Vec::new();
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Create or update a QR code style registration in the content database.
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
// Check if entry already exists
let existing_id: Option<String> = conn
.query_row(
"SELECT id FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
if let Some(id) = existing_id {
conn.execute(
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
params![style, id],
)?;
} else {
let id = Uuid::new_v4().to_string();
conn.execute(
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, url_id, style, now],
)?;
}
Ok(())
}
/// Get the style configured for a QR code.
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
let style: Option<String> = conn
.query_row(
"SELECT style FROM qr_codes WHERE url_id = ?1;",
params![url_id],
|row| row.get(0),
)
.optional()?;
Ok(style.unwrap_or_else(|| "default".to_string()))
}
+9 -5
View File
@@ -14,8 +14,9 @@ use tracing::info;
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns /// 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. /// 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> { pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let actual_mode: String = let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?; row.get::<_, String>(0)
})?;
info!(database = db_name, mode = %actual_mode, "WAL mode configured"); info!(database = db_name, mode = %actual_mode, "WAL mode configured");
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
let enabled: bool = let enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured"); info!(
database = db_name,
foreign_keys = enabled,
"Foreign key enforcement configured"
);
if !enabled { if !enabled {
return Err(rusqlite::Error::QueryReturnedNoRows); return Err(rusqlite::Error::QueryReturnedNoRows);
@@ -183,8 +188,7 @@ mod tests {
#[test] #[test]
fn test_collect_health_report() { fn test_collect_health_report() {
let conn = memory_conn(); let conn = memory_conn();
let report = let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
collect_health_report(&conn, "test").expect("Failed to collect health report");
assert_eq!(report.database, "test"); assert_eq!(report.database, "test");
assert!(report.integrity_ok); assert!(report.integrity_ok);
} }
+60 -13
View File
@@ -1,6 +1,6 @@
use axum::{ use axum::{
response::{IntoResponse, Response},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response},
}; };
use std::fmt; use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
@@ -12,17 +12,36 @@ use std::path::PathBuf;
#[derive(Debug)] #[derive(Debug)]
pub enum DatabaseInitError { pub enum DatabaseInitError {
/// Data directory could not be created or accessed /// Data directory could not be created or accessed
DataDirCreate { path: PathBuf, source: std::io::Error }, DataDirCreate {
path: PathBuf,
source: std::io::Error,
},
/// SQLite connection could not be opened /// SQLite connection could not be opened
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error }, ConnectionOpen {
database: String,
path: PathBuf,
source: rusqlite::Error,
},
/// PRAGMA configuration failed (WAL, foreign_keys, etc.) /// PRAGMA configuration failed (WAL, foreign_keys, etc.)
PragmaConfig { database: String, pragma: String, source: rusqlite::Error }, PragmaConfig {
database: String,
pragma: String,
source: rusqlite::Error,
},
/// Migration execution failed /// Migration execution failed
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> }, MigrationFailed {
database: String,
version: u32,
name: String,
source: Box<dyn std::error::Error + Send + Sync>,
},
/// Database integrity check failed /// Database integrity check failed
IntegrityCheckFailed { database: String, message: String }, IntegrityCheckFailed { database: String, message: String },
/// WAL mode could not be enabled (returned unexpected mode) /// WAL mode could not be enabled (returned unexpected mode)
WalModeFailed { database: String, actual_mode: String }, WalModeFailed {
database: String,
actual_mode: String,
},
} }
impl fmt::Display for DatabaseInitError { impl fmt::Display for DatabaseInitError {
@@ -31,20 +50,48 @@ impl fmt::Display for DatabaseInitError {
Self::DataDirCreate { path, source } => { Self::DataDirCreate { path, source } => {
write!(f, "Failed to create data directory {:?}: {}", path, source) write!(f, "Failed to create data directory {:?}: {}", path, source)
} }
Self::ConnectionOpen { database, path, source } => { Self::ConnectionOpen {
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source) database,
path,
source,
} => {
write!(
f,
"Failed to open {}.db at {:?}: {}",
database, path, source
)
} }
Self::PragmaConfig { database, pragma, source } => { Self::PragmaConfig {
database,
pragma,
source,
} => {
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source) write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
} }
Self::MigrationFailed { database, version, name, source } => { Self::MigrationFailed {
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source) database,
version,
name,
source,
} => {
write!(
f,
"Migration v{} ({}) failed on {}.db: {}",
version, name, database, source
)
} }
Self::IntegrityCheckFailed { database, message } => { Self::IntegrityCheckFailed { database, message } => {
write!(f, "Integrity check failed on {}.db: {}", database, message) write!(f, "Integrity check failed on {}.db: {}", database, message)
} }
Self::WalModeFailed { database, actual_mode } => { Self::WalModeFailed {
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode) database,
actual_mode,
} => {
write!(
f,
"WAL mode not enabled on {}.db (got '{}')",
database, actual_mode
)
} }
} }
} }
+4 -4
View File
@@ -1,15 +1,15 @@
use std::time::Duration; use std::time::Duration;
use tracing::{info, error}; use tracing::{error, info};
use crate::db::Db; use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day; use crate::analytics::aggregate_day;
use super::{log_job_start, log_job_end}; use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) { pub async fn run_aggregator(db: Db, interval_mins: u64) {
loop { loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator..."); info!("Running background analytics aggregator...");
let job_id = log_job_start(&db.system, "analytics_aggregator"); let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await { match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None), Ok(_) => log_job_end(&db.system, &job_id, "success", None),
+31 -11
View File
@@ -1,8 +1,8 @@
use std::time::Duration; use super::{log_job_end, log_job_start};
use tracing::{info, error};
use crate::db::Db;
use crate::config::Config; use crate::config::Config;
use super::{log_job_start, log_job_end}; use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) { pub async fn run_backup_scheduler(db: Db, config: Config) {
if !config.backup_enabled { if !config.backup_enabled {
@@ -10,12 +10,15 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
return; return;
} }
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins); info!(
"Starting background backup scheduler (interval: {} mins)...",
config.backup_interval_mins
);
loop { loop {
// Run backup every configured interval // Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await; tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
info!("Running background database backup..."); info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup"); let job_id = log_job_start(&db.system, "database_backup");
match perform_backup(&db, &config).await { match perform_backup(&db, &config).await {
Ok(path) => { Ok(path) => {
@@ -31,13 +34,16 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
} }
} }
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> { pub async fn perform_backup(
use std::fs::File; db: &Db,
config: &Config,
) -> Result<String, Box<dyn std::error::Error>> {
use chrono::Utc;
use flate2::write::GzEncoder; use flate2::write::GzEncoder;
use flate2::Compression; use flate2::Compression;
use tar::Builder;
use chrono::Utc;
use rusqlite::params; use rusqlite::params;
use std::fs::File;
use tar::Builder;
use uuid::Uuid; use uuid::Uuid;
let out_dir = config.backup_dir.clone(); let out_dir = config.backup_dir.clone();
@@ -45,6 +51,20 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
std::fs::create_dir_all(&out_dir)?; std::fs::create_dir_all(&out_dir)?;
} }
// Force checkpoint on all databases to flush WAL contents to the main DB files
if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); 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_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name); let tar_path = out_dir.join(tar_name);
@@ -60,7 +80,7 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
tar.append_path_with_name(&db_file, f)?; tar.append_path_with_name(&db_file, f)?;
} }
} }
tar.into_inner()?.finish()?; tar.into_inner()?.finish()?;
let size_bytes = std::fs::metadata(&tar_path)?.len(); let size_bytes = std::fs::metadata(&tar_path)?.len();
let path_str = tar_path.to_string_lossy().to_string(); let path_str = tar_path.to_string_lossy().to_string();
+22
View File
@@ -0,0 +1,22 @@
use crate::db::Db;
use std::time::Duration;
use tracing::info;
/// Background job that marks expired URLs.
///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
let count = {
let conn = db.content.lock().unwrap();
crate::db::content::expire_urls(&conn).unwrap_or(0)
};
if count > 0 {
info!(expired_count = count, "Expired URLs marked");
}
}
}
+80 -23
View File
@@ -1,17 +1,18 @@
use reqwest::Client;
use std::time::Duration;
use tracing::{info, error};
use uuid::Uuid;
use chrono::Utc; use chrono::Utc;
use reqwest::Client;
use rusqlite::params; use rusqlite::params;
use std::time::{Duration, Instant};
use tracing::{error, info};
use uuid::Uuid;
use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_link_checker(db: Db, interval_mins: u64) { pub async fn run_link_checker(db: Db, interval_mins: u64) {
let client = Client::builder() let client = Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1") .user_agent("bzod-link-checker/0.1")
.redirect(reqwest::redirect::Policy::limited(10))
.build() .build()
.unwrap_or_default(); .unwrap_or_default();
@@ -19,7 +20,7 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
// Sleep first to give server time to start up // Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background link health check..."); info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker"); let job_id = log_job_start(&db.system, "link_checker");
match perform_link_check(&db, &client).await { match perform_link_check(&db, &client).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None), Ok(_) => log_job_end(&db.system, &job_id, "success", None),
@@ -32,19 +33,29 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
} }
} }
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> { pub async fn perform_link_check(
db: &Db,
client: &Client,
) -> Result<(), Box<dyn std::error::Error>> {
let urls = { let urls = {
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)? crate::db::content::list_urls_for_health_check(&conn)?
}; };
for (id, dest) in urls { for (id, dest) in urls {
let (status, status_code, err_msg) = check_url_health(client, &dest).await; let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{ {
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
crate::db::content::update_url_health(&conn, &id, &status)?; crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
} }
// Log to system.db.health_checks // Log to system.db.health_checks
{ {
let conn = db.system.lock().unwrap(); let conn = db.system.lock().unwrap();
@@ -64,31 +75,77 @@ pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn
Ok(()) Ok(())
} }
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) { /// Check URL health with detailed classification and latency measurement.
let res = client.get(url) ///
.timeout(Duration::from_secs(5)) /// Returns: (general_status, detail_status, status_code, latency_ms, error_message)
.send() async fn check_url_health(
.await; client: &Client,
url: &str,
) -> (String, String, Option<u16>, i64, Option<String>) {
let start = Instant::now();
let res = client.get(url).timeout(Duration::from_secs(5)).send().await;
let latency_ms = start.elapsed().as_millis() as i64;
match res { match res {
Ok(response) => { Ok(response) => {
let status = response.status(); let status = response.status();
let code = status.as_u16(); let code = status.as_u16();
if status.is_success() || status.is_redirection() { if status.is_success() || status.is_redirection() {
("healthy".to_string(), Some(code), None) (
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE { "healthy".to_string(),
("dead".to_string(), Some(code), Some(format!("HTTP {}", code))) "healthy".to_string(),
Some(code),
latency_ms,
None,
)
} else if status == reqwest::StatusCode::NOT_FOUND
|| status == reqwest::StatusCode::GONE
{
(
"dead".to_string(),
"dead".to_string(),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
} else { } else {
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code))) (
"suspect".to_string(),
format!("http_{}", code),
Some(code),
latency_ms,
Some(format!("HTTP {}", code)),
)
} }
} }
Err(err) => { Err(err) => {
let err_str = err.to_string(); let err_str = err.to_string();
if err.is_timeout() || err.is_connect() { let detail = if err.is_timeout() {
("suspect".to_string(), None, Some(err_str)) "timeout".to_string()
} else if err.is_connect() {
if err_str.contains("dns") || err_str.contains("resolve") {
"dns_failure".to_string()
} else if err_str.contains("tls")
|| err_str.contains("ssl")
|| err_str.contains("certificate")
{
"tls_error".to_string()
} else {
"connection_refused".to_string()
}
} else if err.is_redirect() {
"redirect_loop".to_string()
} else { } else {
("dead".to_string(), None, Some(err_str)) "unknown_error".to_string()
} };
let general = if err.is_timeout() || err.is_connect() {
"suspect"
} else {
"dead"
};
(general.to_string(), detail, None, latency_ms, Some(err_str))
} }
} }
} }
+9 -7
View File
@@ -1,16 +1,18 @@
use std::sync::Mutex;
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc; use chrono::Utc;
use rusqlite::{params, Connection};
use std::sync::Mutex;
use uuid::Uuid;
pub mod healthcheck;
pub mod retention;
pub mod aggregate; pub mod aggregate;
pub mod backup; pub mod backup;
pub mod expiry;
pub mod healthcheck;
pub mod retention;
pub use healthcheck::{run_link_checker, perform_link_check}; pub use aggregate::{perform_aggregation, run_aggregator};
pub use expiry::run_expiry_checker;
pub use healthcheck::{perform_link_check, run_link_checker};
pub use retention::run_retention_cleaner; pub use retention::run_retention_cleaner;
pub use aggregate::{run_aggregator, perform_aggregation};
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String { pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
let id = Uuid::new_v4().to_string(); let id = Uuid::new_v4().to_string();
+3 -3
View File
@@ -1,8 +1,8 @@
use std::time::Duration; use std::time::Duration;
use tracing::{info, error}; use tracing::{error, info};
use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) { pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
let retention_days = match retention_days_opt { let retention_days = match retention_days_opt {
@@ -14,7 +14,7 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
// Check once every 24 hours // Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await; tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup..."); info!("Running background data retention cleanup...");
let job_id = log_job_start(&db.system, "retention_cleaner"); let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap(); let conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) { match crate::db::analytics::retention_cleanup(&conn, retention_days) {
+10 -10
View File
@@ -1,14 +1,14 @@
pub mod analytics;
pub mod auth;
pub mod charts;
pub mod cli;
pub mod config; pub mod config;
pub mod db; 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 error;
pub mod jobs;
pub mod models;
pub mod services;
pub mod state;
pub mod templates;
pub mod utils;
pub mod web; pub mod web;
pub mod cli;
+20 -4
View File
@@ -1,20 +1,26 @@
use bzod::cli::{Cli, Commands};
use bzod::config::Config;
use clap::Parser; use clap::Parser;
use tracing_subscriber::EnvFilter; use tracing_subscriber::EnvFilter;
use bzod::config::Config;
use bzod::cli::{Cli, Commands};
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> { async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set up tracing subscriber // Set up tracing subscriber
tracing_subscriber::fmt() tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"))) .with_env_filter(
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
)
.init(); .init();
let cli = Cli::parse(); let cli = Cli::parse();
let config = Config::load(); let config = Config::load();
match cli.command { match cli.command {
Commands::Serve { host, port, data_dir } => { Commands::Serve {
host,
port,
data_dir,
} => {
bzod::cli::serve::run(host, port, data_dir, config).await?; bzod::cli::serve::run(host, port, data_dir, config).await?;
} }
Commands::Backup { out, data_dir } => { Commands::Backup { out, data_dir } => {
@@ -38,6 +44,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Doctor { data_dir } => { Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?; bzod::cli::doctor::run(data_dir, config).await?;
} }
Commands::Shorten {
target_url,
slug,
data_dir,
} => {
bzod::cli::shorten::run(target_url, slug, data_dir, config).await?;
}
Commands::Expand { code, data_dir } => {
bzod::cli::expand::run(code, data_dir, config).await?;
}
} }
Ok(()) Ok(())
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ApiKey { pub struct ApiKey {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditLog { pub struct AuditLog {
+8 -8
View File
@@ -1,13 +1,13 @@
pub mod user;
pub mod url;
pub mod page;
pub mod visit;
pub mod api_key; pub mod api_key;
pub mod audit; pub mod audit;
pub mod page;
pub mod url;
pub mod user;
pub mod visit;
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 api_key::ApiKey;
pub use audit::AuditLog; pub use audit::AuditLog;
pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{Session, User};
pub use visit::{SummaryEntry, VisitRecord};
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LandingPage { pub struct LandingPage {
+64 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Url { pub struct Url {
@@ -11,4 +11,67 @@ pub struct Url {
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub tags: Vec<String>, pub tags: Vec<String>,
// --- Feature Expansion Fields ---
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<String>,
#[serde(default)]
pub expired: bool,
#[serde(skip_serializing)]
pub password_hash: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_status: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_latency_ms: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_access_count: Option<i64>,
#[serde(default)]
pub access_count: i64,
}
impl Url {
/// Returns true if this URL has a password set.
pub fn is_password_protected(&self) -> bool {
self.password_hash.is_some()
}
/// Returns true if this URL has reached its access limit.
pub fn is_access_exhausted(&self) -> bool {
if let Some(max) = self.max_access_count {
self.access_count >= max
} else {
false
}
}
}
/// Link preview metadata for smart landing pages.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LinkPreview {
pub id: String,
pub url_id: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
}
/// QR code metadata record.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct QrCode {
pub id: String,
pub url_id: String,
pub style: String,
pub created_at: String,
}
/// Audit event record for the enhanced audit trail.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditEvent {
pub id: String,
pub actor: String,
pub action: String,
pub object_type: String,
pub object_id: String,
pub timestamp: String,
pub metadata: Option<String>,
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User { pub struct User {
+1 -1
View File
@@ -1,4 +1,4 @@
use serde::{Serialize, Deserialize}; use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VisitRecord { pub struct VisitRecord {
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db; use crate::db::Db;
use crate::models::ApiKey;
use crate::error::AppError; use crate::error::AppError;
use crate::models::ApiKey;
pub fn create_api_key( pub fn create_api_key(
db: &Db, db: &Db,
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db; use crate::db::Db;
use crate::models::AuditLog;
use crate::error::AppError; use crate::error::AppError;
use crate::models::AuditLog;
pub fn log_action( pub fn log_action(
db: &Db, db: &Db,
+49
View File
@@ -0,0 +1,49 @@
use crate::models::Url;
use crate::services::qr::{generate_qr_png, generate_qr_svg};
use std::io::Write;
use zip::write::FileOptions;
use zip::ZipWriter;
/// Export QR codes for the given URLs as a ZIP file.
/// `format` can be "png" or "svg".
/// `base_url` is used to build the full short URL encoded in the QR.
pub fn export_qr_zip(
urls: &[Url],
format: &str,
base_url: &str,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut buf = Vec::new();
{
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut buf));
let options =
FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
let mut csv_content = String::from("code,destination,qr_filename\n");
for url in urls {
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), url.code);
let ext = if format == "svg" { "svg" } else { "png" };
let filename = format!("{}.{}", url.code, ext);
let qr_data = if format == "svg" {
generate_qr_svg(&full_url)?.into_bytes()
} else {
generate_qr_png(&full_url, 256)?
};
zip.start_file(&filename, options)?;
zip.write_all(&qr_data)?;
// Escape quotes in destination for CSV formatting
let escaped_dest = url.destination.replace('"', "\"\"");
csv_content.push_str(&format!("{},\"{}\",{}\n", url.code, escaped_dest, filename));
}
zip.start_file("manifest.csv", options)?;
zip.write_all(csv_content.as_bytes())?;
zip.finish()?;
}
Ok(buf)
}
+3 -2
View File
@@ -1,6 +1,6 @@
use crate::db::Db; use crate::db::Db;
use crate::models::LandingPage;
use crate::error::AppError; use crate::error::AppError;
use crate::models::LandingPage;
pub fn create_landing_page( pub fn create_landing_page(
db: &Db, db: &Db,
@@ -11,7 +11,8 @@ pub fn create_landing_page(
state: &str, state: &str,
) -> Result<LandingPage, AppError> { ) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?; let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
Ok(page) Ok(page)
} }
+4 -2
View File
@@ -1,4 +1,6 @@
pub mod shortener;
pub mod landing_pages;
pub mod api_keys; pub mod api_keys;
pub mod audit; pub mod audit;
pub mod bulk;
pub mod landing_pages;
pub mod qr;
pub mod shortener;
+58
View File
@@ -0,0 +1,58 @@
//! QR code generation service.
//!
//! Generates QR codes on-demand as PNG or SVG. No files are stored on disk.
use image::Luma;
use qrcode::QrCode;
use std::io::Cursor;
/// Generate a QR code as a PNG byte vector.
///
/// The `url` is encoded into the QR matrix. The `size` parameter controls
/// the pixel dimensions of the output image (default: 256).
pub fn generate_qr_png(url: &str, size: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
let mut buf = Vec::new();
let mut cursor = Cursor::new(&mut buf);
image.write_to(&mut cursor, image::ImageFormat::Png)?;
Ok(buf)
}
/// Generate a QR code as an SVG string.
pub fn generate_qr_svg(url: &str) -> Result<String, Box<dyn std::error::Error>> {
let code = QrCode::new(url.as_bytes())?;
let svg = code
.render::<qrcode::render::svg::Color>()
.min_dimensions(256, 256)
.build();
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_qr_png_generation() {
let png = generate_qr_png("https://bzo.in/abc123", 256).unwrap();
assert!(!png.is_empty());
// PNG magic bytes
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
}
#[test]
fn test_qr_svg_generation() {
let svg = generate_qr_svg("https://bzo.in/abc123").unwrap();
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
}
#[test]
fn test_qr_long_url() {
let long_url = format!("https://example.com/{}", "a".repeat(500));
let png = generate_qr_png(&long_url, 512).unwrap();
assert!(!png.is_empty());
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::db::Db; use crate::db::Db;
use crate::models::Url;
use crate::error::AppError; use crate::error::AppError;
use crate::models::Url;
pub fn create_url( pub fn create_url(
db: &Db, db: &Db,
+4 -4
View File
@@ -1,9 +1,9 @@
use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
use rusqlite::Connection;
use crate::config::Config;
use crate::analytics::queue::AnalyticsQueue;
use crate::db::Db;
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
+108
View File
@@ -0,0 +1,108 @@
use crate::models::{LandingPage, Url};
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Clone, Debug)]
pub struct VisitorLogEntry {
pub sr: usize,
pub timestamp: String,
pub ip_address: String,
pub country: String,
pub referrer: String,
pub browser: String,
pub user_agent: String,
pub utm_source: String,
pub utm_campaign: String,
}
#[derive(Template)]
#[template(path = "url_analytics.html")]
pub struct UrlAnalyticsTemplate {
pub admin_username: String,
pub url: Url,
pub total_clicks: i64,
pub unique_visitors: i64,
pub qr_scans: i64,
pub direct_clicks: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
pub browsers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
impl UrlAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UrlAnalyticsTemplate {
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(),
}
}
}
#[derive(Template)]
#[template(path = "page_analytics.html")]
pub struct PageAnalyticsTemplate {
pub admin_username: String,
pub page: LandingPage,
pub total_views: i64,
pub unique_visitors: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
impl PageAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for PageAnalyticsTemplate {
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(),
}
}
}
+6 -2
View File
@@ -1,7 +1,7 @@
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
#[derive(Template)] #[derive(Template)]
@@ -23,7 +23,11 @@ impl IntoResponse for DashboardTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
+56 -6
View File
@@ -1,19 +1,21 @@
pub mod analytics;
pub mod dashboard; pub mod dashboard;
pub mod urls;
pub mod pages; pub mod pages;
pub mod stats;
pub mod settings; pub mod settings;
pub mod stats;
pub mod urls;
pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
pub use dashboard::DashboardTemplate; pub use dashboard::DashboardTemplate;
pub use urls::UrlsTemplate;
pub use pages::PagesTemplate; pub use pages::PagesTemplate;
pub use stats::{StatusTemplate, AuditTemplate};
pub use settings::SettingsTemplate; pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate};
pub use urls::UrlsTemplate;
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
#[derive(Template)] #[derive(Template)]
@@ -27,7 +29,55 @@ impl IntoResponse for LoginTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "gate.html")]
pub struct GateTemplate {
pub code: String,
pub error: Option<String>,
}
impl IntoResponse for GateTemplate {
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(),
}
}
}
#[derive(Template)]
#[template(path = "preview.html")]
pub struct PreviewTemplate {
pub code: String,
pub title: Option<String>,
pub description: Option<String>,
pub logo_url: Option<String>,
pub button_text: String,
pub destination: String,
}
impl IntoResponse for PreviewTemplate {
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(),
} }
} }
} }
+17 -3
View File
@@ -1,9 +1,9 @@
use crate::models::LandingPage;
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
use crate::models::LandingPage;
#[derive(Template)] #[derive(Template)]
#[template(path = "pages.html")] #[template(path = "pages.html")]
@@ -12,13 +12,27 @@ pub struct PagesTemplate {
pub pages: Vec<LandingPage>, pub pages: Vec<LandingPage>,
pub csrf_token: String, pub csrf_token: String,
pub error: Option<String>, pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
} }
impl PagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for PagesTemplate { impl IntoResponse for PagesTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
+7 -3
View File
@@ -1,9 +1,9 @@
use crate::models::ApiKey;
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
use crate::models::ApiKey;
#[derive(Template)] #[derive(Template)]
#[template(path = "settings.html")] #[template(path = "settings.html")]
@@ -20,7 +20,11 @@ impl IntoResponse for SettingsTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
+13 -4
View File
@@ -1,9 +1,9 @@
use crate::models::AuditLog;
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
use crate::models::AuditLog;
#[derive(Template)] #[derive(Template)]
#[template(path = "status_ui.html")] #[template(path = "status_ui.html")]
@@ -16,6 +16,7 @@ pub struct StatusTemplate {
pub uptime: String, pub uptime: String,
pub version: &'static str, pub version: &'static str,
pub git_commit: &'static str, pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
} }
#[derive(Template)] #[derive(Template)]
@@ -29,7 +30,11 @@ impl IntoResponse for StatusTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
@@ -38,7 +43,11 @@ impl IntoResponse for AuditTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
+18 -3
View File
@@ -1,9 +1,9 @@
use crate::models::Url;
use askama::Template; use askama::Template;
use axum::{ use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response},
}; };
use crate::models::Url;
#[derive(Template)] #[derive(Template)]
#[template(path = "urls.html")] #[template(path = "urls.html")]
@@ -13,13 +13,28 @@ pub struct UrlsTemplate {
pub csrf_token: String, pub csrf_token: String,
pub error: Option<String>, pub error: Option<String>,
pub tag_filter: Option<String>, pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
} }
impl UrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UrlsTemplate { impl IntoResponse for UrlsTemplate {
fn into_response(self) -> Response { fn into_response(self) -> Response {
match self.render() { match self.render() {
Ok(html) => Html(html).into_response(), Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(), Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
} }
} }
} }
+1 -1
View File
@@ -1,4 +1,4 @@
use sha2::{Sha256, Digest}; use sha2::{Digest, Sha256};
// General SHA-256 hash helper // General SHA-256 hash helper
pub fn sha256_hash(data: &str) -> String { pub fn sha256_hash(data: &str) -> String {
+5 -4
View File
@@ -1,11 +1,12 @@
pub mod time;
pub mod system;
pub mod hashing; pub mod hashing;
pub mod network; pub mod network;
pub mod random; pub mod random;
pub mod system;
pub mod time;
pub mod validation;
pub use time::format_duration;
pub use system::{get_memory_usage, get_db_file_info};
pub use hashing::sha256_hash; pub use hashing::sha256_hash;
pub use network::get_client_ip; pub use network::get_client_ip;
pub use random::generate_token; pub use random::generate_token;
pub use system::{get_db_file_info, get_memory_usage};
pub use time::format_duration;
+5 -5
View File
@@ -1,12 +1,12 @@
use axum::{extract::ConnectInfo, http::HeaderMap};
use std::net::SocketAddr; use std::net::SocketAddr;
use axum::{
extract::ConnectInfo,
http::HeaderMap,
};
// Extract client IP address from proxy headers or connection info // Extract client IP address from proxy headers or connection info
pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<SocketAddr>>) -> String { pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<SocketAddr>>) -> String {
if let Some(ip) = headers.get("cf-connecting-ip").and_then(|h| h.to_str().ok()) { if let Some(ip) = headers
.get("cf-connecting-ip")
.and_then(|h| h.to_str().ok())
{
return ip.to_string(); return ip.to_string();
} }
if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) { if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
+1 -1
View File
@@ -1,4 +1,4 @@
use rand::{RngCore, thread_rng}; use rand::{thread_rng, RngCore};
// Generate a secure random token (hex-encoded) // Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String { pub fn generate_token(bytes_len: usize) -> String {
+6 -1
View File
@@ -30,7 +30,12 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
if path.exists() { if path.exists() {
if let Ok(metadata) = std::fs::metadata(&path) { if let Ok(metadata) = std::fs::metadata(&path) {
let size = metadata.len(); let size = metadata.len();
stats.push_str(&format!("{}: {} bytes ({:.2} MB)\n", name, size, size as f64 / 1_048_576.0)); stats.push_str(&format!(
"{}: {} bytes ({:.2} MB)\n",
name,
size,
size as f64 / 1_048_576.0
));
} }
} else { } else {
stats.push_str(&format!("{}: File not created yet\n", name)); stats.push_str(&format!("{}: File not created yet\n", name));
+19
View File
@@ -0,0 +1,19 @@
pub fn validate_custom_slug(slug: &str) -> bool {
if !slug.starts_with('!') {
return false;
}
let rest = &slug[1..];
if rest.is_empty() || rest.len() > 24 {
return false;
}
rest.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn validate_redirect_code(code: &str) -> bool {
(code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
pub fn validate_page_code(code: &str) -> bool {
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
+1484 -118
View File
File diff suppressed because it is too large. Load diff
+843 -59
View File
File diff suppressed because it is too large. Load diff
+288
View File
@@ -0,0 +1,288 @@
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::auth::ApiUser;
use crate::state::AppState;
use crate::utils::get_client_ip;
use axum::{
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
#[derive(Deserialize)]
pub struct BulkQrRequest {
pub ids: Vec<String>,
pub format: Option<String>,
}
#[derive(Deserialize)]
pub struct BulkUrlItem {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub expires_at: Option<String>,
pub password: Option<String>,
pub max_access_count: Option<i64>,
}
#[derive(Serialize)]
pub struct BulkErrorResponse {
pub error: String,
}
// POST /api/v1/bulk/qr
pub async fn api_bulk_qr(
State(state): State<AppState>,
headers: HeaderMap,
user: ApiUser,
Json(payload): Json<BulkQrRequest>,
) -> Response {
if payload.ids.len() > 500 {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Maximum 500 QR codes allowed per bulk request".to_string(),
}),
)
.into_response();
}
let format = payload
.format
.unwrap_or_else(|| "png".to_string())
.to_lowercase();
if format != "png" && format != "svg" {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Invalid format. Supported: png, svg".to_string(),
}),
)
.into_response();
}
// Retrieve URLs from database
let mut urls = Vec::new();
{
let conn = state.content_db.lock().unwrap();
for id in &payload.ids {
match crate::db::content::get_url_by_id(&conn, id) {
Ok(Some(url)) => urls.push(url),
Ok(None) => {}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database error fetching URL {}: {}", id, e),
}),
)
.into_response();
}
}
}
}
if urls.is_empty() {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "No valid URLs found for the provided IDs".to_string(),
}),
)
.into_response();
}
// Base URL configuration check
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8654");
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
// Generate ZIP
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
Ok(zip_data) => {
// Write Audit Log
{
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.0.username,
"BULK_QR_EXPORT",
"bulk",
"qr",
Some(&format!("Count: {}, Format: {}", urls.len(), format)),
);
}
Response::builder()
.header("content-type", "application/zip")
.header(
"content-disposition",
"attachment; filename=\"qr_codes.zip\"",
)
.body(axum::body::Body::from(zip_data))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Error generating ZIP: {}", e),
}),
)
.into_response(),
}
}
// POST /api/v1/bulk/url
pub async fn api_bulk_url(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Json(payload): Json<Vec<BulkUrlItem>>,
) -> Response {
if payload.len() > 500 {
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: "Maximum 500 URLs allowed per bulk creation".to_string(),
}),
)
.into_response();
}
let mut conn = state.content_db.lock().unwrap();
let tx = match conn.transaction() {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to start database transaction: {}", e),
}),
)
.into_response()
}
};
let mut created_urls = Vec::new();
for item in payload {
let mut code = item.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()) {
let _ = tx.rollback();
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: format!("Short code '{}' must be 6 hex characters", code),
}),
)
.into_response();
}
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
let _ = tx.rollback();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Password hashing error: {}", e),
}),
)
.into_response();
}
}
} else {
None
};
let tags = item.tags.unwrap_or_default();
match crate::db::content::create_url_extended(
&tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
) {
Ok(url) => created_urls.push(url),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let _ = tx.rollback();
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
Err(e) => {
let _ = tx.rollback();
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database insert error: {}", e),
}),
)
.into_response();
}
}
}
if let Err(e) = tx.commit() {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to commit transaction: {}", e),
}),
)
.into_response();
}
// Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
{
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.0.username,
"BULK_URL_CREATION",
"bulk",
"url",
Some(&format!(
"Count: {}, IP: {:?}, User-Agent: {:?}",
created_urls.len(),
ip,
user_agent
)),
);
}
(StatusCode::CREATED, Json(created_urls)).into_response()
}
+7 -4
View File
@@ -1,9 +1,12 @@
pub mod routes;
pub mod middleware;
pub mod redirect;
pub mod pages;
pub mod admin; pub mod admin;
pub mod api; pub mod api;
pub mod bulk;
pub mod middleware;
pub mod pages;
pub mod password_gate;
pub mod qr;
pub mod redirect;
pub mod routes;
pub mod system; pub mod system;
pub use routes::create_router; pub use routes::create_router;
+56 -10
View File
@@ -1,17 +1,17 @@
use axum::{ use axum::{
extract::{Path, State, ConnectInfo}, extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{Response, Html, IntoResponse}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; 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::analytics::get_client_country;
use crate::models::VisitRecord;
use crate::services::landing_pages::get_landing_page_by_code; use crate::services::landing_pages::get_landing_page_by_code;
use crate::state::AppState;
use crate::utils::get_client_ip;
// GET /p/:code and GET /p/:code/*slug // GET /p/:code and GET /p/:code/*slug
// Resolve and render landing page // Resolve and render landing page
@@ -21,7 +21,7 @@ pub async fn resolve_page(
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_page_code(&code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
@@ -40,15 +40,18 @@ pub async fn resolve_page(
// Record view analytics // Record view analytics
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers); let country = get_client_country(&headers);
let user_agent = headers.get("user-agent") let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
let referer = headers.get("referer") let referer = headers
.get("referer")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.unwrap_or("Direct") .unwrap_or("Direct")
.to_string(); .to_string();
let accept_language = headers.get("accept-language") let accept_language = headers
.get("accept-language")
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.unwrap_or("Unknown") .unwrap_or("Unknown")
.to_string(); .to_string();
@@ -74,3 +77,46 @@ pub async fn resolve_page(
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(), None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
} }
} }
// GET /
// Serve static root landing page from www/index.html
pub async fn root_landing() -> Response {
let mut target_path = std::path::PathBuf::from("www/index.html");
if !target_path.exists() {
// Search relative to executable
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let path1 = exe_dir.join("www/index.html");
if path1.exists() {
target_path = path1;
} else if let Some(parent1) = exe_dir.parent() {
let path2 = parent1.join("www/index.html");
if path2.exists() {
target_path = path2;
} else if let Some(parent2) = parent1.parent() {
let path3 = parent2.join("www/index.html");
if path3.exists() {
target_path = path3;
}
}
}
}
}
}
if !target_path.exists() {
// Search in CARGO_MANIFEST_DIR
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let path = std::path::PathBuf::from(manifest_dir).join("www/index.html");
if path.exists() {
target_path = path;
}
}
}
match std::fs::read_to_string(&target_path) {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_response(),
}
}
+75
View File
@@ -0,0 +1,75 @@
use axum::{
extract::{Path, State},
response::{IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::{cookie::Cookie, CookieJar};
use serde::Deserialize;
use crate::auth::password::verify_password;
use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::templates::GateTemplate;
#[derive(Deserialize)]
pub struct PasswordGateForm {
pub password: String,
}
// GET /gate/:code
pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
GateTemplate { code, error: None }
}
// POST /gate/:code
pub async fn gate_post(
State(state): State<AppState>,
Path(code): Path<String>,
jar: CookieJar,
Form(form): Form<PasswordGateForm>,
) -> Response {
let url_opt = match get_url_by_code(&state.db, &code) {
Ok(url) => url,
Err(_) => {
return (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
"Database error",
)
.into_response()
}
};
let url = match url_opt {
Some(u) => u,
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
};
let password_hash = match url.password_hash {
Some(ref h) => h,
None => {
// Not password protected, redirect to resolution
return Redirect::temporary(&format!("/{}", code)).into_response();
}
};
if verify_password(&form.password, password_hash) {
// Correct password - set 15 min temporary cookie
let cookie_name = format!("bzod_gate_{}", code);
let cookie = Cookie::build((cookie_name, "authorized"))
.secure(state.config.cookie_secure)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.http_only(true)
.path("/")
.max_age(time::Duration::minutes(15));
let updated_jar = jar.add(cookie);
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
} else {
// Invalid password
GateTemplate {
code,
error: Some("Invalid password".to_string()),
}
.into_response()
}
}
+161
View File
@@ -0,0 +1,161 @@
use crate::services::qr::{generate_qr_png, generate_qr_svg};
use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::utils::get_client_ip;
use axum::{
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
};
use std::net::SocketAddr;
use serde_json::json;
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
pub async fn qr_handler(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(file): Path<String>,
) -> Response {
let parts: Vec<&str> = file.split('.').collect();
if parts.len() != 2 {
// No extension: this is a JSON stats request!
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let authenticated = if let Some(auth) = auth_header {
let conn = state.admin_db.lock().unwrap();
matches!(
crate::auth::session::authenticate_api_key(&conn, auth),
Ok(Some(_user))
)
} else {
false
};
if !authenticated {
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
}
let url_opt = match get_url_by_code(&state.db, &file) {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
};
let qr_scans = {
let conn = state.analytics_db.lock().unwrap();
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
};
let direct_clicks = {
let conn = state.analytics_db.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
rusqlite::params![url.id],
|row| row.get(0),
)
.unwrap_or(0)
};
return axum::response::Json(json!({
"direct_clicks": direct_clicks,
"qr_scans": qr_scans
}))
.into_response();
}
let code = parts[0];
let ext = parts[1].to_lowercase();
if !crate::utils::validation::validate_redirect_code(code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let url_opt = match get_url_by_code(&state.db, code) {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
};
// Construct public base URL
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let host_header = headers
.get("host")
.and_then(|h| h.to_str().ok())
.unwrap_or("localhost:8654");
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code);
// Generate QR code based on format
let (body, content_type) = if ext == "svg" {
match generate_qr_svg(&full_url) {
Ok(svg) => (svg.into_bytes(), "image/svg+xml"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("QR generation error: {}", e),
)
.into_response()
}
}
} else if ext == "png" {
match generate_qr_png(&full_url, 256) {
Ok(png) => (png, "image/png"),
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("QR generation error: {}", e),
)
.into_response()
}
}
} else {
return (
StatusCode::BAD_REQUEST,
"Unsupported format. Use .png or .svg",
)
.into_response();
};
// Log the QR access event
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers
.get("user-agent")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
{
let analytics_conn = state.db.analytics.lock().unwrap();
let _ = crate::db::qr::log_qr_access(
&analytics_conn,
&url.id,
Some(ip.as_str()),
user_agent.as_deref(),
);
}
Response::builder()
.header("content-type", content_type)
.header("cache-control", "public, max-age=86400") // cache for 1 day
.body(axum::body::Body::from(body))
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
+115 -43
View File
@@ -1,28 +1,31 @@
use axum::{ use axum::{
extract::{Path, State, ConnectInfo}, extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{Redirect, Response, IntoResponse}, response::{IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::CookieJar;
use chrono::Utc;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; 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::analytics::get_client_country;
use crate::models::VisitRecord;
use crate::services::shortener::get_url_by_code; use crate::services::shortener::get_url_by_code;
use crate::state::AppState;
use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip;
// GET /:code // GET /:code
// Resolve and redirect // Resolve and redirect
pub async fn resolve_redirect( pub async fn resolve_redirect(
State(state): State<AppState>, State(state): State<AppState>,
jar: CookieJar,
Path(code): Path<String>, Path(code): Path<String>,
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
// Basic validation of code (must be 6 hex characters) // Basic validation of code (must be 6 hex characters or a valid custom slug)
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(&code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
@@ -31,43 +34,112 @@ pub async fn resolve_redirect(
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
match url_opt { let url = match url_opt {
Some(url) => { Some(u) => u,
// Asynchronously record analytics None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
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 { // 1. Expiration check
id: Uuid::new_v4().to_string(), if url.expired {
target_type: "url".to_string(), return (StatusCode::GONE, "This link has expired").into_response();
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) if let Some(ref expires_at_str) = url.expires_at {
state.analytics_queue.push(record); if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
if expires_at.with_timezone(&Utc) < Utc::now() {
// Perform redirect // Mark as expired in DB asynchronously/immediately
Redirect::temporary(&url.destination).into_response() {
let conn = state.db.content.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
);
}
return (StatusCode::GONE, "This link has expired").into_response();
}
} }
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(), }
// 2. Access limit check
if url.is_access_exhausted() {
return (
StatusCode::GONE,
"This link has reached its maximum access limit",
)
.into_response();
}
// 3. Password protection check
if url.is_password_protected() {
let cookie_name = format!("bzod_gate_{}", code);
let authorized = jar
.get(&cookie_name)
.map(|c| c.value() == "authorized")
.unwrap_or(false);
if !authorized {
return Redirect::temporary(&format!("/gate/{}", code)).into_response();
}
}
// 4. Increment access count & retrieve preview config
let _new_access_count = {
let conn = state.db.content.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
};
let preview_opt = {
let conn = state.db.content.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
};
// 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: if preview_opt.is_some() { 200 } else { 302 },
};
// Push to memory queue (non-blocking)
state.analytics_queue.push(record);
// 5. Render Preview or Redirect
if let Some(preview) = preview_opt {
PreviewTemplate {
code,
title: preview.title,
description: preview.description,
logo_url: preview.logo_url,
button_text: preview.button_text,
destination: url.destination,
}
.into_response()
} else {
Redirect::temporary(&url.destination).into_response()
} }
} }
+86 -21
View File
@@ -1,26 +1,34 @@
use axum::{
Router,
routing::{get, post},
};
use crate::state::AppState; use crate::state::AppState;
use crate::web::{redirect, pages, admin, api, system}; use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
use axum::{
routing::{get, post},
Router,
};
pub fn create_router(state: AppState) -> Router { pub fn create_router(state: AppState) -> Router {
Router::new() Router::new()
// --- Root Landing Page ---
.route("/", get(pages::root_landing))
// --- Public Redirection Routes --- // --- Public Redirection Routes ---
.route("/:code", get(redirect::resolve_redirect)) .route("/:code", get(redirect::resolve_redirect))
.route("/p/:code", get(pages::resolve_page)) .route("/p/:code", get(pages::resolve_page))
.route("/p/:code/*slug", get(pages::resolve_page)) .route("/p/:code/*slug", get(pages::resolve_page))
.route(
"/gate/:code",
get(password_gate::gate_get).post(password_gate::gate_post),
)
// --- QR Code Serving ---
.route("/api/qr/:file", get(qr::qr_handler))
// --- System Health & Diagnostics --- // --- System Health & Diagnostics ---
.route("/status", get(system::status_endpoint)) .route("/status", get(system::status_endpoint))
.route("/metrics", get(system::metrics_endpoint)) .route("/metrics", get(system::metrics_endpoint))
// --- Admin UI Login/Logout --- // --- Admin UI Login/Logout ---
.route("/admin", get(admin::admin_index)) .route("/admin", get(admin::admin_index))
.route("/admin/login", get(admin::login_get).post(admin::login_post)) .route(
"/admin/login",
get(admin::login_get).post(admin::login_post),
)
.route("/admin/logout", get(admin::logout)) .route("/admin/logout", get(admin::logout))
// --- Admin UI Pages --- // --- Admin UI Pages ---
.route("/admin/dashboard", get(admin::dashboard_get)) .route("/admin/dashboard", get(admin::dashboard_get))
.route("/admin/urls", get(admin::urls_get)) .route("/admin/urls", get(admin::urls_get))
@@ -29,27 +37,84 @@ pub fn create_router(state: AppState) -> Router {
.route("/admin/pages", get(admin::pages_get)) .route("/admin/pages", get(admin::pages_get))
.route("/admin/pages/create", post(admin::pages_create)) .route("/admin/pages/create", post(admin::pages_create))
.route("/admin/pages/delete/:id", post(admin::pages_delete)) .route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
.route("/admin/analytics/url/:id/export/csv", get(admin::url_analytics_csv_export))
.route("/admin/analytics/url/:id/export/json", get(admin::url_analytics_json_export))
.route("/admin/analytics/page/:id", get(admin::page_analytics_get))
.route("/admin/analytics/page/:id/export/csv", get(admin::page_analytics_csv_export))
.route("/admin/analytics/page/:id/export/json", get(admin::page_analytics_json_export))
.route("/admin/settings", get(admin::settings_get)) .route("/admin/settings", get(admin::settings_get))
.route("/admin/settings/password", post(admin::change_password_post)) .route(
.route("/admin/settings/retention", post(admin::change_retention_post)) "/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/compact", post(admin::compact_db_post))
.route("/admin/settings/backup", get(admin::download_backup)) .route("/admin/settings/backup", get(admin::download_backup))
.route("/admin/settings/api-keys/create", post(admin::create_api_key_post)) .route("/admin/settings/restore", post(admin::restore_backup_post))
.route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post)) .route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
.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/audit", get(admin::audit_get))
.route("/admin/status", get(admin::status_get)) .route("/admin/status", get(admin::status_get))
// --- REST API v1 JSON Endpoints --- // --- REST API v1 JSON Endpoints ---
.route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls)) .route(
.route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url)) "/api/v1/urls",
.route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages)) post(api::api_create_url).get(api::api_list_urls),
.route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page)) )
.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", get(api::api_overall_stats))
.route("/api/v1/stats/url/:uuid", get(api::api_url_stats)) .route("/api/v1/stats/url/:uuid", get(api::api_url_stats))
.route("/api/v1/stats/page/:uuid", get(api::api_page_stats)) .route("/api/v1/stats/page/:uuid", get(api::api_page_stats))
// --- Feature Expansion REST API Endpoints ---
.route("/api/v1/qr/:code", get(api::api_get_qr_stats))
.route("/api/v1/bulk/qr", post(bulk::api_bulk_qr))
.route("/api/v1/bulk/url", post(bulk::api_bulk_url))
.route("/api/v1/audit", get(api::api_list_audit))
// --- Feature 10 Non-Versioned API Extensions ---
.route("/api/qr", post(api::api_create_qr))
.route("/api/bulk/qr", post(bulk::api_bulk_qr))
.route("/api/bulk/url", post(bulk::api_bulk_url))
.route("/api/stats", get(api::api_overall_stats))
.route("/api/audit", get(api::api_list_audit))
.route(
"/api/v1/urls/:uuid/preview",
post(api::api_set_preview)
.get(api::api_get_preview)
.delete(api::api_delete_preview),
)
.route(
"/api/v1/urls/:uuid/password",
post(api::api_set_password).delete(api::api_remove_password),
)
// --- Static Asset Stub --- // --- Static Asset Stub ---
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") })) .route(
"/static/style.css",
get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }),
)
.with_state(state) .with_state(state)
} }
+16 -10
View File
@@ -1,15 +1,15 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response, Json}, response::{IntoResponse, Json, Response},
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use serde::Serialize; use serde::Serialize;
use crate::auth::{authenticate_api_key, authenticate_session};
use crate::db::admin::get_user_count; use crate::db::admin::get_user_count;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{get_memory_usage, get_db_file_info}; use crate::utils::{get_db_file_info, get_memory_usage};
use crate::auth::{authenticate_session, authenticate_api_key};
// Helper: authenticate system request via header or session cookie // Helper: authenticate system request via header or session cookie
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool { fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
@@ -51,8 +51,9 @@ pub async fn status_endpoint(
// Return public basic status for container/load-balancer health checks // Return public basic status for container/load-balancer health checks
return ( return (
StatusCode::OK, StatusCode::OK,
Json(serde_json::json!({ "application": "Healthy" })) Json(serde_json::json!({ "application": "Healthy" })),
).into_response(); )
.into_response();
} }
let is_db_ok = { let is_db_ok = {
@@ -61,7 +62,10 @@ pub async fn status_endpoint(
}; };
let db_status = if is_db_ok { let db_status = if is_db_ok {
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir)) format!(
"Connected (WAL Mode enabled). Files Info:\n{}",
get_db_file_info(&state.config.data_dir)
)
} else { } else {
"Disconnected".to_string() "Disconnected".to_string()
}; };
@@ -71,12 +75,13 @@ pub async fn status_endpoint(
Json(StatusResponse { Json(StatusResponse {
application: "Healthy", application: "Healthy",
database: db_status, database: db_status,
queue_size: 0, queue_size: 0,
memory_usage: get_memory_usage(), memory_usage: get_memory_usage(),
uptime_seconds: uptime, uptime_seconds: uptime,
version: "0.1.0", version: "0.1.0",
git_commit: "unknown", git_commit: "unknown",
}).into_response() })
.into_response()
} }
// GET /metrics // GET /metrics
@@ -104,7 +109,7 @@ pub async fn metrics_endpoint(
let conn = state.analytics_db.lock().unwrap(); let conn = state.analytics_db.lock().unwrap();
( (
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0), crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0) crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
) )
}; };
@@ -168,5 +173,6 @@ bzod_uptime_seconds {uptime}
StatusCode::OK, StatusCode::OK,
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")], [("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
metrics_text, metrics_text,
).into_response() )
.into_response()
} }
+177
View File
@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Password Protected Link - BZOD</title>
<style>
:root {
--bg-base: #070a13;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
--danger-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.gate-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 420px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
text-align: center;
}
.icon-container {
margin-bottom: 1.5rem;
display: inline-flex;
justify-content: center;
align-items: center;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
color: #818cf8;
}
.lock-icon {
width: 32px;
height: 32px;
fill: currentColor;
}
.title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
background: var(--primary-grad);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.25rem;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input {
background-color: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
width: 100%;
margin-top: 1rem;
transition: all 0.2s ease;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(99, 102, 241, 0.35);
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.5rem;
text-align: left;
font-weight: 500;
}
</style>
</head>
<body>
<div class="gate-card">
<div class="icon-container">
<svg class="lock-icon" viewBox="0 0 24 24">
<path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/>
</svg>
</div>
<h1 class="title">Secure Link</h1>
<p class="subtitle">This link is password-protected. Please enter the password to proceed.</p>
{% if let Some(err) = error %}
<div class="alert-error">
{{ err }}
</div>
{% endif %}
<form action="/gate/{{ code }}" method="POST">
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required autofocus autocomplete="current-password" placeholder="Enter password">
</div>
<button type="submit" class="btn">Unlock Link</button>
</form>
</div>
</body>
</html>
+223
View File
@@ -0,0 +1,223 @@
{% extends "layout.html" %}
{% block title %}Landing Page Analytics - {{ page.slug }} - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
{% block header_actions %}
<div style="display: flex; gap: 0.5rem;">
<a href="/admin/analytics/page/{{ page.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export CSV
</a>
<a href="/admin/analytics/page/{{ page.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export JSON
</a>
<a href="/admin/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages
</a>
</div>
{% endblock %}
{% block content %}
<!-- Date Filter Form -->
<div class="card" style="margin-bottom: 2rem;">
<form method="GET" action="/admin/analytics/page/{{ page.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_to" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date To</label>
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div>
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
</form>
</div>
<!-- Page Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page Details
</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Short Path</span>
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
/p/{{ page.code }}
</a>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">SEO Preview Path</span>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
/p/{{ page.code }}/{{ page.slug }}
</a>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Title</span>
<span style="font-weight: 500;">{{ page.title }}</span>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Publish State</span>
<span class="badge badge-{{ page.state }}">{{ page.state }}</span>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Created Date</span>
<span>{{ page.created_at[0..10] }} {{ page.created_at[11..16] }}</span>
</div>
</div>
</div>
<!-- Overview Stats Cards -->
<div class="grid-stats" style="margin-bottom: 2rem; grid-template-columns: repeat(2, 1fr);">
<div class="card stat-card">
<span class="stat-label">Total Views</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_views }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Unique Visitors</span>
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
</div>
</div>
<!-- Traffic Charts (Timeline) -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Daily Page Views (Last 30 Days)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ traffic_chart|safe }}
</div>
</div>
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Monthly Page Views
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ monthly_chart|safe }}
</div>
</div>
</div>
<!-- Geographic and Referrers Analysis -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Countries -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Geographic Analysis (Top Countries)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ countries_chart|safe }}
</div>
</div>
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="margin-top: 2rem; padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
<span class="badge badge-healthy" style="text-transform: none;">
{{ entry.country }}
</span>
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div style="padding: 1.25rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; gap: 0.75rem;">
<div style="font-size: 0.9rem; color: var(--text-secondary);">
Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
{% if current_page > 1 %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+40 -2
View File
@@ -36,12 +36,17 @@
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group"> <div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label> <label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters"> <input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div> </div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group"> <div class="form-group">
<label for="state">Publish State</label> <label for="state">Publish State</label>
<select id="state" name="state"> <select id="state" name="state">
@@ -78,6 +83,7 @@
<th>Short Path</th> <th>Short Path</th>
<th>SEO Preview Path</th> <th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th>
<th>Created</th> <th>Created</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
@@ -85,7 +91,7 @@
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started! No landing pages registered. Create one to get started!
</td> </td>
</tr> </tr>
@@ -115,6 +121,11 @@
{{ page.state }} {{ page.state }}
</span> </span>
</td> </td>
<td>
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);"> <td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }} {{ page.created_at[0..10] }}
</td> </td>
@@ -132,6 +143,33 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination Controls -->
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+184
View File
@@ -0,0 +1,184 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{% if let Some(t) = title %}
<title>{{ t }}</title>
<meta property="og:title" content="{{ t }}">
<meta name="twitter:title" content="{{ t }}">
{% else %}
<title>Link Preview - BZOD</title>
<meta property="og:title" content="Link Preview">
<meta name="twitter:title" content="Link Preview">
{% endif %}
{% if let Some(d) = description %}
<meta name="description" content="{{ d }}">
<meta property="og:description" content="{{ d }}">
<meta name="twitter:description" content="{{ d }}">
{% endif %}
{% if let Some(l) = logo_url %}
<meta property="og:image" content="{{ l }}">
<meta name="twitter:image" content="{{ l }}">
{% endif %}
<meta property="og:type" content="website">
<meta name="twitter:card" content="summary_large_image">
<style>
:root {
--bg-base: #070a13;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.preview-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 3rem 2.5rem;
width: 100%;
max-width: 500px;
backdrop-filter: blur(12px);
box-shadow: 0 12px 40px 0 rgba(0, 0, 0, 0.5);
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.logo-container {
margin-bottom: 2rem;
display: flex;
justify-content: center;
align-items: center;
width: 96px;
height: 96px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border-color);
overflow: hidden;
}
.logo-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.logo-placeholder {
width: 48px;
height: 48px;
color: #818cf8;
}
.title {
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 1rem;
line-height: 1.3;
background: linear-gradient(135deg, #fff 0%, #cbd5e1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.description {
color: var(--text-secondary);
font-size: 1rem;
line-height: 1.6;
margin-bottom: 2.5rem;
max-width: 100%;
word-wrap: break-word;
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 1rem 2rem;
border-radius: 12px;
font-weight: 600;
font-size: 1.1rem;
text-decoration: none;
cursor: pointer;
width: 100%;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
display: inline-block;
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
}
.btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px 0 rgba(99, 102, 241, 0.45);
opacity: 0.95;
}
.btn:active {
transform: translateY(0);
}
.footer-text {
margin-top: 2rem;
font-size: 0.8rem;
color: var(--text-secondary);
opacity: 0.6;
}
</style>
</head>
<body>
<div class="preview-card">
<div class="logo-container">
{% if let Some(l) = logo_url %}
<img class="logo-img" src="{{ l }}" alt="Logo">
{% else %}
<svg class="logo-placeholder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
{% endif %}
</div>
{% if let Some(t) = title %}
<h1 class="title">{{ t }}</h1>
{% else %}
<h1 class="title">You are being redirected</h1>
{% endif %}
{% if let Some(d) = description %}
<p class="description">{{ d }}</p>
{% else %}
<p class="description">Click the button below to proceed to the destination URL.</p>
{% endif %}
<a href="{{ destination }}" class="btn">{{ button_text }}</a>
<div class="footer-text">Powered by BZOD</div>
</div>
</body>
</html>
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff. Show more