66 Commits
Author SHA1 Message Date
thakares 2cd3c2d965 Release v0.7.0 documentation and version update
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-11 11:33:33 +05:30
thakares b66703082f Refactor responsive UI and build metadata
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-10 17:46:49 +05:30
thakares f49698bb5c Release v0.6.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-09 17:17:57 +05:30
thakares 7069ca9db7 docs(web): update social preview image 2026-07-01 15:15:08 +05:30
thakares faf8fc0eda docs(web): update social preview image 2026-07-01 15:12:47 +05:30
thakares bf29ccab56 fix(web): correct Open Graph preview image URL 2026-07-01 14:51:27 +05:30
thakares 667503c8f6 fix(web): add social preview image assets 2026-07-01 14:42:10 +05:30
thakares 83218ba602 docs: update documentation for v0.5.3
- Refresh administrator guide
- Update architecture documentation
- Document Registry Validator
- Document Registry Repair Framework
- Update backup and restore guide
- Refresh CLI documentation
- Update Docker deployment example
- Bump version to v0.5.3
2026-06-29 17:44:10 +05:30
thakares 58c0af6510 docs: refresh README for v0.5.3
- Rewrite project overview
- Update architecture documentation
- Document Registry Validator and Repair Framework
- Refresh CLI reference
- Expand installation and deployment guides
- Update feature matrix
- Add roadmap and operational tooling
- Update badges and version
2026-06-29 17:12:43 +05:30
thakares f4947489af feat: add registry repair framework
- add shared registry validation service
- add transaction-safe repair CLI
- refactor doctor to use validator
- improve restore integrity checks
- add registry repair integration tests
- strengthen global slug consistency
2026-06-29 16:53:05 +05:30
thakares 2761863c14 Release v0.5.2
- Add admin content consistency diagnostics
- Add admin-migrate CLI
- Harden RBAC for API endpoints
- Normalize multi-tenant storage paths
- Improve backup and restore compatibility
- Fix administrator routing consistency
- Improve doctor and stats commands
2026-06-29 16:11:43 +05:30
thakares a32c0fd7ca docs: update README and v0.5.1 release notes 2026-06-20 20:58:49 +05:30
thakares b03e0ea727 Release v0.5.1: namespace integrity and multi-user hardening 2026-06-20 20:18:11 +05:30
thakares 115f6e9a23 Release v0.5.1: namespace integrity, dashboard parity and QR hardening 2026-06-20 19:54:29 +05:30
thakares 0295b4bd7c www/index.html page updated as per the refactoring... 2026-06-19 22:00:18 +05:30
thakares 6a3c744667 www/index.html page updated as per the refactoring... 2026-06-19 20:58:53 +05:30
thakares 19e48270ed docs: update landing page UI and add comparison tables 2026-06-19 19:59:58 +05:30
thakares 133c707f27 docs: update landing page UI and add comparison tables 2026-06-19 19:36:22 +05:30
thakares 496186e2af docs: complete v0.5.0 administration, architecture and deployment guides 2026-06-19 16:19:31 +05:30
thakares fe7e8efeef docs: complete v0.5.0 administration, architecture and deployment guides 2026-06-19 16:10:48 +05:30
thakares 5193870c97 docs: complete v0.5.0 administration, architecture and deployment guides 2026-06-19 16:07:11 +05:30
thakares 7fdc352547 docs: complete v0.5.0 administration, architecture and deployment guides 2026-06-19 16:05:43 +05:30
thakares 49acf76cf6 docs: complete v0.5.0 administration, architecture and deployment guides 2026-06-19 15:58:23 +05:30
thakares c7e851000f docs: Release Notes on BZOD v0.5.0 — General Availability 2026-06-19 15:16:13 +05:30
thakares c5f33e9713 docs: expand README and testing documentation 2026-06-19 15:07:43 +05:30
thakares 7dfb8c0f1b BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation 2026-06-19 14:51:38 +05:30
thakares 743502b183 ci: improve Rust workflow checks 2026-06-18 15:31:52 +05:30
thakares 7ee6ab2feb ci: rerun workflow 2026-06-18 15:25:06 +05:30
thakares 621a1eccdc Add project comparison guide and improve landing page 2026-06-18 15:09:09 +05:30
thakares cefb84f643 Add project comparison guide and improve landing page 2026-06-18 15:02:31 +05:30
thakares 9fae39dfa4 Use standard Rust dual-license layout 2026-06-18 13:07:34 +05:30
thakares 2212701b6b Add Apache 2.0 license file 2026-06-18 13:04:28 +05:30
thakares 536d885a3d Add API documentation and dual MIT/Apache licensing 2026-06-18 12:59:07 +05:30
thakares a4ffe9a509 Delete LICENSE-APACHE 2026-06-18 12:39:03 +05:30
thakares ad05af95e9 Add API documentation and dual MIT/Apache licensing 2026-06-18 12:32:51 +05:30
thakares 17d8618443 docs: add REST API documentation and installation guide 2026-06-18 12:17:16 +05:30
thakares 74524cb7d2 docs: add one-command deployment section with deploy.sh 2026-06-17 20:45:54 +05:30
thakares e2140e6614 Add deploy script endpoint and improve landing page 2026-06-17 20:33:25 +05:30
thakares 1a9bf096f3 Add deploy.sh endpoint and one-command installation flow 2026-06-17 20:17:19 +05:30
thakares 900f72a299 release: update installer 2026-06-17 19:08:19 +05:30
thakares d42f6da94a feat: production deployment script with rollback and multi-arch support 2026-06-17 19:00:41 +05:30
thakares 18d8b16a2c docs: update documentation for v0.4.0 release 2026-06-17 16:49:47 +05:30
thakares f6dcf58b79 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:47:14 +05:30
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
185 changed files with 39932 additions and 3091 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/
+80 -17
View File
@@ -1,22 +1,85 @@
name: Rust name: Rust CI
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: test:
name: Test & Quality Checks
runs-on: ubuntu-latest
runs-on: ubuntu-latest steps:
- name: Checkout Repository
uses: actions/checkout@v4
steps: - name: Install Rust Toolchain
- uses: actions/checkout@v4 uses: dtolnay/rust-toolchain@stable
- name: Build with:
run: cargo build --verbose components: rustfmt, clippy
- name: Run tests
run: cargo test --verbose - name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Check Formatting
run: cargo fmt --check
- name: Run Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build Release
run: cargo build --release --verbose
- name: Run Tests
run: cargo test --all-features --verbose
docker:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker Metadata
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: Setup 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
+2 -1
View File
@@ -1,7 +1,8 @@
/target /target/
data/ data/
*.db *.db
*.db-wal *.db-wal
*.db-shm *.db-shm
.env .env
.idea/
Generated
+78 -536
View File
@@ -29,24 +29,6 @@ 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"
@@ -121,17 +103,6 @@ dependencies = [
"derive_arbitrary", "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"
@@ -144,21 +115,6 @@ 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"
@@ -200,7 +156,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 7.1.3", "nom",
] ]
[[package]] [[package]]
@@ -226,49 +182,6 @@ 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"
@@ -289,6 +202,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@@ -381,27 +295,12 @@ 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"
@@ -420,12 +319,6 @@ 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"
@@ -452,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.1.0" version = "0.7.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
@@ -462,6 +355,7 @@ dependencies = [
"clap", "clap",
"dotenvy", "dotenvy",
"flate2", "flate2",
"futures-util",
"hex", "hex",
"image", "image",
"qrcode", "qrcode",
@@ -479,6 +373,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip", "zip",
"zstd",
] ]
[[package]] [[package]]
@@ -559,12 +454,6 @@ 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"
@@ -582,6 +471,24 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -606,37 +513,12 @@ 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]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" 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"
@@ -689,18 +571,21 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" 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"
@@ -710,26 +595,6 @@ 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"
@@ -746,21 +611,6 @@ 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"
@@ -779,12 +629,6 @@ 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]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -850,6 +694,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"
@@ -863,6 +718,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",
@@ -918,27 +774,6 @@ 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"
@@ -1239,38 +1074,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "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"
@@ -1283,17 +1091,6 @@ 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"
@@ -1306,15 +1103,6 @@ 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"
@@ -1354,28 +1142,12 @@ 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"
@@ -1405,6 +1177,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1420,15 +1198,6 @@ 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"
@@ -1450,16 +1219,6 @@ 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"
@@ -1536,21 +1295,6 @@ 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"
@@ -1561,21 +1305,6 @@ 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"
@@ -1585,53 +1314,12 @@ 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"
@@ -1687,18 +1375,6 @@ 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"
@@ -1774,22 +1450,19 @@ dependencies = [
] ]
[[package]] [[package]]
name = "profiling" name = "psl-types"
version = "1.0.18" version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
dependencies = [
"profiling-procmacros",
]
[[package]] [[package]]
name = "profiling-procmacros" name = "publicsuffix"
version = "1.0.18" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [ dependencies = [
"quote", "idna",
"syn", "psl-types",
] ]
[[package]] [[package]]
@@ -1798,15 +1471,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "qrcode" name = "qrcode"
version = "0.14.1" version = "0.14.1"
@@ -1816,12 +1480,6 @@ dependencies = [
"image", "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"
@@ -1957,76 +1615,6 @@ 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"
@@ -2061,6 +1649,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"futures-core", "futures-core",
"http", "http",
"http-body", "http-body",
@@ -2091,12 +1681,6 @@ 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"
@@ -2320,15 +1904,6 @@ 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"
@@ -2446,20 +2021,6 @@ 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"
@@ -2775,17 +2336,6 @@ 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"
@@ -2955,12 +2505,6 @@ 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"
@@ -3295,12 +2839,6 @@ 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"
@@ -3440,25 +2978,29 @@ dependencies = [
] ]
[[package]] [[package]]
name = "zune-core" name = "zstd"
version = "0.5.1" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [ dependencies = [
"simd-adler32", "zstd-safe",
] ]
[[package]] [[package]]
name = "zune-jpeg" name = "zstd-safe"
version = "0.5.15" version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [ dependencies = [
"zune-core", "zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
] ]
+36 -4
View File
@@ -1,11 +1,31 @@
[package] [package]
name = "bzod" name = "bzod"
version = "0.1.0" description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.7.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener"
homepage = "https://bzo.in"
documentation = "https://github.com/thakares/nx9-url-shortener"
readme = "README.md"
authors = ["Sunil P. Thakare"]
keywords = [
"url-shortener",
"landing-pages",
"analytics",
"qr-code",
"self-hosted"
]
categories = [
"web-programming",
"command-line-utilities"
]
[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"] }
@@ -19,7 +39,7 @@ askama = { version = "0.12" }
argon2 = "0.5" argon2 = "0.5"
sha2 = "0.10" sha2 = "0.10"
rand = "0.8" rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
tar = "0.4" tar = "0.4"
flate2 = "1.0" flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -27,5 +47,17 @@ hex = "0.4"
time = "0.3" time = "0.3"
toml = "0.8" toml = "0.8"
qrcode = "0.14" qrcode = "0.14"
image = "0.25" image = { version = "0.25", default-features = false, features = ["png"] }
zip = { version = "2.1", default-features = false, features = ["deflate"] } zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3"
zstd = "0.13"
[lints.clippy]
let_unit_value = "allow"
useless_vec = "allow"
[profile.release]
lto = true
codegen-units = 1
strip = true
panic = "abort"
+26 -22
View File
@@ -1,7 +1,6 @@
# ========================================== # ==========================================
# Stage 1: Build # Stage 1: Builder (with optimized caching)
# ========================================== # ==========================================
# FROM rust:1.82-slim-bookworm AS builder
FROM rust:1.89-bookworm AS builder FROM rust:1.89-bookworm AS builder
WORKDIR /app WORKDIR /app
@@ -10,34 +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 ./ 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 \
@@ -47,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
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/www ./www
# Create non-root user
RUN groupadd -g 1000 bzod && \ RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod useradd -u 1000 -g bzod -m -s /bin/bash bzod
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data # 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=8654 PORT=8654 \
ENV HOST=0.0.0.0 HOST=0.0.0.0 \
ENV COOKIE_SECURE=true COOKIE_SECURE=true
EXPOSE 8654 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:-8654}/status || exit 1 CMD curl -f http://localhost:${PORT}/status || exit 1
ENTRYPOINT ["bzod"] ENTRYPOINT ["bzod"]
CMD ["serve"] CMD ["serve"]
View File
File renamed without changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sunil Purushottam Thakare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1355 -243
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
if let Ok(output) = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
{
if output.status.success() {
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !commit.is_empty() {
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
}
}
}
}
+180 -76
View File
@@ -1,11 +1,11 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# BZOD Production Deployment Script
# BZOD Deployment Script (Debian Native Deployment) # curl -fsSL https://bzo.in/deploy.sh | sudo bash
# This script sets up a secure, production-ready systemd service for BZOD.
set -euo pipefail set -euo pipefail
# Configurations BZOD_VERSION="0.7.0"
SERVICE_USER="bzod" SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod" INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod" CONFIG_DIR="/etc/bzod"
@@ -13,93 +13,145 @@ DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env" ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service" SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
# Color outputs
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m'
echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}" # Temporary file cleanup
TMP_BINARY=""
cleanup() {
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
}
trap cleanup EXIT
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
echo -e "Production deployment started...\n"
# 1. Check Root Privileges
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}" echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
exit 1 exit 1
fi fi
# 2. Install Package Dependencies # 1. Install Base Dependencies
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}" echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
apt-get update apt-get update -qq
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
# 3. Compile Production Build Locally # 2. Install Binary (safe atomic download)
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}" echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
if ! command -v cargo &> /dev/null; then
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}" ARCH="$(uname -m)"
case $ARCH in
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
REPO="thakares/nx9-url-shortener"
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
TMP_BINARY=$(mktemp)
echo "Trying GitHub Releases..."
if curl --retry 5 --retry-delay 2 --retry-connrefused \
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
else
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
if command -v docker >/dev/null 2>&1; then
TMP_GHCR=$(mktemp)
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
mv "${TMP_GHCR}" "${TMP_BINARY}"
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
fi
fi
if [ ! -s "${TMP_BINARY}" ]; then
echo -e "${BLUE}Falling back to local build...${NC}"
if ! command -v cargo >/dev/null 2>&1; then
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
exit 1
fi
apt-get install -y pkg-config build-essential
cargo build --release
cp target/release/bzod "${TMP_BINARY}"
echo -e "${GREEN}✓ Built from source${NC}"
fi
fi
# Atomic replace with backup
if [ -f "${INSTALL_PATH}" ]; then
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
fi
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
# Verify
if [ ! -x "${INSTALL_PATH}" ]; then
echo -e "${RED}Binary installation failed${NC}"
exit 1 exit 1
fi fi
cargo build --release "${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
echo -e "${GREEN}Release build completed.${NC}" echo -e "${RED}Binary verification failed${NC}"
exit 1
}
# 4. Install Binary # Verify -V also works
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}" "${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
cp target/release/bzod "${INSTALL_PATH}" echo -e "${RED}Binary -V verification failed${NC}"
chmod 755 "${INSTALL_PATH}" exit 1
chown root:root "${INSTALL_PATH}" }
echo -e "${GREEN}Binary installed successfully.${NC}"
# 5. Create Dedicated locked-down System User # Show installed version and verify it matches requested version
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}" VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
exit 1
fi
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
# 3. Create System User
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
if ! id -u "${SERVICE_USER}" &>/dev/null; then if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}" useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
else
echo "User '${SERVICE_USER}' already exists."
fi fi
# 6. Configure Directory Trees and Permissions # 4. Setup Directories
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}" echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
mkdir -p "${CONFIG_DIR}" mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
mkdir -p "${DATA_DIR}" chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
chmod 700 "${CONFIG_DIR}"
# Copy .env file if it exists, otherwise prompt/generate # 5. Configuration (preserve on upgrades)
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then echo -e "\n${BLUE}[5/8] Configuration...${NC}"
echo "Copying local .env file to ${ENV_FILE}..." if [ ! -f "${ENV_FILE}" ]; then
cp .env "${ENV_FILE}" echo -e "${BLUE}Generating new secure configuration...${NC}"
elif [ ! -f "${ENV_FILE}" ]; then
echo "Generating default configuration file at ${ENV_FILE}..."
cat <<EOF > "${ENV_FILE}" cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8654
DATA_DIR=${DATA_DIR} DATA_DIR=${DATA_DIR}
COOKIE_SECURE=true COOKIE_SECURE=true
RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32) SESSION_SECRET=$(openssl rand -hex 32)
ADMIN_USERNAME=admin
# SHA-256 for bootstrap (Default: admin)
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
EOF EOF
chmod 600 "${ENV_FILE}"
chown root:"${SERVICE_USER}" "${ENV_FILE}"
else
echo -e "${GREEN}Existing configuration preserved${NC}"
fi fi
chmod 600 "${ENV_FILE}" # 6. Systemd Service
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}" echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
# 7. Initialise DB as the service user (avoids file permission conflicts)
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}Databases initialised.${NC}"
# 8. Set Up Systemd Service
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}" cat <<EOF > "${SYSTEMD_UNIT}"
[Unit] [Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
After=network.target After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
@@ -107,11 +159,12 @@ User=${SERVICE_USER}
Group=${SERVICE_USER} Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE} EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR} ExecStart=${INSTALL_PATH} serve
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# Hardening / Sandboxing options for security # Security Hardening
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
@@ -119,6 +172,10 @@ PrivateDevices=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
LockPersonality=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/bzod ReadWritePaths=/var/lib/bzod
[Install] [Install]
@@ -127,21 +184,68 @@ EOF
chmod 644 "${SYSTEMD_UNIT}" chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload systemctl daemon-reload
echo -e "${GREEN}Systemd service registered.${NC}"
# 9. Enable and Start the Service # 7. Initialize & Start
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}" echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
systemctl enable bzod
systemctl restart bzod
sleep 2 # Database creation and migration is handled automatically by 'bzod serve'
if systemctl is-active --quiet bzod; then if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
echo -e "${GREEN}BZOD service is running successfully!${NC}" echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}"
echo -e "You can access BZOD at http://localhost:8080" # Pre-upgrade: stop service and backup databases
echo -e "Admin Login Dashboard is at http://localhost:8080/admin" if systemctl is-active --quiet bzod 2>/dev/null; then
echo -e "System service logs: journalctl -u bzod -f" echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}" systemctl stop bzod
fi
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}"
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
else else
echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}" echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
fi fi
systemctl enable --now bzod
# 8. Validation + Rollback
sleep 3
if ! systemctl is-active --quiet bzod; then
echo -e "${RED}Service failed to start! Rolling back...${NC}"
if [ -f "${INSTALL_PATH}.bak" ]; then
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
systemctl restart bzod || true
fi
journalctl -u bzod -n 50 --no-pager
exit 1
fi
# Clean up backup on success
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
# Soft health check
if command -v curl >/dev/null 2>&1; then
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
echo -e "${GREEN}✓ HTTP health check passed${NC}"
else
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
fi
fi
# Final Message
IP=$(hostname -I | awk '{print $1}' | head -n1)
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
echo -e "🌐 Web UI: http://${IP}:8654"
echo -e "🔑 Admin: http://${IP}:8654/admin"
echo -e "🖥 Architecture: ${ARCH}"
echo -e "📦 Version: ${VERSION}"
echo -e "\nNext step (first install):"
echo -e " sudo -u bzod bzod create-admin"
echo -e "\nCommands:"
echo -e " journalctl -u bzod -f"
echo -e " bzod doctor"
echo -e " systemctl status bzod"
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
+23 -27
View File
@@ -1,67 +1,63 @@
name: app-bzod name: app-bzod
services: services:
bzod: bzod:
image: nx9-url-shortener:v0.1.0
build: build:
context: . context: /DATA/AppData/bzod
dockerfile: Dockerfile dockerfile: Dockerfile
cpu_shares: 90
command: []
container_name: bzod container_name: bzod
deploy:
resources:
limits:
memory: 31940M
environment: environment:
- COOKIE_SECURE=false - COOKIE_SECURE=false
- DATA_DIR=/app/data - DATA_DIR=/app/data
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=8654 - PORT=8654
- RUST_LOG=info - RUST_LOG=info
hostname: bzod
image: nx9-url-shortener:v0.7.0
ports: ports:
- mode: ingress - mode: ingress
target: 8654 target: 8654
published: "8654" published: "8654"
protocol: tcp protocol: tcp
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- type: bind - type: bind
source: /DATA/AppData/bzod/data source: /DATA/AppData/bzod/data
target: /app/data target: /app/data
bind: bind:
create_host_path: true create_host_path: true
- type: bind - type: bind
source: /DATA/AppData/bzod/config source: /DATA/AppData/bzod/config
target: /app/config target: /app/config
bind: bind:
create_host_path: true create_host_path: true
- type: bind
source: /DATA/AppData/bzod/www
target: /app/www
- type: bind
source: /DATA/AppData/bzod/images
target: /app/images
devices: []
cap_add: []
networks: networks:
- default - default
hostname: bzod
privileged: false privileged: false
cpu_shares: 90
deploy:
resources:
limits:
memory: 31940M
networks: networks:
default: default:
name: app_default name: app_default
x-casaos: x-casaos:
hostname: ""
scheme: http
index: /
port_map: "8654"
author: self author: self
category: self category: self
hostname: ""
icon: "" icon: ""
index: /
is_uncontrolled: false
port_map: "8654"
scheme: http
title: title:
custom: nx9-url-shortener custom: nx9-url-shortener
+888
View File
@@ -0,0 +1,888 @@
# BZOD Administrator Guide
Version: v0.7.0
---
# Introduction
This guide is intended for BZOD administrators responsible for operating, maintaining, and managing a BZOD instance.
It covers:
* Administrator authentication
* User management
* Quotas
* Sessions
* Moderation
* Slug ownership
* Analytics
* Audit logs
* Backup and recovery
* Health monitoring
* Operational best practices
---
# Administrator Role
Administrators have full platform control.
Administrative capabilities include:
* Create users
* Modify users
* Disable users
* Delete users
* Reset passwords
* Manage quotas
* Review analytics
* Moderate content
* Transfer slug ownership
* Manage backups
* Review audit logs
* Monitor system health
Administrators cannot bypass audit logging.
All administrative actions are recorded.
---
# Login
Administrative login is available at:
```text
/login
```
Successful login redirects to:
```text
/admin
```
Authentication uses:
```text
users.db
```
Sessions are stored in:
```text
users.db.sessions
```
Cookie name:
```text
bzod_session
```
---
# Administrative Dashboard
Route:
```text
/admin
```
The dashboard provides a high-level overview of platform activity.
Metrics include:
* Total Users
* Active Users
* Total URLs
* Total Landing Pages
* Active Sessions
* API Tokens
* Storage Usage
* Moderation Events
* Recent Audit Events
Quick actions include:
* Create User
* View Sessions
* View Audit Logs
* Create Backup
* Review Health Status
---
# User Management
## Users List
Route:
```text
/admin/users
```
Displays:
* User ID
* Username
* Status
* Account Type
* Creation Date
Available actions:
* View
* Edit
* Disable
* Enable
* Reset Password
* Delete
---
## Create User
Route:
```text
/admin/users/new
```
Fields:
* Username
* Password
* Account Type
* Quota Limits
Supported account types:
```text
admin
standard
```
Reserved usernames cannot be used.
Examples:
```text
admin
legacy_admin
system
root
administrator
```
---
## User Detail Page
Route:
```text
/admin/users/{id}
```
Displays:
### Profile
* User ID
* Username
* Status
* Account Type
* Created Date
### Usage Statistics
* URL Count
* Landing Page Count
* Visit Count
* Storage Usage
* API Token Count
* Active Sessions
### Quotas
* Maximum URLs
* Maximum Pages
* Maximum Storage
* Maximum Tokens
### Sessions
List of active sessions.
### API Tokens
List of active tokens.
---
## Edit User
Route:
```text
/admin/users/{id}/edit
```
Administrators may:
* Change status
* Change account type
* Modify quotas
---
## Reset Password
Route:
```text
/admin/users/{id}/password
```
Creates a new password hash and invalidates existing sessions.
Audit event generated:
```text
password_reset
```
---
## Disable User
Route:
```text
/admin/users/{id}/disable
```
Effects:
* User login disabled
* Existing sessions revoked
* API access denied
Audit event generated:
```text
user_disabled
```
---
## Enable User
Route:
```text
/admin/users/{id}/enable
```
Restores account access.
Audit event generated:
```text
user_enabled
```
---
## Delete User
Route:
```text
/admin/users/{id}/delete
```
Deletion performs:
1. Session revocation
2. API token removal
3. Content removal
4. Analytics removal
5. Slug release
6. User database deletion
Audit event generated:
```text
user_deleted
```
---
# Session Management
Route:
```text
/admin/sessions
```
Displays all active platform sessions.
Information displayed:
* User ID
* Username
* Session Identifier
* Created Time
* Expiry Time
* IP Address
* User Agent
---
## Revoke Session
Individual sessions can be revoked.
Effects:
* Session removed immediately
* User forced to reauthenticate
---
## Revoke All Sessions
Administrators may invalidate all active sessions.
Useful after:
* Password compromise
* Security incidents
* Large configuration changes
---
# Quota Management
Route:
```text
/admin/quotas
```
Quotas limit user resource consumption.
Available limits:
```text
max_urls
max_pages
max_storage_mb
max_api_tokens
```
---
## Quota Reconciliation
Administrators can execute:
```text
quota_reconcile
```
Purpose:
* Detect counter drift
* Recount resources
* Repair quota usage
Common causes:
* Manual database modifications
* Failed migrations
* Interrupted operations
---
# Moderation
Route:
```text
/admin/moderation
```
Moderation allows administrators to manage abuse and policy violations.
---
## Flag Content
Marks content for review.
Audit event:
```text
content_flagged
```
---
## Disable Content
Disabled content returns:
```http
410 Gone
```
Affected endpoints:
```text
/{slug}
/p/{slug}
/api/qr/{slug}.png
/api/qr/{slug}.svg
```
Audit event:
```text
content_disabled
```
---
## Enable Content
Restores functionality.
Audit event:
```text
content_enabled
```
---
## Delete Content
Permanently removes content.
Audit event:
```text
content_deleted
```
---
# Slug Management
Route:
```text
/admin/slugs
```
Displays platform-wide slug ownership.
Information includes:
* Slug
* Owner
* Type
* Status
* Creation Date
---
## Slug Types
Supported types:
```text
url
page
```
---
## Transfer Ownership
Administrators may transfer ownership.
Workflow:
1. Validate recipient quota.
2. Copy content.
3. Update ownership.
4. Update global slug registry.
5. Write audit record.
Audit event:
```text
slug_transfer
```
Analytics are preserved.
---
# Analytics
Administrators can access analytics for any managed resource.
---
## URL Analytics
Route:
```text
/admin/analytics/url/{id}
```
Displays:
* Total Visits
* Unique Visitors
* Referrers
* Browsers
* Countries
* Visit Timeline
---
## Page Analytics
Route:
```text
/admin/analytics/page/{id}
```
Displays identical metrics for landing pages.
---
## User Analytics
Administrators can review user-level analytics.
Route:
```text
/analytics
```
Includes:
* Top Links
* Top Pages
* Referrers
* Browsers
* Countries
* Recent Visits
---
# Audit Logs
Route:
```text
/admin/audit
```
All administrative actions are recorded.
Searchable event types include:
```text
login
logout
failed_login
user_created
user_deleted
user_disabled
user_enabled
password_reset
quota_updated
slug_transfer
content_flagged
content_disabled
backup_created
restore_executed
```
Audit logs should be reviewed regularly.
---
# Backup Management
Route:
```text
/admin/backups
```
Provides web-based backup operations.
---
## Create Backup
Creates a platform snapshot.
Includes:
```text
users.db
system.db
tenant databases
```
Audit event:
```text
backup_created
```
---
## Download Backup
Allows local storage of backup archives.
Recommended frequency:
```text
Daily
```
---
## Restore Backup
Restores a selected backup archive.
Audit event:
```text
restore_executed
```
Always test restores before production use.
---
## Delete Backup
Removes backup archives from storage.
---
# Health Dashboard
Route:
```text
/admin/health
```
Provides operational diagnostics.
Displays:
* Database Status
* WAL Status
* Storage Utilization
* Backup Status
* Health Check Results
* Quota Reconciliation Results
---
## Database Health
Checks:
```text
users.db
system.db
content.db
analytics.db
```
Reports:
```text
healthy
warning
error
```
---
## Storage Monitoring
Shows:
* Total Storage
* Free Storage
* Database Sizes
* Backup Sizes
---
# Security Administration
## Password Policies
Recommendations:
* Minimum 12 characters
* Unique passwords
* Password manager usage
---
## Session Management
Recommended actions:
* Revoke old sessions
* Review active sessions
* Remove inactive users
---
## CSRF Protection
All administrative forms require valid CSRF tokens.
Invalid requests return:
```http
403 Forbidden
```
---
## Audit Reviews
Recommended review schedule:
| Event Type | Frequency |
| ----------------- | --------- |
| Failed Logins | Daily |
| User Creation | Weekly |
| Slug Transfers | Weekly |
| Backup Events | Daily |
| Moderation Events | Weekly |
---
# Disaster Recovery
Recommended workflow:
1. Stop BZOD.
2. Create backup copy.
3. Restore archive.
4. Verify databases.
5. Run integrity checks.
6. Restart service.
---
# Operational Best Practices
Recommended:
* Enable HTTPS
* Run daily backups
* Monitor disk usage
* Review audit logs
* Keep binaries updated
* Test restore procedures regularly
Avoid:
* Manual database modifications
* Direct deletion of tenant databases
* Disabling audit logging
---
# Troubleshooting
## User Cannot Login
Check:
* User status
* Session validity
* Password reset history
---
## Slug Already Exists
Check:
```text
/admin/slugs
```
for ownership conflicts.
---
## Analytics Missing
Verify:
* Analytics worker running
* Analytics database present
* Event queue processing
---
## Backup Failure
Check:
* Free disk space
* File permissions
* Backup destination path
---
# Summary
The BZOD administration system provides:
* Centralized user management
* Quotas and session controls
* Moderation and slug ownership management
* Analytics visibility
* Audit logging
* Backup and restore capabilities
* Health monitoring
while maintaining strong tenant isolation and a SQLite-native operational model.
---
End of Document.
+391
View File
@@ -0,0 +1,391 @@
# BZOD REST API
> Programmatic access to URLs, Landing Pages, QR Codes, Analytics, and Audit Logs.
## Overview
The BZOD REST API allows automation and integration with external systems such as:
* Home Assistant
* Shell Scripts
* CI/CD Pipelines
* Monitoring Systems
* Internal Applications
* Self-hosted Services
All API endpoints require authentication using an API Token generated from:
```text
Admin Dashboard → Settings → REST API Tokens
```
---
# Authentication
Generate an API token from the Admin Dashboard.
Example token:
```text
bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Pass the token using the `Authorization` header.
## Example
```bash
curl \
-H "Authorization: bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
https://your-domain.com/api/v1/stats
```
---
# Base URL
```text
https://your-domain.com/api/v1
```
Example:
```text
https://bzo.in/api/v1
```
---
# Response Format
Successful responses:
```json
{
"success": true,
"data": {}
}
```
Error responses:
```json
{
"success": false,
"error": "Invalid API token"
}
```
---
# URL Management
## List URLs
```http
GET /api/v1/urls
```
### Example
```bash
curl \
-H "Authorization: TOKEN" \
https://your-domain.com/api/v1/urls
```
---
## Create URL
```http
POST /api/v1/urls
```
### Request
```json
{
"code": "rust",
"target_url": "https://www.rust-lang.org",
"description": "Rust Language"
}
```
### Example
```bash
curl \
-X POST \
-H "Authorization: TOKEN" \
-H "Content-Type: application/json" \
-d '{
"code":"rust",
"target_url":"https://www.rust-lang.org"
}' \
https://your-domain.com/api/v1/urls
```
---
## Get URL
```http
GET /api/v1/urls/{uuid}
```
Example:
```http
GET /api/v1/urls/5d4d9e98-7cb7-4c97-9a0a-123456789abc
```
---
## Update URL
```http
PUT /api/v1/urls/{uuid}
```
---
## Delete URL
```http
DELETE /api/v1/urls/{uuid}
```
---
## URL Preview
```http
GET /api/v1/urls/{uuid}/preview
```
Returns rendered metadata used by preview cards.
---
# Landing Pages
## List Pages
```http
GET /api/v1/pages
```
---
## Create Page
```http
POST /api/v1/pages
```
### Example Request
```json
{
"title": "My Product",
"slug": "product",
"description": "Product Landing Page",
"content": "<h1>Hello World</h1>"
}
```
---
## Get Page
```http
GET /api/v1/pages/{uuid}
```
---
## Update Page
```http
PUT /api/v1/pages/{uuid}
```
---
## Delete Page
```http
DELETE /api/v1/pages/{uuid}
```
---
# Analytics
## Global Statistics
```http
GET /api/v1/stats
```
Returns overall platform metrics.
Example response:
```json
{
"total_urls": 125,
"total_pages": 12,
"total_clicks": 8431,
"total_qr_scans": 241
}
```
---
## URL Statistics
```http
GET /api/v1/stats/url/{uuid}
```
Returns analytics for a single URL.
---
## Landing Page Statistics
```http
GET /api/v1/stats/page/{uuid}
```
Returns analytics for a single landing page.
---
# QR Codes
## Download QR Code
```http
GET /api/v1/qr/{code}
```
Example:
```http
GET /api/v1/qr/rust
```
Returns QR image.
---
# Bulk Operations
## Bulk QR Export
```http
POST /api/v1/bulk/qr
```
Generate QR codes for multiple URLs.
---
## Bulk URL Operations
```http
POST /api/v1/bulk/url
```
Bulk create, update, or manage URLs.
---
# Audit Log
## List Audit Events
```http
GET /api/v1/audit
```
Returns administrative activity history.
Example response:
```json
[
{
"event": "url_created",
"user": "admin",
"timestamp": "2026-06-17T14:30:00Z"
}
]
```
---
# HTTP Status Codes
| Code | Description |
| ---- | --------------------- |
| 200 | Success |
| 201 | Created |
| 400 | Invalid Request |
| 401 | Authentication Failed |
| 403 | Access Denied |
| 404 | Resource Not Found |
| 409 | Conflict |
| 500 | Internal Server Error |
---
# Security Notes
* API tokens are displayed only once during creation.
* Tokens are stored as hashes and cannot be recovered.
* Revoke unused tokens immediately.
* Always use HTTPS.
* Never embed API tokens in public repositories.
---
# Example: Create URL From Shell Script
```bash
TOKEN="bzo_xxxxxxxxxxxxxxxxx"
curl \
-X POST \
-H "Authorization: ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"code":"example",
"target_url":"https://example.com"
}' \
https://your-domain.com/api/v1/urls
```
---
# API Stability
The BZOD API follows semantic versioning.
Current API namespace:
```text
/api/v1
```
Future breaking changes will be introduced under a new versioned namespace.
Example:
```text
/api/v2
```
+667
View File
@@ -0,0 +1,667 @@
# BZOD Architecture Guide
Version: v0.7.0
---
# Overview
BZOD is a self-hosted multi-user URL management platform written in Rust.
The platform combines:
* URL shortening
* Landing pages
* QR code generation
* Analytics
* User management
* Moderation
* Audit logging
* Backup & restore
* Disaster recovery
into a single deployable binary powered entirely by SQLite.
BZOD is designed around operational simplicity, tenant isolation, and long-term maintainability.
---
# Architectural Goals
The primary design goals are:
1. Self-hosted first
2. SQLite-first architecture
3. Multi-user operation
4. Tenant isolation
5. Simple deployment
6. Minimal dependencies
7. Easy backup and recovery
8. No vendor lock-in
---
# High-Level Architecture
```text
┌─────────────┐
│ Browser │
└──────┬──────┘
│
▼
┌────────────────────┐
│ Axum Router │
└─────────┬──────────┘
│
┌────────────────────┼────────────────────┐
│ │ │
▼ ▼ ▼
users.db system.db User Databases
Users Global Slugs content.db
Sessions Audit Events analytics.db
Quotas Moderation
API Tokens Settings
```
---
# Runtime Components
## Web Layer
Location:
```text
src/web/
```
Responsible for:
* HTTP routing
* Dashboard rendering
* Form handling
* Authentication checks
* Redirect handling
* REST API endpoints
Major modules:
```text
admin/ (modular feature directory)
auth.rs (authentication and session handling)
dashboard.rs (dashboard rendering)
urls.rs (URL management handlers)
pages.rs (landing page management handlers)
analytics.rs (analytics and export handlers)
settings.rs (settings and configuration handlers)
users.rs (user management handlers)
sessions.rs (session administration)
quotas.rs (quota management)
health.rs (health diagnostics)
backups.rs (backup and restore handlers)
api_keys.rs (API key management)
audit.rs (audit log handlers)
moderation.rs (content moderation handlers)
mod.rs (module exports and shared helpers)
api.rs
pages.rs
redirect.rs
qr.rs
system.rs
multi_user.rs
routes.rs
```
---
## Authentication Layer
Location:
```text
src/auth/
```
Responsible for:
* Password hashing
* Session validation
* Cookie management
* CSRF protection
* Authorization
Modules:
```text
csrf.rs
middleware.rs
password.rs
session.rs
```
Authentication technologies:
* Argon2id password hashing
* Session cookies
* CSRF tokens
* RBAC checks
---
## Database Layer
Location:
```text
src/db/
```
Responsible for:
* Schema creation
* Migrations
* Database access
* Analytics storage
* User management
Modules:
```text
admin.rs
analytics.rs
audit_events.rs
content.rs
migrations.rs
sqlite.rs
users.rs
```
---
# Database Architecture
BZOD uses multiple SQLite databases rather than a single monolithic database.
This approach provides:
* Better isolation
* Easier backup
* Simpler disaster recovery
* Reduced risk of cross-user data leakage
---
## users.db
Purpose:
Central identity and account database.
Contains:
```text
users
sessions
api_tokens
quotas
```
Stores:
* User accounts
* Password hashes
* Session records
* API tokens
* Quota information
---
## system.db
Purpose:
Global platform metadata.
Contains:
```text
global_slugs
audit_events
moderation_events
reserved_slugs
settings
slug_history
```
Stores:
* Global slug ownership
* Audit records
* Moderation actions
* Platform settings
* Slug transfers
---
## Tenant Databases
Each user receives isolated databases.
Directory structure:
```text
users/
└── <user_id>/
├── content.db
└── analytics.db
```
---
### content.db
Stores:
* URLs
* Landing pages
* Metadata
---
### analytics.db
Stores:
* Visits
* Referrers
* QR scans
* Browser information
* Analytics aggregates
---
# Multi-User Architecture
BZOD v0.5.0 introduced complete tenant isolation.
Each user owns:
```text
content.db
analytics.db
```
Users cannot directly access:
* Other users' URLs
* Other users' landing pages
* Other users' analytics
The administrator accesses all tenants through controlled administrative interfaces.
---
# Global Slug Namespace
All public URLs are tracked in:
```text
system.db -> global_slugs
```
Purpose:
Prevent collisions across users.
Example:
```text
User A owns:
https://bzo.in/!office
User B cannot create:
https://bzo.in/!office
```
This guarantees global uniqueness.
---
# Request Lifecycle
## URL Redirect
Request:
```text
GET /abc123
```
Flow:
```text
Browser
↓
Axum Router
↓
global_slugs lookup
↓
Locate owner database
↓
Resolve URL
↓
Validate destination
↓
Record analytics
↓
301 Redirect (with safe Location header construction)
```
---
## Landing Page
Request:
```text
GET /p/demo
```
Flow:
```text
Browser
↓
Router
↓
global_slugs lookup
↓
Tenant content.db lookup
↓
Render page
```
---
## QR Generation
Request:
```text
GET /api/qr/demo.svg
```
Flow:
```text
Router
↓
global_slugs lookup
↓
Generate QR
↓
Return SVG
```
---
# Analytics Pipeline
Location:
```text
src/analytics/
```
Components:
```text
events.rs
queue.rs
worker.rs
aggregate.rs
location.rs
```
Responsibilities:
* Visit tracking
* QR tracking
* Browser detection
* Referrer parsing
* Aggregation
---
# Background Jobs
Location:
```text
src/jobs/
```
Jobs:
## aggregate.rs
Analytics aggregation.
## backup.rs
Automated backups.
## expiry.rs
Expired content cleanup.
## retention.rs
Retention policy enforcement.
## healthcheck.rs
System health validation.
## quota_reconcile.rs
Quota consistency verification.
---
# Services Layer
Location:
```text
src/services/
```
Purpose:
Business logic abstraction.
Modules:
```text
api_keys.rs
audit.rs
bulk.rs
landing_pages.rs
qr.rs
shortener.rs
```
This layer separates business rules from HTTP handlers.
---
# CLI Architecture
Location:
```text
src/cli/
```
The CLI and Web UI share the same internal services.
Examples:
```bash
bzod create-admin
bzod create-user
bzod backup
bzod restore
bzod doctor
bzod migrate
```
This avoids duplicate logic between administration methods.
---
# Security Model
Security mechanisms:
## Authentication
* Argon2id password hashes
* Session cookies
## Authorization
* RBAC
* Administrative permission checks
## CSRF Protection
* Form tokens
* Request validation
## Tenant Isolation
* Separate databases
* Controlled access paths
## Audit Logging
All critical operations are recorded.
Examples:
* Login attempts
* User creation
* Password resets
* Slug transfers
* Moderation actions
---
# Backup & Recovery
BZOD is designed for SQLite-first recovery.
Backup targets:
```text
users.db
system.db
admin/
users/*
```
Capabilities:
* Full backups
* Restore operations
* Upgrade migrations
* Disaster recovery validation
---
# Testing Architecture
Location:
```text
tests/
```
Coverage includes:
* Authentication
* Authorization
* User management
* Analytics
* Backups
* Disaster recovery
* Routing
* Security
* Concurrency
* Upgrade validation
* Multi-user isolation
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
---
# Deployment Models
Supported deployments:
## Native
```bash
cargo build --release
./bzod serve
```
## Systemd
```text
bzod.service
```
## Docker
```text
Dockerfile
docker-compose.yml
```
---
# Future Architecture Direction
Planned for future releases:
* Geo analytics
* OpenAPI generation
* SSO integration
* Multi-organization support
* Advanced reporting
* Distributed analytics aggregation
---
# Summary
BZOD is built around a simple principle:
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
The platform achieves this through:
* Rust
* Axum
* SQLite
* Tenant isolation
* Multi-database architecture
* Strong automated validation
* Operational simplicity
+584
View File
@@ -0,0 +1,584 @@
# Backup & Restore Guide
Version: v0.7.0
Applies To: BZOD Multi-User Platform
---
# Overview
BZOD provides built-in backup and recovery functionality for both single-user and multi-user deployments.
The backup architecture is designed to support:
* Full platform backups
* Individual tenant backups
* Disaster recovery
* Upgrade safety
* Migration validation
* Data integrity verification
All production deployments should maintain regular backups before performing upgrades, maintenance, or administrative operations.
---
# Database Architecture
BZOD stores data across multiple SQLite databases.
## Core Databases
```text
data/
├── users.db
├── system.db
└── users/
```
### users.db
Stores:
* User accounts
* Password hashes
* Account status
* Roles
* Sessions
* Quotas
* API tokens
### system.db
Stores:
* Global slug registry
* Reserved slugs
* Slug ownership history
* Audit events
* Moderation events
* System settings
---
## Tenant Databases
Each tenant owns isolated content and analytics databases.
```text
data/users/{user_id}/
├── content.db
└── analytics.db
```
### content.db
Stores:
* Short URLs
* Landing pages
* Metadata
* Tags
* QR code configuration
### analytics.db
Stores:
* Visit events
* Referrers
* Browser information
* Country information
* Aggregated statistics
---
# Backup Types
## Full Platform Backup
Creates a complete snapshot of the entire BZOD installation.
Includes:
```text
users.db
system.db
all tenant content.db files
all tenant analytics.db files
```
Recommended for:
* Daily scheduled backups
* Upgrades
* Server migration
* Disaster recovery
---
## User Backup
Creates a backup of a single tenant.
Includes:
```text
content.db
analytics.db
```
Recommended for:
* User export
* User migration
* User recovery
---
# CLI Backup Commands
## Create Full Backup
```bash
bzod backup
```
Output:
```text
backups/
└── backup-YYYYMMDD-HHMMSS.zip
```
---
## Create User Backup
```bash
bzod backup-user 42
```
Output:
```text
backups/
└── user-42-YYYYMMDD-HHMMSS.zip
```
---
# CLI Restore Commands
## Restore Full Backup
```bash
bzod restore backup-20260619-020000.zip
```
Restores:
* users.db
* system.db
* all tenant databases
---
## Restore Single User
```bash
bzod restore-user user-42-20260619.zip
```
Restores only:
```text
users/42/content.db
users/42/analytics.db
```
without affecting any other tenant.
---
# Web-Based Backup Management
Administrative users can manage backups through:
```text
/admin/backups
```
Features:
* Create backup
* Download backup
* Upload backup
* Restore backup
* Delete backup
Only authenticated administrators may access backup operations.
---
# Backup Strategy
## Recommended Schedule
### Daily
```text
02:00 AM
```
Create a full platform backup.
---
### Weekly
```text
Sunday 03:00 AM
```
Create a full backup and copy it to:
* NAS
* Secondary server
* External storage
---
### Monthly
Archive a backup for long-term retention.
Recommended retention:
```text
12 months
```
---
# Retention Policy
Recommended policy:
```text
Daily Backups:
30 days
Weekly Backups:
12 weeks
Monthly Backups:
12 months
```
Adjust retention according to compliance requirements.
---
# Upgrade Procedure
Always create a backup before upgrading.
## Step 1
Create backup:
```bash
bzod backup
```
## Step 2
Upgrade BZOD binary.
## Step 3
Start BZOD.
```bash
bzod serve
```
## Step 4
Allow database migrations to complete.
## Step 5
Verify:
* Login
* URLs
* Landing pages
* Analytics
* Administration panels
---
# Restore Validation
After every restore operation verify:
## Authentication
* Administrator login works
* Standard user login works
## Content
* URLs are visible
* Landing pages render correctly
## Routing
* Slug redirects work
* Landing page routes resolve
## Analytics
* Visit counts exist
* Analytics dashboards load
## System
* Audit events visible
* Moderation records preserved
* System settings preserved
## Multi-User
* Tenant isolation maintained
* Ownership mappings preserved
---
# Disaster Recovery Scenarios
## Scenario 1: Deleted User
Problem:
```text
User account accidentally deleted.
```
Recovery:
```bash
bzod restore-user user-42.zip
```
Verify:
* URLs restored
* Pages restored
* Analytics restored
---
## Scenario 2: Corrupted Tenant Database
Problem:
```text
content.db corruption
```
Recovery:
```bash
bzod restore-user user-42.zip
```
or
```bash
bzod restore full-backup.zip
```
---
## Scenario 3: Corrupted users.db
Problem:
```text
Unable to login
Missing users
Session failures
```
Recovery:
```bash
bzod restore full-backup.zip
```
---
## Scenario 4: Corrupted system.db
Problem:
```text
Slug resolution failures
Moderation data missing
Settings lost
```
Recovery:
```bash
bzod restore full-backup.zip
```
---
## Scenario 5: Complete Server Failure
Problem:
```text
Disk failure
Server loss
Hardware replacement
```
Recovery:
1. Reinstall operating system
2. Install BZOD
3. Restore backup
```bash
bzod restore backup.zip
```
4. Start BZOD
```bash
bzod serve
```
---
# WAL Mode
BZOD uses SQLite Write-Ahead Logging (WAL).
Examples:
```text
users.db
users.db-wal
users.db-shm
system.db
system.db-wal
system.db-shm
content.db
content.db-wal
content.db-shm
analytics.db
analytics.db-wal
analytics.db-shm
```
Benefits:
* Improved concurrency
* Better crash recovery
* Faster write operations
---
# Backup Safety
Do not manually copy live SQLite databases while the server is actively writing.
Always use:
```bash
bzod backup
```
or the Backup Management UI.
This ensures consistent snapshots.
---
# Security Considerations
Backups may contain:
* User accounts
* Password hashes
* Session metadata
* Analytics data
* Audit records
* API token hashes
Even though passwords and tokens are stored as hashes, backup archives should be treated as sensitive information.
Recommended practices:
* Encrypt backup storage
* Restrict filesystem permissions
* Maintain offsite copies
* Transfer backups over secure channels
* Test restores periodically
---
# Backup Testing
A backup is only useful if it can be restored.
Quarterly validation is recommended.
Example:
```bash
mkdir restore-test
bzod restore backup.zip \
--data-dir restore-test
```
Verify:
* Login works
* URLs resolve
* Landing pages load
* Analytics display
* Administration dashboard functions
---
# Production Recommendation
Minimum production policy:
```text
Daily Full Backup
Weekly Offsite Backup
Monthly Archive Backup
Quarterly Restore Validation
```
Following this policy protects against:
* User mistakes
* Database corruption
* Upgrade failures
* Hardware failures
* Site disasters
and provides a reliable recovery path for BZOD deployments.
+408
View File
@@ -0,0 +1,408 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project follows Semantic Versioning.
---
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
## Added
### Responsive UI
* Responsive layouts for admin and user URL registry panels
* Responsive layouts for admin and user landing page registry panels
* Desktop, laptop, tablet, and mobile layout support
* Table-to-card responsive behavior for registry panels
* Resolved horizontal scrolling issues in registry panels
### Theme Support
* Dark/light theme toggle
* Theme persistence across sessions
* Responsive theme behavior across device sizes
### Build Metadata
* Introduced `build.rs` build script for compile-time metadata
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
* Graceful fallback to `"unknown"` when Git metadata is unavailable
### Public Landing Page
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
* Public/runtime www assets supported by the deployment layout
## Changed
* Documentation updated to reflect v0.7.0 current state
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
## Notes
* No API behavior changes
* No database schema changes
* No authentication or security behavior changes
* Existing redirect, routing, and tenant isolation behavior preserved
---
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
# v0.5.3 — Architecture Refinement & Redirect Hardening
---
## Changed
### Architecture
* Eliminated the monolithic `admin.rs` handler file
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
* Extracted shared authentication and authorization helpers
* Extracted common export and helper functionality
### Redirect Handling
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
* Added destination URL validation (scheme validation, control character rejection)
* Added safe HTTP Location header construction
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
* Removed synchronous expiration writes from the redirect hot path
---
## Improved
* Database lock scoping across admin handlers
* Error handling consistency and observability
* Handler decomposition for oversized functions
* Reduced duplicated handler logic across admin operations
---
## Verified
* Root landing page (GET /) confirmed as intentional route serving www/index.html
* Release binary built successfully
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
* SQLite WAL mode and foreign-key enforcement initialized successfully
* All existing migrations reported as up to date
* Comprehensive automated test suite passed, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
---
## Notes
* This release is an internal architecture and quality improvement
* No new user-facing features were introduced
* Existing API and route behavior was preserved
* Existing redirect security and tenant isolation behavior was preserved
---
# v0.5.1 - General Availability (GA)
Release Date: 2026-06-20
BZOD v0.5.1 is the largest release since project inception, transforming BZOD from a single-user URL shortener into a complete multi-user redirector, landing page, analytics, and administration platform.
---
## Added
### Multi-User Platform
* Multi-user architecture with isolated tenant databases
* Standard user accounts
* Administrator accounts
* User provisioning and lifecycle management
* User enable/disable operations
* User deletion workflows
* Password reset functionality
* User quota management
* User database isolation
### Authentication & Security
* Session-based authentication
* CSRF protection
* Role-Based Access Control (RBAC)
* Password hashing and verification
* Session invalidation
* Login/logout workflows
* Administrative privilege separation
* Audit logging
### User Self-Service Portal
* User dashboard
* My Links management
* My Pages management
* User analytics dashboard
* API token management
* Password management
* Profile management
### Administration
* User management dashboard
* User detail pages
* User creation forms
* User editing interface
* Session administration
* Quota administration
* Moderation dashboard
* Slug management dashboard
* Audit event viewer
* Backup management interface
* System health dashboard
### Analytics
* Per-user analytics
* URL analytics dashboards
* Landing page analytics dashboards
* Browser statistics
* Referrer tracking
* Visit logging
* Geographic analytics framework
* Analytics aggregation jobs
### Content Management
* Landing page builder
* URL registry management
* Global slug namespace
* Slug ownership tracking
* Slug transfer workflows
* Soft delete support
* Moderation controls
### Operations
* Backup CLI
* Restore CLI
* User backup support
* User restore support
* Database diagnostics
* Health checks
* Quota reconciliation jobs
* Retention jobs
* Expiry jobs
* Aggregation workers
### Documentation
* Installation Guide
* Upgrade Guide
* Multi-User Guide
* Administration Guide
* Security Guide
* Backup & Restore Guide
* Database Documentation
* Architecture Documentation
* CLI Documentation
* API Documentation
* Testing Documentation
---
## Changed
### Architecture
* Migrated from single-user storage model to tenant-isolated storage model
* Introduced users.db as central identity store
* Introduced system.db as global platform metadata store
* Introduced per-user content databases
* Introduced per-user analytics databases
### Routing
* Unified global slug resolution
* Centralized slug ownership tracking
* Improved redirect handling
* Improved landing page routing
### Analytics
* Improved aggregation performance
* Improved reporting consistency
* Improved analytics isolation
### Administration
* Expanded administrative tooling
* Improved dashboard coverage
* Added operational visibility
---
## Security
### Added
* CSRF validation
* Session management
* RBAC enforcement
* Audit event logging
* User isolation controls
* Slug ownership validation
### Hardened
* Authentication flows
* Session validation
* Administrative authorization
* User lifecycle operations
---
## Database
### Added
* users.db
* system.db
* Per-user content.db
* Per-user analytics.db
* Migration framework
### Improved
* WAL mode support
* Upgrade migrations
* Backup compatibility
* Recovery workflows
---
## Testing
### Added
Comprehensive automated validation covering:
* Authentication tests
* Authorization tests
* Migration tests
* Upgrade validation tests
* User isolation tests
* Slug namespace tests
* Slug transfer tests
* Moderation tests
* Backup and restore tests
* Disaster recovery tests
* Analytics tests
* Concurrency tests
* HTTP end-to-end tests
* Business workflow tests
* Security regression tests
### Coverage
* 90+ unit and integration tests
* HTTP workflow validation
* Upgrade path verification
* Multi-user isolation verification
* Backup and recovery validation
---
## Fixed
### Authentication
* Multi-user migration login regressions
* Session validation issues
* Administrative account migration edge cases
### Routing
* Redirect handling consistency
* Slug ownership synchronization
* Landing page resolution issues
### Analytics
* Aggregation edge cases
* Reporting consistency
* Isolation validation
### Concurrency
* Fixed mutex deadlock conditions discovered during E2E testing
* Improved lock scoping around audit logging
### Administration
* Improved slug transfer workflows
* Improved user lifecycle operations
* Improved dashboard consistency
---
## Upgrade Notes
### From v0.4.0
BZOD v0.5.0 introduces a new multi-user architecture.
Existing installations are automatically migrated during startup.
Migration includes:
* Legacy administrator migration
* Global slug index generation
* User database creation
* Analytics preservation
* Content preservation
Backups are strongly recommended before upgrading.
---
# v0.4.0
## Added
* Raw visitor activity logs
* Analytics drill-down pages
* Date-range analytics filters
* CSV export
* JSON export
* Advanced pagination
* Visitor log tables
## Improved
* Registry pagination
* Analytics navigation
* Export performance
## Fixed
* Pagination edge cases
* Analytics sorting consistency
+286
View File
@@ -0,0 +1,286 @@
# BZOD Command Line Interface (CLI)
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
The current command list for BZOD v0.7.0 is:
```text
$ bzod --help
BZOD - Personal Redirector & Landing Page Platform
Usage: bzod <COMMAND>
Commands:
serve Start the BZOD web server
backup Create a tar.gz backup of all databases
restore Restore databases from a tar.gz backup file
migrate Apply pending database schema migrations
stats Print database statistics and record counts in the terminal
validate Perform a one-shot validation of all registered short link destinations
create-admin Create a new administrator user in the database
doctor Run database diagnostics and health checks
shorten Shorten a URL (Feature 3)
expand Expand a shortened code or custom slug to its destination URL (Feature 4)
create-user Create a new standard user in the database
delete-user Delete a standard user and all their databases/slugs
disable-user Disable a standard user
enable-user Enable a standard user
reset-password Reset standard user's password
list-users List all standard/system users
backup-user Backup a standard user's databases to a .tar.zst package
restore-user Restore a standard user's databases from a .tar.zst package
admin-migrate FUTURE: Migrate legacy admin content to a specific admin tenant database
repair Repair registry and database inconsistencies
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help
```
---
# Server Operations
## Start Web Server
```bash
bzod serve
```
---
# Backup & Recovery
## Full Backup
```bash
bzod backup
```
Creates a compressed backup archive containing:
* users.db
* system.db
* content databases
* analytics databases
* user directories
## Full Restore
```bash
bzod restore backup.tar.gz
```
Restores an entire BZOD installation from a backup archive.
---
# Database Operations
## Apply Migrations
```bash
bzod migrate
```
Applies any pending database migrations.
Safe to execute multiple times.
## Database Statistics
```bash
bzod stats
```
Displays database statistics, record counts, storage usage, and operational metrics.
---
# Validation & Diagnostics
## Validate Links
```bash
bzod validate
```
Checks all registered URLs and reports invalid destinations.
## Health Diagnostics
```bash
bzod doctor
```
Performs:
* SQLite integrity checks
* WAL validation
* Database availability checks
* Storage verification
* System health diagnostics
* Global registry integrity validation
## Registry Repair
```bash
bzod repair registry --dry-run
```
Provides a transaction-safe repair utility for fixing global slug registry inconsistencies detected by `bzod doctor`.
* Use `--dry-run` to preview changes safely.
* Use `--force` to execute changes and remove orphaned entries.
* Use `--slug <slug>` to target a single missing entry.
---
# URL Management
## Create Short URL
```bash
bzod shorten https://example.com
```
## Expand Existing URL
```bash
bzod expand abc123
```
Returns the destination URL associated with the slug.
---
# Administrator Management
## Create Administrator
```bash
bzod create-admin admin
```
Creates a new administrator account.
---
# User Management
## List Users
```bash
bzod list-users
```
Displays all users in the platform.
## Create User
```bash
bzod create-user alice
```
Creates a new standard user.
## Disable User
```bash
bzod disable-user alice
```
Blocks login and invalidates sessions.
## Enable User
```bash
bzod enable-user alice
```
Re-enables a disabled user.
## Reset Password
```bash
bzod reset-password alice
```
Resets a user's password.
## Delete User
```bash
bzod delete-user alice
```
Deletes:
* User account
* User databases
* Sessions
* API tokens
* Slug ownership
---
# User Backup Operations
## Backup User
```bash
bzod backup-user alice
```
Creates a portable `.tar.zst` archive containing all user-owned data.
## Restore User
```bash
bzod restore-user alice.tar.zst
```
Restores a user from a previously generated archive.
---
# Recommended Maintenance
Daily:
```bash
bzod doctor
```
Weekly:
```bash
bzod backup
```
Before Upgrades:
```bash
bzod backup
bzod validate
```
After Upgrades:
```bash
bzod migrate
bzod doctor
```
---
# Related Documentation
* INSTALL.md
* MULTI_USER.md
* ADMIN_GUIDE.md
* BACKUP_RESTORE.md
* SECURITY.md
* API.md
* ARCHITECTURE.md
+354
View File
@@ -0,0 +1,354 @@
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
Unlike traditional URL shorteners that focus primarily on URL redirection, BZOD provides a complete platform for managing URLs, landing pages, analytics, users, permissions, backups, and operational workflows.
## Quick Comparison
| Feature | BZOD | Shlink | YOURLS | Chhoto URL |
|--------------------------|------|--------|--------|------------|
| Language | Rust | PHP | PHP | Rust |
| Single Binary | ✅ | ❌ | ❌ | ✅ |
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
| QR Code + Analytics | ✅ | Partial| Plugin | Partial |
| Password Protection | ✅ | Limited| Plugin | ❌ |
| Backup & Restore | ✅ | External| External| ❌ |
| Audit Trail | ✅ | Limited| Plugin | ❌ |
| CLI Tools | ✅ | Limited| Limited| Limited |
| Dependencies | None | PHP + DB | PHP + DB | None |
| Deployment Complexity | Low | Medium | High | Low |
---
### Rust URL Shortener Comparison
| Project | Language | Single Binary | Landing Pages | QR Codes + Analytics | Password Protection | Backup & Restore | CLI Tools | Audit Trail | Admin Dashboard | Notes |
|----------------------|----------|---------------|---------------|----------------------|---------------------|------------------|-------------|-------------|-----------------|--------------------------------------------|
| **BZOD** | Rust | ✅ (~11 MB) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | Feature-rich, multi-user ready, strong philosophy |
| Chhoto URL | Rust | ✅ | ❌ | Partial | ❌ | ❌ | Limited | ❌ | Basic | Very minimal, smallest footprint |
| smrs | Rust | ✅ | ❌ | ❌ | ❌ | ❌ | Limited | ❌ | Basic | Personal project, very simple |
| urlshortener-rs | Rust | Library | N/A | N/A | N/A | N/A | N/A | N/A | N/A | Library, not full server |
| Custom Rust | Rust | Varies | Varies | Varies | Varies | Varies | Varies | Varies | Varies | Usually minimal implementations |
# Executive Summary
BZOD combines:
* URL shortening
* Landing pages
* QR code generation
* QR analytics
* Link analytics
* Password-protected links
* Link expiration
* REST API
* Administrative dashboard
* Multi-user operation
* User management
* User quotas
* Session management
* Audit logging
* Moderation
* Backup & restore
* Disaster recovery tooling
into a single Rust binary deployment.
---
# At a Glance
| Feature | BZOD |
| -------------------- | ----------------- |
| Language | Rust |
| License | MIT OR Apache-2.0 |
| Deployment | Single Binary |
| Runtime Dependencies | None |
| Database | SQLite |
| Multi-User | Yes |
| Landing Pages | Yes |
| QR Codes | Yes |
| Analytics | Yes |
| REST API | Yes |
| CLI Tools | Yes |
| Backups | Built-in |
| Audit Logs | Built-in |
| RBAC | Built-in |
---
# What Changed in v0.5.0
BZOD v0.5.0 introduces a major architectural evolution.
## New Platform Capabilities
* Multi-user architecture
* Tenant isolation
* Global slug namespace
* User management
* User quotas
* Session management
* Administrative dashboards
* User self-service dashboards
* Audit event logging
* Moderation workflows
* Backup management
* Health monitoring
* Upgrade framework
* Migration tooling
BZOD is no longer merely a URL shortener.
It is now a self-hosted URL Management Platform.
---
# Traditional URL Shortener Comparison
| Capability | BZOD | Shlink | YOURLS | Chhoto URL |
| ------------------- | ---- | -------- | -------- | ---------- |
| URL Shortening | ✅ | ✅ | ✅ | ✅ |
| Landing Pages | ✅ | ❌ | Plugin | ❌ |
| QR Generation | ✅ | Partial | Plugin | Partial |
| QR Analytics | ✅ | Partial | Plugin | ❌ |
| Password Protection | ✅ | Limited | Plugin | ❌ |
| Link Expiration | ✅ | ✅ | Plugin | Limited |
| REST API | ✅ | ✅ | ✅ | JSON-RPC |
| Backup & Restore | ✅ | External | External | ❌ |
| Audit Logs | ✅ | Limited | Plugin | ❌ |
| Multi User | ✅ | Partial | Plugin | ❌ |
| User Quotas | ✅ | ❌ | ❌ | ❌ |
| User Isolation | ✅ | ❌ | ❌ | ❌ |
| User Dashboards | ✅ | ❌ | ❌ | ❌ |
---
# Multi-User Platform Comparison
BZOD v0.5.0 introduces first-class multi-user support.
| Capability | BZOD |
| ---------------------- | ---- |
| User Accounts | ✅ |
| Administrator Accounts | ✅ |
| User Isolation | ✅ |
| User Quotas | ✅ |
| Session Management | ✅ |
| API Tokens | ✅ |
| Audit Trail | ✅ |
| Moderation | ✅ |
| Tenant Analytics | ✅ |
| Self-Service Portal | ✅ |
Most self-hosted URL shorteners are fundamentally single-user applications.
BZOD is designed for:
* Individuals
* Teams
* Organizations
* Educational Institutions
* Governments
* Service Providers
---
# Security Comparison
| Security Feature | BZOD | Typical URL Shortener |
| ------------------------- | ---- | --------------------- |
| Argon2id Password Hashing | ✅ | Varies |
| Session Management | ✅ | Basic |
| CSRF Protection | ✅ | Varies |
| RBAC | ✅ | Rare |
| Audit Logging | ✅ | Rare |
| User Disablement | ✅ | Rare |
| Moderation Controls | ✅ | Rare |
| Tenant Isolation | ✅ | Rare |
| API Token Security | ✅ | Varies |
---
# Operations Comparison
| Operational Feature | BZOD |
| ------------------- | ---- |
| Backup Creation | ✅ |
| Backup Restore | ✅ |
| User Backup | ✅ |
| User Restore | ✅ |
| Disaster Recovery | ✅ |
| Upgrade Validation | ✅ |
| Health Monitoring | ✅ |
| WAL Recovery | ✅ |
| Migration Framework | ✅ |
Most competing products rely on external tooling for these capabilities.
---
# Deployment Comparison
| Requirement | BZOD | Shlink | YOURLS |
| -------------------------- | ---- | -------- | -------- |
| Single Binary | ✅ | ❌ | ❌ |
| SQLite Only | ✅ | Optional | Optional |
| External Database Required | ❌ | Usually | Usually |
| Docker Support | ✅ | ✅ | ✅ |
| Systemd Support | ✅ | Manual | Manual |
| Backup Framework | ✅ | ❌ | ❌ |
| Upgrade Framework | ✅ | ❌ | ❌ |
---
# BZOD vs Go-Based URL Shorteners
Popular Go alternatives include:
* Krtk
* Goshorly
* Slash
* Shortr
* Custom Gin/Echo implementations
### Strengths of Go Projects
* Small binaries
* Excellent performance
* Simple codebases
### Strengths of BZOD
* Multi-user support
* Landing pages
* User management
* Built-in analytics
* Backup framework
* Audit logging
* Moderation
* Administrative dashboards
---
# BZOD vs Python-Based Solutions
Examples:
* Pygmy
* Schort
* ReducePy
* Flask-based projects
* FastAPI-based projects
### Python Advantages
* Rapid development
* Familiar ecosystem
### BZOD Advantages
* No runtime dependency
* Lower memory consumption
* Single binary deployment
* Operational tooling included
* Better long-term maintenance characteristics
---
# Reliability & Testing
BZOD v0.5.0 includes a comprehensive automated validation suite.
Coverage includes:
* Unit tests
* Integration tests
* HTTP E2E tests
* Business workflow tests
* Upgrade validation tests
* Backup/restore tests
* Disaster recovery tests
* Security tests
* Concurrency tests
* WAL recovery tests
The platform is validated using more than 90 automated tests.
---
# NX9 Platform Philosophy
BZOD follows the NX9 engineering philosophy:
* Linux-first
* Rust-first
* Self-hosted
* Privacy-first
* No telemetry
* No vendor lock-in
* No external dependencies
* Single binary deployment
The goal is simple:
> Build software that remains useful, understandable, maintainable, and deployable decades into the future.
---
# Who Should Use BZOD?
BZOD is suitable for:
### Individuals
* Personal URL management
* Homelabs
* Self-hosted services
### Organizations
* Marketing campaigns
* Internal redirects
* Landing page hosting
### Governments
* Public service redirects
* Long-term link preservation
* Controlled infrastructure
### Service Providers
* Multi-tenant URL management
* Managed short-link services
* White-label deployments
---
# Conclusion
BZOD v0.5.0 is not simply a URL shortener.
It is a self-hosted URL Management Platform providing:
* Multi-user operation
* Tenant isolation
* URL shortening
* Landing pages
* QR generation
* Analytics
* Audit logging
* Moderation
* User administration
* Backup & restore
* Health monitoring
within a single Rust binary deployment.
BZOD is designed for individuals, organizations, governments, educational institutions, and service providers that require full ownership of their links, analytics, and infrastructure.
> Own your links.
> Own your data.
> Own your infrastructure.
No telemetry. No vendor lock-in. No unnecessary complexity.
+503
View File
@@ -0,0 +1,503 @@
# DATABASES.md
# BZOD Database Architecture
BZOD v0.7.0 uses SQLite exclusively.
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
---
# Overview
BZOD stores data in the following structure:
```text
data/
├── admin/
│ ├── admin.db
│ ├── system.db
│ └── users.db
│
└── users/
├── 1/
│ ├── analytics.db
│ ├── content.db
│ └── profile.db
│
├── 2/
│ ├── analytics.db
│ ├── content.db
│ └── profile.db
│
└── N/
├── analytics.db
├── content.db
└── profile.db
```
Each user receives isolated databases.
No user content or analytics are stored in the central administrative databases.
---
# Administrative Databases
Administrative databases are located under:
```text
data/admin/
```
---
# users.db
Primary authentication and user management database.
Purpose:
* User accounts
* Password hashes
* Sessions
* Quotas
* API tokens
* User status tracking
Typical tables:
```text
users
sessions
quotas
api_tokens
```
Responsibilities:
* Authentication
* Authorization
* Session management
* Account status
* Quota enforcement
This is the primary identity database of the platform.
---
# system.db
Global platform database.
Purpose:
* Global slug namespace
* Moderation
* Auditing
* System configuration
Typical tables:
```text
global_slugs
slug_history
moderation_events
audit_events
reserved_slugs
settings
```
Responsibilities:
* Global slug uniqueness
* Slug ownership
* Moderation actions
* Audit logging
* System settings
Every redirect ultimately resolves through records stored in this database.
---
# admin.db
Administrative application database.
Purpose:
* Administrative metadata
* Administrative API key records
* Legacy compatibility structures
* Internal management data
Typical tables:
```text
api_keys
audit_events
```
This database is reserved for administrative functions and does not store tenant content.
---
# Tenant Databases
Tenant databases are located under:
```text
data/users/{user_id}/
```
Each user owns a completely isolated set of databases.
Example:
```text
data/users/2/
├── analytics.db
├── content.db
└── profile.db
```
---
# content.db
Stores user-owned content.
Purpose:
* Short URLs
* Landing pages
* QR metadata
* Preview metadata
Typical tables:
```text
urls
pages
qr_codes
previews
```
Responsibilities:
* URL management
* Landing page management
* Content ownership
This database contains the actual resources owned by a user.
---
# analytics.db
Stores traffic and visitor information.
Purpose:
* Visit recording
* Referrer tracking
* Browser tracking
* Country statistics
* Aggregated analytics
Typical tables:
```text
visits
referrers
browsers
countries
daily_stats
```
Responsibilities:
* Analytics collection
* Reporting
* Dashboard statistics
Analytics are fully isolated per user.
Administrators access aggregated analytics by querying each user's analytics database.
---
# profile.db
Stores user-specific profile information.
Purpose:
* User preferences
* Profile settings
* Future extensible metadata
Typical tables:
```text
profile
preferences
```
Responsibilities:
* User profile management
* Dashboard preferences
* Future personalization features
---
# Database Isolation Model
BZOD follows a strict tenant isolation model.
```text
User A
├── content.db
├── analytics.db
└── profile.db
User B
├── content.db
├── analytics.db
└── profile.db
```
User databases never share tables.
Cross-user content access is prevented by design.
Benefits:
* Security
* Easier backups
* Easier deletion
* Reduced corruption impact
---
# Global Slug Registry
The system maintains a single namespace.
Stored in:
```text
system.db
```
Table:
```text
global_slugs
```
Example:
```text
abc123 → User 2 URL
docs → User 5 Page
demo → User 1 URL
```
This guarantees:
* Global uniqueness
* Ownership tracking
* Moderation support
* Slug transfer support
---
# Write Flow
Creating a URL:
```text
1. Validate quota
2. Register slug in system.db
3. Create URL in content.db
4. Update quota counters
5. Write audit event
```
Creating a landing page:
```text
1. Validate quota
2. Register slug in system.db
3. Create page in content.db
4. Update quota counters
5. Write audit event
```
---
# Analytics Flow
Visitor request:
```text
GET /abc123
```
Process:
```text
global_slugs
↓
content.db lookup
↓
redirect
↓
analytics.db visit record
```
Analytics writes never modify content records.
---
# WAL Mode
All databases operate in SQLite WAL mode.
Verify:
```sql
PRAGMA journal_mode;
```
Expected:
```text
wal
```
Benefits:
* Improved concurrency
* Reduced write contention
* Crash recovery
Associated files:
```text
*.db
*.db-shm
*.db-wal
```
---
# WAL Checkpointing
Large WAL files are normal during heavy traffic.
Example:
```text
analytics.db-wal
content.db-wal
```
To manually checkpoint:
```sql
PRAGMA wal_checkpoint(TRUNCATE);
```
The healthcheck and backup jobs may trigger checkpoints automatically.
---
# Backups
Recommended:
```bash
bzod backup
```
This creates a consistent archive of:
```text
admin/
users/
```
Never manually copy live databases while the application is running.
---
# Integrity Verification
Run:
```bash
bzod doctor
```
Or:
```sql
PRAGMA integrity_check;
```
Expected:
```text
ok
```
---
# Migration System
BZOD maintains schema versions using:
```sql
PRAGMA user_version;
```
Startup automatically executes:
```text
Db::init()
```
which:
1. Creates missing databases
2. Applies migrations
3. Validates schemas
4. Repairs legacy installations when required
---
# Design Principles
BZOD database architecture prioritizes:
* SQLite-only deployment
* Multi-user isolation
* Operational simplicity
* Backup friendliness
* Easy disaster recovery
* Minimal dependencies
* Single-binary deployment
---
# Related Documentation
* ARCHITECTURE.md
* MULTI_USER.md
* BACKUP_RESTORE.md
* INSTALL.md
* UPGRADE.md
* SECURITY.md
+545
View File
@@ -0,0 +1,545 @@
# 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.
---
# Analytics Export
Analytics pages support:
* Raw visitor logs
* CSV export
* JSON export
* Date filtering
Exports can be generated from:
Admin → Analytics
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
```
# Upgrading to v0.4.0
1. Backup databases
2. Pull latest source
3. Rebuild container
4. Restart service
```bash
git pull
docker compose build --no-cache
docker compose up -d
---
# 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
```
docker exec -it bzod bzod doctor
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.
+604
View File
@@ -0,0 +1,604 @@
# BZOD Installation Guide
Version: v0.7.0
---
# Introduction
BZOD is a self-hosted multi-user URL management platform written in Rust.
Features include:
* URL shortening
* Landing pages
* QR code generation
* Analytics
* User management
* Audit logging
* Moderation
* Backup & restore
* Disaster recovery
BZOD is distributed as a single executable and uses SQLite databases for storage.
No PostgreSQL, MySQL, Redis, Elasticsearch, or external services are required.
---
# Installation Methods
BZOD supports three deployment methods:
| Method | Recommended For |
| -------------- | ---------------- |
| Docker Compose | Most deployments |
| Native Binary | Linux servers |
| Source Build | Development |
---
# System Requirements
## Minimum
| Component | Requirement |
| --------- | ------------ |
| CPU | 1 Core |
| Memory | 512 MB |
| Storage | 1 GB |
| OS | Linux x86_64 |
## Recommended
| Component | Requirement |
| --------- | ------------------------ |
| CPU | 2+ Cores |
| Memory | 2 GB |
| Storage | 10+ GB SSD |
| OS | Debian 12 / Ubuntu 24.04 |
## Tested Platforms
* Debian 12 Bookworm
* Ubuntu 22.04
* Ubuntu 24.04
* Arch Linux
* Docker
* CasaOS
---
# Installation Using Docker
## Prerequisites
Install:
```bash
docker
docker compose
```
Verify:
```bash
docker --version
docker compose version
```
---
## Create Directory
```bash
mkdir -p /opt/bzod
cd /opt/bzod
```
---
## Copy Files
Required:
```text
docker-compose.yml
Dockerfile
```
Optional:
```text
bzod.service
```
---
## Start Container
```bash
docker compose up -d
```
Verify:
```bash
docker compose ps
```
View logs:
```bash
docker compose logs -f
```
---
## Stop Container
```bash
docker compose down
```
---
## Restart Container
```bash
docker compose restart
```
---
# Native Installation
## Install Dependencies
### Debian / Ubuntu
```bash
sudo apt update
sudo apt install -y \
build-essential \
pkg-config \
libssl-dev \
sqlite3
```
### Arch Linux
```bash
sudo pacman -S \
base-devel \
openssl \
sqlite
```
---
## Download Release Binary
Example:
```bash
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
```
Install:
```bash
sudo install -m755 bzod /usr/local/bin/bzod
```
Verify:
```bash
bzod --help
```
---
# Build From Source
## Install Rust
```bash
curl https://sh.rustup.rs -sSf | sh
```
Verify:
```bash
cargo --version
rustc --version
```
---
## Clone Repository
```bash
git clone https://github.com/thakares/nx9-url-shortener.git
cd nx9-url-shortener
```
---
## Build
Development:
```bash
cargo build
```
Release:
```bash
cargo build --release
```
Binary:
```bash
target/release/bzod
```
---
# Data Directory
BZOD automatically creates its databases on first startup.
Default structure:
```text
data/
├── users.db
├── system.db
│
├── admin/
│ ├── content.db
│ └── analytics.db
│
└── users/
└── ...
```
Do not manually modify database files while BZOD is running.
---
# First Startup
Run:
```bash
bzod serve
```
By default:
```text
http://localhost:8080
```
Open:
```text
http://localhost:8080
```
---
# Bootstrap Administrator
On a fresh installation:
1. Open Login page
2. Use bootstrap credentials
3. Create the first administrator account
4. Save the credentials securely
After bootstrap:
* Bootstrap mode is disabled
* Normal authentication is enforced
---
# Create Administrator Using CLI
Alternative method:
```bash
bzod create-admin
```
Follow prompts:
```text
Username:
Password:
```
The administrator account is stored in:
```text
users.db
```
---
# Reverse Proxy Configuration
Using Nginx is recommended.
Example:
```nginx
server {
server_name bzod.example.com;
location / {
proxy_pass http://127.0.0.1:8080;
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;
}
}
```
Reload:
```bash
sudo nginx -t
sudo systemctl reload nginx
```
---
# HTTPS
Recommended options:
* Let's Encrypt
* Nginx Proxy Manager
* Caddy
* Traefik
Always use HTTPS in production.
---
# Running as Systemd Service
Install binary:
```bash
sudo install -m755 bzod /usr/local/bin/bzod
```
Copy service:
```bash
sudo cp bzod.service /etc/systemd/system/
```
Reload:
```bash
sudo systemctl daemon-reload
```
Enable:
```bash
sudo systemctl enable bzod
```
Start:
```bash
sudo systemctl start bzod
```
Status:
```bash
sudo systemctl status bzod
```
Logs:
```bash
journalctl -u bzod -f
```
---
# Firewall
Open HTTP:
```bash
sudo ufw allow 8080/tcp
```
HTTPS:
```bash
sudo ufw allow 443/tcp
```
HTTP:
```bash
sudo ufw allow 80/tcp
```
---
# Health Verification
Open:
```text
http://localhost:8080
```
Login as administrator.
Verify:
* Dashboard loads
* User list loads
* URL creation works
* Landing pages work
* QR generation works
* Analytics record visits
---
# Upgrade Procedure
Always backup before upgrading.
Create backup:
```bash
bzod backup
```
Stop service:
```bash
sudo systemctl stop bzod
```
Replace binary.
Run migrations:
```bash
bzod migrate
```
Start service:
```bash
sudo systemctl start bzod
```
Verify logs.
See:
```text
docs/UPGRADE.md
```
---
# Troubleshooting
## Port Already In Use
Check:
```bash
ss -tulpn | grep 8080
```
Change port or stop conflicting service.
---
## Database Locked
Verify only one BZOD instance is running:
```bash
ps aux | grep bzod
```
---
## Permission Errors
Verify ownership:
```bash
chown -R bzod:bzod data/
```
---
## Login Problems
Verify:
* Administrator account exists
* Session cookies enabled
* System clock is correct
---
## View Logs
Systemd:
```bash
journalctl -u bzod -f
```
Docker:
```bash
docker compose logs -f
```
---
# Next Steps
After installation:
1. Read `MULTI_USER.md`
2. Read `ADMIN_GUIDE.md`
3. Configure backups
4. Configure HTTPS
5. Create additional users
6. Verify restore procedures
---
# Additional Documentation
| File | Purpose |
| ----------------- | ------------------------ |
| ARCHITECTURE.md | System architecture |
| MULTI_USER.md | Multi-user design |
| ADMIN_GUIDE.md | Administrative workflows |
| BACKUP_RESTORE.md | Backup procedures |
| SECURITY.md | Security model |
| CLI.md | Command reference |
| API.md | REST API reference |
| UPGRADE.md | Upgrade instructions |
---
End of Document.
+732
View File
@@ -0,0 +1,732 @@
# BZOD Multi-User Architecture Guide
Version: v0.7.0
---
# Introduction
BZOD v0.5.0 introduces a complete multi-user architecture that transforms BZOD from a single-tenant URL shortener into a secure, isolated, self-hosted multi-user platform.
Each user receives logically isolated content and analytics storage while sharing a common authentication, administration, moderation, and routing infrastructure.
This document explains the architecture, database layout, ownership model, security boundaries, quotas, slug management, and administrative workflows.
---
# Design Goals
The multi-user architecture was designed around the following principles:
* Strong tenant isolation
* Single binary deployment
* SQLite-only operation
* Minimal operational complexity
* No external services required
* Global slug namespace
* Centralized administration
* Disaster recovery support
* Simple backup and restore workflows
---
# User Types
BZOD supports the following account types.
## Administrator
Administrators can:
* Access the administrative dashboard
* Create users
* Delete users
* Reset passwords
* Manage quotas
* Transfer ownership
* Moderate content
* Manage backups
* Access health dashboards
* Access audit logs
Administrators cannot bypass database isolation.
---
## Standard User
Standard users can:
* Create short URLs
* Create landing pages
* View analytics
* Generate QR codes
* Manage API tokens
* Update passwords
Standard users cannot:
* Access other user content
* Access administrative functions
* Access system settings
---
## System Accounts
System accounts are reserved for internal operations.
They cannot authenticate into the dashboard.
---
# Database Architecture
BZOD uses multiple SQLite databases.
## users.db
Central identity store.
Contains:
```text
users
sessions
quotas
api_tokens
```
Responsibilities:
* Authentication
* Session management
* Password verification
* User status management
* Quota tracking
---
## system.db
Global platform database.
Contains:
```text
global_slugs
slug_history
moderation_events
audit_events
settings
reserved_slugs
```
Responsibilities:
* Slug ownership
* Moderation
* Audit logging
* Global settings
* System metadata
---
## Tenant Databases
Every tenant owns independent databases.
Example:
```text
users/
└── 15/
├── content.db
└── analytics.db
```
Responsibilities:
### content.db
Stores:
```text
urls
pages
qr_metadata
previews
```
### analytics.db
Stores:
```text
visits
aggregates
referrers
browsers
countries
```
---
# Directory Structure
Example installation:
```text
data/
├── users.db
├── system.db
│
├── admin/
│ ├── content.db
│ └── analytics.db
│
└── users/
├── 2/
│ ├── content.db
│ └── analytics.db
│
├── 3/
│ ├── content.db
│ └── analytics.db
│
└── 4/
├── content.db
└── analytics.db
```
---
# Global Slug Namespace
BZOD uses a platform-wide namespace.
A slug can only exist once.
Examples:
```text
/company
/about
/docs
```
If User A owns:
```text
/company
```
User B cannot create:
```text
/company
```
The operation is rejected.
---
# Slug Registration Flow
When a URL or page is created:
1. Validate quota.
2. Validate slug.
3. Register slug in system.db.
4. Create record in tenant content.db.
5. Increment quota counters.
6. Write audit log.
If any step fails:
* Changes are rolled back.
* Partial records are removed.
---
# Global Slug Table
Conceptually:
```text
global_slugs
```
Contains:
```text
slug
owner_user_id
target_type
target_id
status
created_at
```
Example:
| slug | owner | type |
| ---- | ----- | ---- |
| docs | 3 | page |
| api | 8 | page |
| home | 2 | url |
---
# Slug Ownership Transfer
Administrators may transfer ownership.
Process:
1. Validate destination quotas.
2. Copy content.
3. Move ownership.
4. Update global slug registry.
5. Record history.
6. Write audit event.
Analytics remain preserved.
URLs remain functional.
---
# Tenant Isolation
Each user owns independent databases.
Example:
```text
User A
└── users/2/
User B
└── users/3/
```
User A never accesses:
```text
users/3/content.db
users/3/analytics.db
```
User B never accesses:
```text
users/2/content.db
users/2/analytics.db
```
All access is enforced by application logic.
---
# Authentication Architecture
Authentication is centralized.
Stored in:
```text
users.db
```
Tables:
```text
users
sessions
```
All dashboard sessions use:
```text
bzod_session
```
Sessions are validated against:
```text
users.db.sessions
```
---
# Session Lifecycle
Login:
```text
User Login
↓
Create Session
↓
Store in users.db
↓
Set bzod_session cookie
```
Logout:
```text
Delete session row
↓
Expire cookie
```
Disabled users immediately lose access.
---
# Quota System
Every user has quotas.
Examples:
```text
max_urls
max_pages
max_storage_mb
max_api_tokens
```
Current utilization is tracked separately.
Administrators may:
* Increase limits
* Reduce limits
* Trigger reconciliation
---
# Quota Reconciliation
Background job:
```text
quota_reconcile
```
Purpose:
* Detect drift
* Recount resources
* Repair counters
Example:
```text
Stored URLs = 50
Actual URLs = 47
```
Counter automatically corrected.
---
# Analytics Isolation
Each tenant stores analytics independently.
Example:
```text
users/10/analytics.db
```
Contains only User 10 traffic.
Administrators can:
* View aggregated analytics
* Access user analytics
Users cannot view analytics from other tenants.
---
# QR Code System
QR codes are generated dynamically.
Endpoints:
```text
/api/qr/{slug}.png
/api/qr/{slug}.svg
```
Slug ownership is resolved through:
```text
system.db.global_slugs
```
No content database scan is required.
---
# Moderation Architecture
Administrators can:
* Flag content
* Disable content
* Delete content
* Transfer ownership
Disabled content returns:
```http
410 Gone
```
For:
```text
/slug
/p/slug
/api/qr/slug.png
/api/qr/slug.svg
```
---
# Audit Logging
All administrative actions are recorded.
Examples:
```text
login
logout
user_create
user_delete
password_reset
quota_update
slug_transfer
backup_create
restore_execute
```
Stored in:
```text
system.db
```
---
# Backup Architecture
Supported levels:
## Full Platform Backup
Includes:
```text
users.db
system.db
all tenant databases
```
---
## User Backup
Includes:
```text
content.db
analytics.db
```
For a specific user.
---
# Disaster Recovery
Supported operations:
```bash
bzod backup
bzod restore
bzod backup-user
bzod restore-user
```
Recovery preserves:
* URLs
* Pages
* Analytics
* Users
* Slugs
* Settings
---
# Upgrade Path
BZOD automatically migrates:
```text
v0.4.x
```
to
```text
v0.5.x
```
Migration process:
1. Create users.db.
2. Create system.db.
3. Create admin tenant.
4. Migrate content.
5. Migrate analytics.
6. Populate global_slugs.
7. Create legacy_admin.
8. Validate integrity.
No manual database migration is normally required.
---
# Security Model
Security boundaries:
## Authentication
Centralized.
```text
users.db
```
---
## Authorization
Role-based.
```text
admin
standard
system
```
---
## CSRF Protection
All forms protected.
Invalid tokens:
```http
403 Forbidden
```
---
## Session Security
* Secure session IDs
* Session invalidation
* Expiration support
* Replay protection
---
## Tenant Isolation
Per-user databases.
No shared content tables.
---
# Operational Recommendations
Recommended deployment:
```text
Nginx
↓
BZOD
↓
SQLite WAL
```
Enable:
* HTTPS
* Daily backups
* Log rotation
* Health monitoring
---
# Limitations
Current v0.5.0 limitations:
* SQLite backend only
* Single server deployment
* No clustering
* No federation
* No organization account hierarchy
These may be addressed in future releases.
---
# Future Expansion
Potential v0.6.x features:
* Organization accounts
* Service accounts
* SSO integration
* Multi-node replication
* Advanced analytics dashboards
* Scheduled tasks UI
---
# Summary
BZOD v0.5.0 provides:
* Centralized authentication
* Multi-user isolation
* Global slug namespace
* Per-user analytics
* Administrative moderation
* Quotas
* Audit logging
* Backup & disaster recovery
* Single-binary deployment
while remaining lightweight, SQLite-native, and operationally simple.
---
End of Document.
+465
View File
@@ -0,0 +1,465 @@
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
Release Date: 2026-08-11
## Highlights
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
## User-Visible Changes
- Admin URL registry panel is responsive across all device sizes
- Admin landing page registry panel is responsive across all device sizes
- User URL registry panel is responsive across all device sizes
- User landing page registry panel is responsive across all device sizes
- Dark/light theme toggle available in the UI
- Theme preference persists across sessions
- Registry tables switch to card layouts on tablet and mobile viewports
- Horizontal scrolling eliminated from registry panels
## Technical Changes
- Introduced `build.rs` build script for compile-time metadata extraction
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
- Version and Git commit now available to system status and admin settings endpoints
- Root `/` serves `www/index.html` with runtime/embedded fallback
## Breaking Changes
None.
## Upgrade Notes
- Direct upgrade from v0.6.0 with no migration required
- No database schema changes
- No API changes
- No configuration changes
- No breaking changes to existing functionality
---
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
Release Date: 2026-08-09
## Highlights
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
## Breaking Changes
None.
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
---
# Highlights
## Modular Admin Architecture
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
Feature modules:
* `auth.rs` — authentication and session handling
* `dashboard.rs` — dashboard rendering
* `urls.rs` — URL management handlers
* `pages.rs` — landing page management handlers
* `analytics.rs` — analytics and export handlers
* `settings.rs` — settings and configuration handlers
* `users.rs` — user management handlers
* `sessions.rs` — session administration
* `quotas.rs` — quota management
* `health.rs` — health diagnostics
* `backups.rs` — backup and restore handlers
* `api_keys.rs` — API key management
* `audit.rs` — audit log handlers
* `moderation.rs` — content moderation handlers
Benefits:
* Improved code organization and navigability
* Reduced coupling between feature areas
* Improved database lock scoping
* Reduced duplicated handler logic
* Better error handling consistency and observability
* Simplified future extension
---
## Redirect Handler Hardening
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
Changes:
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
* Added destination URL validation (scheme enforcement, control character rejection)
* Added safe Location header construction that handles malformed values gracefully
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions
* Removed synchronous expiration writes from the redirect hot path
Existing redirect security and tenant isolation behavior was preserved.
---
## Root Landing Page Verification
* Confirmed `GET /` as an intentional application route serving `www/index.html`
* Resolved a runtime path-resolution issue affecting static landing-page resolution
* Verified `GET /` returns HTTP 200
* Verified `GET /login` returns HTTP 200
* Verified `GET /admin/login` returns HTTP 200
---
# Testing & Validation
BZOD v0.5.3 passed:
* Release build (`cargo build --release`)
* Comprehensive automated test suite, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
* Runtime smoke tests against the release binary
* SQLite WAL mode and foreign-key enforcement initialization
* Database migration verification (all migrations up to date)
---
# Compatibility
* No breaking changes
* No API changes
* No route changes
* No database schema changes
* No configuration changes
* Direct upgrade from v0.5.1 with no migration required
---
# Repository
* Clean source tree established
* Build artifacts, temporary reports, and IDE metadata removed
* Existing BZOD Git history preserved
* Refactoring baseline merged with existing history
---
---
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
**Release Date:** 2026-06-20
BZOD v0.5.1 focuses on platform integrity, multi-tenant safety, dashboard parity, QR reliability, and upgrade validation.
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foundations required for safe operation at scale.
---
# Highlights
## Runtime Efficiency (v0.5.1)
| Metric | Value |
|---------------------|------------|
| Binary Size | 11 MB |
| RSS Memory | 11.8 MB |
| Peak RSS | 11.8 MB |
| CPU Idle | 0.02% |
| Swap Usage | 0 KB |
| PIDs | 7 |
**On a typical 32 GB server:**
- Memory usage: ~0.04%
- No swapping
- Plenty of headroom
BZOD runs closer to a lightweight infrastructure service than a typical web application.
## Global Slug Registry
Introduced a hardened global slug registry to guarantee namespace integrity across the entire platform.
The following resources can no longer share the same slug:
* Administrator URLs
* Administrator Landing Pages
* User URLs
* User Landing Pages
Duplicate namespace conflicts are automatically detected and blocked.
---
## Namespace Integrity Validation
New validation routines now verify:
* Duplicate slug detection
* Missing ownership records
* Invalid registry entries
* Invalid target types
* Orphaned slug references
Namespace conflicts now abort upgrades and restores before corruption can occur.
---
## Reservation-Based Slug Allocation
BZOD now reserves slugs before content creation.
Creation workflow:
```text
Quota Check
↓
Reserve Global Slug
↓
Create Content
↓
Activate Slug
↓
Increment Quota
↓
Audit Log
```
Benefits:
* Prevents race conditions
* Prevents duplicate creation under concurrency
* Enables safer rollback handling
---
## Stale Reservation Recovery
Added automatic cleanup of abandoned slug reservations.
Scenarios covered:
* Server crash during creation
* Interrupted writes
* Failed transactions
BZOD now automatically recovers stale reservations during startup.
---
## Dashboard Parity
Administrator and Standard User dashboards now provide equivalent functionality where appropriate.
Added parity validation for:
* URL management
* Landing page management
* Analytics
* QR code previews
* Export functionality
Differences remain only for administrator-specific operations.
---
## Unified Analytics Templates
Removed duplicated analytics templates.
Benefits:
* Consistent rendering
* Reduced maintenance burden
* Improved reliability
Administrator and user analytics now share the same rendering logic.
---
## QR Code Improvements
QR functionality was substantially improved.
### Added
* Inline QR previews
* PNG downloads
* SVG downloads
* Shared QR rendering component
### Fixed
* Landing page QR generation
* Multi-user QR ownership handling
* QR routing consistency
* Content-type validation
---
## Canonical Landing Page Routing
Landing page slugs now redirect permanently to canonical page URLs.
Example:
```text
/landing-page
```
redirects to:
```text
/p/landing-page
```
using:
```http
301 Moved Permanently
```
This improves consistency and SEO behavior.
---
## Ownership Isolation Hardening
Additional protections ensure:
* Users cannot access another user's analytics
* Users cannot export another user's data
* Users cannot manage another user's resources
New ownership validation tests were added.
---
## Backup & Restore Improvements
Restore operations now validate namespace integrity before importing data.
Benefits:
* No silent slug collisions
* No partial restores
* No hidden ownership conflicts
Restore operations fail safely when conflicts are detected.
---
## Upgrade Validation Enhancements
Upgrade workflows now verify:
* Global namespace consistency
* Duplicate slug conflicts
* Registry integrity
* Tenant ownership correctness
Unsafe upgrades are blocked automatically.
---
## Health & Diagnostics
The system health subsystem now validates:
* Global slug registry integrity
* Namespace conflicts
* Ownership consistency
* Stale reservations
This improves operational visibility and troubleshooting.
---
# Testing & Validation
BZOD v0.5.1 passed:
* Formatting validation (`cargo fmt --check`)
* Static analysis (`cargo clippy --all-targets -- -D warnings`)
* Full automated test suite
* Namespace integrity tests
* Ownership isolation tests
* QR endpoint tests
* Dashboard parity tests
* Upgrade validation tests
* Backup & restore tests
* Disaster recovery tests
* Security tests
* Concurrency tests
All automated tests pass successfully.
---
# Upgrade Notes
Administrators upgrading from v0.5.0 should review:
* UPGRADE.md
* MULTI_USER.md
* BACKUP_RESTORE.md
* DATABASES.md
* TESTING.md
BZOD will automatically validate namespace integrity before completing upgrades.
Duplicate slugs that previously existed across users or resource types must be resolved before migration can proceed.
---
# Breaking Changes
## Global Namespace Enforcement
Slugs are now globally unique across the entire platform.
Configurations that previously relied on duplicate slugs across users or resource types will be rejected during upgrade.
This behavior is intentional and protects routing integrity.
---
# Summary
BZOD v0.5.1 is an integrity-focused release that significantly strengthens:
* Namespace safety
* Multi-tenant isolation
* Dashboard consistency
* QR reliability
* Restore safety
* Upgrade safety
* Operational diagnostics
The result is a more predictable, recoverable, and production-ready platform.
+685
View File
@@ -0,0 +1,685 @@
# BZOD Security Guide
Version: v0.7.0
---
# Security Overview
BZOD is designed as a self-hosted URL shortener and landing page platform with a strong emphasis on:
* Multi-user isolation
* Secure authentication
* Role-based access control
* Auditability
* Data ownership
* Disaster recovery
* Operational simplicity
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
---
# Security Principles
BZOD follows several core principles:
1. Least Privilege
2. Tenant Isolation
3. Defense in Depth
4. Auditability
5. Secure Defaults
6. Explicit Ownership
7. Fail Secure
---
# Threat Model
BZOD is designed to protect against:
* Unauthorized dashboard access
* Credential theft
* Session hijacking
* Cross-user data access
* Slug takeover attempts
* Privilege escalation
* CSRF attacks
* XSS injection attempts
* Unauthorized API access
* Malicious content modification
* Accidental administrative mistakes
BZOD is not intended to defend against:
* Physical server compromise
* Root-level operating system compromise
* Malware running as the BZOD service user
* Full database theft by a privileged host administrator
---
# Authentication
Authentication is centralized in:
```text
users.db
```
Tables:
```text
users
sessions
api_tokens
```
All users authenticate through the same identity system.
---
# Password Security
Passwords are never stored in plaintext.
Stored values:
```text
password_hash
```
Passwords are hashed before storage.
Administrative password resets generate entirely new hashes.
Existing passwords cannot be recovered.
---
# Session Security
All dashboard authentication uses:
```text
bzod_session
```
cookie.
Sessions are stored in:
```text
users.db.sessions
```
Each session contains:
```text
session_id
user_id
created_at
expires_at
```
---
## Session Validation
Each authenticated request verifies:
1. Session exists
2. Session has not expired
3. User exists
4. User status is active
5. User has required permissions
Failure at any step immediately invalidates access.
---
## Session Revocation
Sessions are revoked when:
* User logs out
* User is disabled
* User is deleted
* Password is reset
* Administrator revokes sessions
---
## Session Fixation Protection
BZOD generates new session identifiers after successful authentication.
Previously issued identifiers are not reused.
---
# Authorization Model
BZOD implements Role-Based Access Control (RBAC).
Supported roles:
```text
admin
standard
system
```
---
## Administrator
Administrators can:
* Manage users
* Reset passwords
* Transfer ownership
* Manage quotas
* Access audit logs
* Review analytics
* Create backups
* Restore backups
* Moderate content
Administrators cannot bypass audit logging.
---
## Standard User
Standard users can:
* Manage owned URLs
* Manage owned landing pages
* View owned analytics
* Generate API tokens
* Manage owned content
Standard users cannot:
* Access other users' content
* Access administrative endpoints
* Access system settings
---
## System Accounts
System accounts are internal accounts.
They cannot authenticate into:
* Dashboard
* REST API
---
# Multi-User Isolation
Multi-user isolation is one of the primary security features of BZOD.
Each tenant receives independent databases.
Example:
```text
users/
├── 2/
│ ├── content.db
│ └── analytics.db
│
├── 3/
│ ├── content.db
│ └── analytics.db
```
User 2 never accesses:
```text
users/3/content.db
users/3/analytics.db
```
User 3 never accesses:
```text
users/2/content.db
users/2/analytics.db
```
---
# Global Slug Security
All public slugs are stored in:
```text
system.db.global_slugs
```
Each slug is globally unique.
Example:
```text
/company
```
may belong to only one owner.
Duplicate registrations are rejected.
---
## Slug Ownership
Every slug contains:
```text
owner_user_id
target_id
target_type
status
```
Ownership must match before modification is permitted.
---
## Slug Transfer Protection
Only administrators may transfer ownership.
Transfer operations:
1. Validate destination quotas
2. Validate destination user
3. Copy content
4. Update ownership
5. Record history
6. Write audit event
---
# API Security
REST API authentication uses API tokens.
Tokens are stored as hashes.
Plaintext tokens are shown only once during creation.
---
## API Token Security
Stored values:
```text
token_hash
```
Never:
```text
plaintext_token
```
If a token is lost:
1. Revoke it
2. Generate a new token
---
## API Permissions
Admin tokens:
```text
Full administrative access
```
Standard user tokens:
```text
Owned resources only
```
System accounts:
```text
API access denied
```
---
# CSRF Protection
All dashboard forms require valid CSRF tokens.
Protected actions include:
* Login
* User creation
* Password reset
* Content modification
* Moderation actions
* Quota updates
* Backup operations
---
## Invalid CSRF Requests
Invalid requests return:
```http
403 Forbidden
```
and are rejected before processing.
---
# XSS Protection
User-supplied content is validated before rendering.
Templates use:
```text
Askama
```
which escapes output by default.
Recommended:
* Do not allow arbitrary JavaScript
* Validate HTML content
* Restrict trusted editors
---
# Content Moderation
Administrators may:
* Flag content
* Disable content
* Delete content
Disabled content returns:
```http
410 Gone
```
for:
```text
/{slug}
/p/{slug}
/api/qr/{slug}.png
/api/qr/{slug}.svg
```
---
# Redirect Security
The redirect handler validates destination URLs before constructing HTTP Location headers.
Protections include:
* URL scheme validation (only http and https destinations are permitted)
* Control character rejection
* CRLF injection prevention
* Safe Location header construction (no panics on malformed values)
Invalid redirect destinations return:
```http
500 Internal Server Error
```
with structured server-side logging. Full destination values are not exposed to clients.
---
# Audit Logging
Security-sensitive actions are logged.
Examples:
```text
login
logout
failed_login
user_created
user_deleted
password_reset
slug_transfer
quota_update
backup_created
restore_executed
```
Stored in:
```text
system.db
```
Audit logs should be reviewed regularly.
---
# Backup Security
Backups may contain:
* User records
* Session records
* URLs
* Pages
* Analytics
* API token hashes
Backups should be treated as sensitive data.
---
## Recommendations
Store backups:
* Offsite
* Encrypted
* Access-controlled
Never expose backup archives publicly.
---
# Database Security
SQLite databases should be accessible only to the BZOD service account.
Recommended permissions:
```bash
chmod 700 data
chmod 600 *.db
```
---
# HTTPS Requirements
Production deployments should always use HTTPS.
Recommended reverse proxies:
* Nginx
* Caddy
* Traefik
Never expose login pages over plaintext HTTP.
---
# Security Headers
Recommended reverse proxy headers:
```http
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Content-Security-Policy: default-src 'self'
```
---
# Password Policy Recommendations
Recommended minimum:
```text
12 characters
```
Encourage:
* Password managers
* Unique passwords
* Randomly generated credentials
Avoid:
* Reused passwords
* Dictionary words
* Predictable patterns
---
# Brute Force Protection
Recommended deployment protections:
* Reverse proxy rate limiting
* Fail2Ban
* Firewall rules
Example:
```text
5 login attempts
within 5 minutes
```
before temporary blocking.
---
# Administrative Security Checklist
Before production deployment:
* Enable HTTPS
* Configure backups
* Review file permissions
* Remove default credentials
* Verify audit logging
* Test restore procedures
* Review active sessions
---
# Incident Response
If compromise is suspected:
1. Disable affected accounts.
2. Revoke active sessions.
3. Revoke API tokens.
4. Create forensic backup.
5. Review audit logs.
6. Restore from trusted backups if necessary.
7. Rotate credentials.
---
# Security Testing
BZOD v0.7.0 includes tests covering:
* Authentication
* Authorization
* Session validation
* CSRF enforcement
* Slug ownership
* User isolation
* Upgrade migrations
* Backup integrity
* Disaster recovery
* Redirect destination validation
* HTTP Location header safety
These tests are executed during CI and release validation.
---
# Responsible Disclosure
If a security vulnerability is discovered:
1. Do not publish exploit details immediately.
2. Report the issue privately.
3. Allow time for remediation.
4. Coordinate disclosure after a fix is available.
---
# Known Limitations
Current limitations include:
* No MFA support
* No SSO integration
* No hardware security key support
* No built-in rate limiter
* No WebAuthn support
These may be addressed in future releases.
---
# Summary
BZOD v0.7.0 provides:
* Centralized authentication
* Secure session management
* RBAC authorization
* Multi-user isolation
* Global slug ownership controls
* CSRF protection
* API token hashing
* Audit logging
* Backup security
* Operational security guidance
while maintaining a lightweight, SQLite-native, self-hosted architecture.
---
End of Document.
+516
View File
@@ -0,0 +1,516 @@
# BZOD Testing & Validation Guide
## Overview
BZOD follows a defense-in-depth validation strategy.
A release is considered valid only when:
* Code quality checks pass
* Automated tests pass
* Upgrade validation passes
* Backup/restore validation passes
* Namespace integrity validation passes
* Multi-user isolation validation passes
* Disaster recovery validation passes
The objective is not simply to ensure the application starts, but to ensure that it can be safely upgraded, operated, backed up, restored, and recovered.
---
# Validation Philosophy
BZOD prioritizes:
1. Namespace Integrity
2. Data Integrity
3. Multi-Tenant Isolation
4. Operational Simplicity
5. Recovery Capability
6. Security
7. Functional Correctness
A successful release is not merely one that runs.
A successful release is one that can be recovered.
---
# Automated Test Coverage
Current validation suite includes:
* Unit Tests
* Integration Tests
* HTTP E2E Tests
* Business Workflow Tests
* Security Tests
* Backup & Restore Tests
* Disaster Recovery Tests
* Migration Tests
* Upgrade Validation Tests
* Namespace Integrity Tests
* Ownership Isolation Tests
* Dashboard Parity Tests
* QR Endpoint Tests
* Concurrency Tests
* WAL Recovery Tests
The platform currently executes approximately 100+ automated tests.
---
# 1. Build Validation
Verify successful compilation.
```bash
cargo check
cargo build
cargo build --release
```
Expected:
* No compilation failures
* Release binary generated
---
# 2. Formatting Validation
```bash
cargo fmt --check
```
Expected:
* No formatting errors
---
# 3. Static Analysis
```bash
cargo clippy --all-targets -- -D warnings
```
Expected:
* Zero warnings
* Zero errors
---
# 4. Complete Automated Test Suite
```bash
cargo test --all-targets -- --nocapture
```
Expected:
* All tests pass
* No failures
* No ignored critical tests
---
# 5. Database Initialization Validation
Create clean environment:
```bash
rm -rf data
```
Run:
```bash
bzod stats
```
Expected:
* Database hierarchy created
* Migrations applied
* System healthy
Validate:
```bash
bzod doctor
```
Expected:
```text
Overall status: HEALTHY
```
---
# 6. Namespace Integrity Validation
BZOD maintains a global slug namespace.
The following must never coexist:
```text
Admin URL
hello
User URL
hello
Landing Page
hello
```
Validate:
```bash
bzod doctor
```
Expected:
```text
No namespace conflicts detected
```
Duplicate slugs must abort upgrade and restore operations.
---
# 7. Multi-User Isolation Validation
Verify:
* User A cannot access User B URLs
* User A cannot access User B Pages
* User A cannot access User B Analytics
* User A cannot export User B analytics
Expected:
```http
403 Forbidden
```
for all unauthorized access.
---
# 8. Dashboard Parity Validation
Verify:
## Administrator URLs
Contains:
* Analytics
* QR Preview
* PNG Download
* SVG Download
## User URLs
Contains identical functionality.
Differences allowed:
* User Management
* Moderation
* Backups
* Health
* Audit
* Quotas
Everything else must match.
---
# 9. Analytics Validation
Verify:
* URL Analytics
* Landing Page Analytics
* CSV Export
* JSON Export
* Date Filters
* Charts
* Referrer Breakdown
* Country Breakdown
* Browser Breakdown
* Device Breakdown
Expected:
Administrator and owner views return identical analytics.
---
# 10. QR Validation
Verify:
```text
/api/qr/<slug>.png
/api/qr/<slug>.svg
```
Expected:
```http
200 OK
```
Verify:
```text
Content-Type: image/png
Content-Type: image/svg+xml
```
Disabled resources:
```http
410 Gone
```
Missing resources:
```http
404 Not Found
```
---
# 11. Routing Validation
URL resources:
```text
/<slug>
```
must redirect correctly.
Landing Pages:
```text
/<slug>
```
must redirect permanently to:
```text
/p/<slug>
```
Expected:
```http
301 Moved Permanently
```
and:
```http
200 OK
```
for final landing page render.
Root landing page:
```text
GET /
```
must serve the static landing page.
Expected:
```http
200 OK
Content-Type: text/html
```
Redirect security:
Redirect destinations are validated against:
* Invalid URL schemes
* CRLF injection attempts
* Control character injection
* Malformed HTTP Location header values
Invalid destinations must return:
```http
500 Internal Server Error
```
and must not panic or produce malformed HTTP responses.
---
# 12. Backup Validation
Create backup:
```bash
bzod backup
```
Expected:
Archive generated successfully.
Validate archive contents.
---
# 13. Restore Validation
Restore backup:
```bash
bzod restore --file backup.tar.gz
```
Expected:
* Restore succeeds
* All data preserved
* Namespace integrity preserved
---
# 14. Collision Protection Validation
Attempt restore containing duplicate slugs.
Expected:
```text
Restore aborted
Slug conflict detected
```
No partial restore.
---
# 15. Upgrade Validation
Verify upgrade from legacy deployments.
Expected:
* User databases migrated
* Analytics preserved
* Links preserved
* Landing pages preserved
* Authentication preserved
Duplicate slugs must abort upgrade.
---
# 16. Disaster Recovery Validation
Procedure:
1. Backup system
2. Stop service
3. Remove data directory
4. Restore backup
5. Start service
Expected:
* Full recovery
* No manual repair
* All URLs functional
* All Landing Pages functional
* Analytics preserved
---
# 17. Docker Validation
```bash
docker compose build --no-cache
docker compose up -d
```
Verify:
```bash
docker compose logs
```
Expected:
```text
Server started successfully
```
Container health:
```text
healthy
```
---
# 18. WAL Recovery Validation
Verify:
* SQLite WAL mode enabled
* Recovery after backup succeeds
* No corruption detected
---
# Release Validation Checklist
Before every release:
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets -- --nocapture
cargo build --release
cargo audit
```
Release is approved only if all steps succeed.
---
# Release Blockers
The following are release blockers:
* Namespace conflicts
* Backup failure
* Restore failure
* Upgrade failure
* Multi-user isolation failure
* Ownership validation failure
* Security test failure
* Data corruption
* Disaster recovery failure
A release that cannot be restored is not considered production ready.
+795
View File
@@ -0,0 +1,795 @@
# Upgrade Guide
Version: v0.7.0
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
---
# Overview
BZOD v0.5.1 is a platform hardening release focused on:
* Global namespace integrity
* Multi-tenant safety
* Dashboard parity
* QR reliability
* Upgrade validation
* Restore collision protection
* Ownership isolation
While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments.
---
# Supported Upgrade Paths
Supported:
```text
v0.5.0 → v0.5.1
v0.4.x → v0.5.1
```
Recommended:
```text
v0.4.x → v0.5.0 → v0.5.1
```
Unsupported:
```text
v0.3.x → v0.5.1
```
Older installations should first upgrade to v0.4.x.
---
# Major Changes in v0.5.1
## Global Namespace Enforcement
BZOD now enforces a single platform-wide slug namespace.
The following resources can no longer share the same slug:
* Administrator URLs
* Administrator Landing Pages
* User URLs
* User Landing Pages
Example:
```text
Admin URL:
hello
User URL:
hello
```
Result:
```text
Upgrade aborted.
Namespace conflict detected.
```
---
## Global Slug Registry
BZOD now treats the slug registry as the authoritative source of truth.
All slugs are registered in:
```text
system.db
```
Table:
```text
global_slugs
```
The registry tracks:
```text
slug
owner_user_id
target_type
target_id
status
```
---
## Reservation-Based Slug Allocation
Slug creation now follows:
```text
Quota Validation
↓
Reserve Global Slug
↓
Create Resource
↓
Activate Slug
↓
Update Quotas
↓
Audit Log
```
Benefits:
* Prevents race conditions
* Prevents duplicate allocations
* Improves rollback safety
* Improves multi-user integrity
---
## Stale Reservation Recovery
BZOD automatically cleans abandoned reservations created by:
* Server crashes
* Interrupted requests
* Failed transactions
Stale reservations are validated and cleaned during startup.
---
# Breaking Changes
## Global Slug Uniqueness
Deployments containing duplicate slugs will not upgrade.
Example:
```text
User 1:
!nx9-dns-server
User 3:
!nx9-dns-server
```
Result:
```text
Upgrade aborted.
Database upgrade aborted due to slug conflicts.
```
Conflicts must be resolved before migration can continue.
---
## Restore Collision Protection
Restore operations now validate namespace integrity.
Example:
```text
Existing slug:
company
Backup slug:
company
```
Result:
```text
Restore aborted.
Slug conflict detected.
```
No partial restore occurs.
---
# Pre-Upgrade Checklist
Before upgrading:
* Create backup
* Verify backup integrity
* Stop active traffic
* Run diagnostics
* Resolve namespace conflicts
---
# Step 1: Create Backup
Full backup:
```bash
bzod backup
```
Manual backup:
```bash
tar czf bzod-backup.tar.gz data/
```
---
# Step 2: Verify Backup
Verify archive contents:
```text
users.db
system.db
users/
```
If upgrading from legacy versions:
```text
admin.db
content.db
analytics.db
```
should also be present.
---
# Step 3: Run Diagnostics
Execute:
```bash
bzod doctor
```
Expected:
```text
Overall Status: HEALTHY
```
Verify:
```text
No namespace conflicts detected
No ownership violations detected
No registry corruption detected
```
---
# Step 4: Stop Service
Systemd:
```bash
sudo systemctl stop bzod
```
Docker:
```bash
docker compose down
```
---
# Upgrade Procedure
## Install New Version
Build:
```bash
cargo build --release
```
Or install official release binary.
---
## Start BZOD
```bash
bzod serve
```
or:
```bash
docker compose up -d
```
---
# Automatic Upgrade Actions
During startup BZOD automatically performs:
1. Database migration checks
2. Namespace integrity validation
3. Registry validation
4. Stale reservation cleanup
5. Global slug verification
6. Schema migration execution
---
# Namespace Validation
BZOD scans:
```text
legacy databases
administrator databases
tenant databases
```
for duplicate slugs.
Example:
```text
Owner 1:
hello
Owner 3:
hello
```
Result:
```text
Namespace conflict detected.
Upgrade aborted.
```
---
# Registry Validation
BZOD validates:
* Duplicate slug entries
* Missing owners
* Missing targets
* Invalid target types
* Invalid status values
Allowed target types:
```text
url
page
```
Allowed statuses:
```text
reserving
active
disabled
```
---
# Post-Upgrade Validation
Run:
```bash
bzod doctor
```
Expected:
```text
Namespace Integrity: PASS
Registry Integrity: PASS
Ownership Integrity: PASS
Database Integrity: PASS
```
---
# Login Validation
Verify:
```text
Administrator login succeeds
User login succeeds
```
---
# URL Validation
Verify:
```text
https://example.com/abc123
```
redirects correctly.
Expected:
```http
302 Found
```
or configured redirect behavior.
---
# Landing Page Validation
Verify:
```text
https://example.com/p/demo
```
renders successfully.
Verify:
```text
https://example.com/demo
```
redirects permanently:
```http
301 Moved Permanently
```
to:
```text
/p/demo
```
---
# QR Validation
Verify:
```text
/api/qr/demo.png
/api/qr/demo.svg
```
Expected:
```http
200 OK
```
Content types:
```text
image/png
image/svg+xml
```
Disabled resources:
```http
410 Gone
```
Missing resources:
```http
404 Not Found
```
---
# Dashboard Validation
Verify Administrator Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Verify Standard User Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Both should provide equivalent functionality except for administrator-only operations.
---
# Ownership Isolation Validation
Verify:
```text
User A
```
cannot access:
```text
User B Analytics
User B URLs
User B Landing Pages
User B Exports
```
Expected:
```http
403 Forbidden
```
---
# Backup & Restore Validation
Create backup:
```bash
bzod backup
```
Restore backup:
```bash
bzod restore backup.tar.gz
```
Expected:
* No namespace conflicts
* No ownership conflicts
* No partial restores
---
# Rollback Procedure
If upgrade validation fails:
Stop service:
```bash
sudo systemctl stop bzod
```
or:
```bash
docker compose down
```
Restore backup:
```bash
bzod restore backup.tar.gz
```
or restore archived data directory.
Reinstall previous release.
---
# Docker Upgrade
Pull image:
```bash
docker compose pull
```
Restart:
```bash
docker compose up -d
```
Monitor:
```bash
docker compose logs -f
```
Expected:
```text
Namespace validation passed
Registry validation passed
Server started successfully
```
---
# Systemd Upgrade
Replace binary:
```bash
sudo cp bzod /usr/local/bin/
```
Restart:
```bash
sudo systemctl restart bzod
```
Verify:
```bash
sudo systemctl status bzod
```
Expected:
```text
active (running)
```
---
# Automated Upgrade Validation
Execute:
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets -- --nocapture
```
Particularly validate:
```text
upgrade_validation_tests
backup_restore_tests
slug_registry_tests
ownership_tests
analytics_parity_tests
transaction_tests
```
---
# Recommended Upgrade Workflow
```text
1. Create Backup
2. Verify Backup
3. Run bzod doctor
4. Resolve Namespace Conflicts
5. Stop Service
6. Install v0.5.1
7. Start Service
8. Validate Registry
9. Validate URLs
10. Validate Landing Pages
11. Validate QR Endpoints
12. Validate Dashboards
13. Validate Ownership Isolation
14. Return To Production
```
---
# Troubleshooting
## Upgrade Aborted Due To Slug Conflicts
Example:
```text
Slug '!nx9-dns-server'
is defined in multiple content databases
by owners [1,3]
```
Cause:
```text
Duplicate slug detected.
```
Resolution:
```text
Rename or remove conflicting resources.
Restart upgrade.
```
---
## QR Codes Return 404
Verify:
```text
global_slugs
```
contains the slug.
Verify slug status:
```text
active
```
---
## Landing Page Redirect Fails
Verify:
```text
target_type = page
```
in:
```text
global_slugs
```
---
## Ownership Errors
Run:
```bash
bzod doctor
```
Verify ownership integrity passes.
---
# Upgrade Status
BZOD v0.5.1 upgrade path has been validated through:
* Migration Tests
* Upgrade Validation Tests
* Namespace Integrity Tests
* Ownership Isolation Tests
* Backup & Restore Tests
* Dashboard Parity Tests
* QR Endpoint Tests
* Routing Tests
The v0.5.1 upgrade path is considered production-ready.
+8 -4
View File
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
} }
impl AnalyticsQueue { impl AnalyticsQueue {
pub fn new(db: Db, capacity: usize) -> Self { pub fn new(
db: Db,
capacity: usize,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> (Self, tokio::task::JoinHandle<()>) {
let (sender, receiver) = mpsc::channel(capacity); let (sender, receiver) = mpsc::channel(capacity);
// Spawn background worker to batch-write records // Spawn background worker to batch-write records
tokio::spawn(async move { let handle = tokio::spawn(async move {
super::worker::run_worker(db, receiver).await; super::worker::run_worker(db, receiver, shutdown_rx).await;
}); });
Self { sender } (Self { sender }, handle)
} }
// Attempt to queue a visit. Non-blocking. // Attempt to queue a visit. Non-blocking.
+52 -7
View File
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
use crate::db::Db; use crate::db::Db;
use crate::models::VisitRecord; use crate::models::VisitRecord;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) { pub async fn run_worker(
db: Db,
mut receiver: mpsc::Receiver<VisitRecord>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let mut batch = Vec::new(); let mut batch = Vec::new();
let batch_size = 50; let batch_size = 50;
let flush_interval = Duration::from_secs(2); let flush_interval = Duration::from_secs(2);
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
flush_batch(&db, &mut batch); flush_batch(&db, &mut batch);
} }
} }
_ = shutdown_rx.changed() => {
info!("Analytics worker flushing pending records");
flush_batch(&db, &mut batch);
break;
}
} }
} }
} }
@@ -46,11 +55,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
return; return;
} }
info!("Flushing {} visits to analytics database", batch.len()); info!(
let mut conn_lock = db.analytics.lock().unwrap(); "Flushing {} visits to user analytics databases",
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) { batch.len()
error!("Failed to write analytics batch to database: {:?}", e); );
} else {
batch.clear(); // Group visits by owner_user_id
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
std::collections::HashMap::new();
for record in batch.drain(..) {
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
groups.entry(user_id).or_default().push(record);
}
for (user_id, user_visits) in groups {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match rusqlite::Connection::open(&db_path) {
Ok(mut conn) => {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
error!(
"Failed to write analytics batch to user {} database: {:?}",
user_id, e
);
}
}
Err(e) => {
error!(
"Failed to open analytics database for user {}: {:?}",
user_id, e
);
}
}
} }
} }
+37 -5
View File
@@ -1,13 +1,44 @@
use crate::auth::session::authenticate_api_key; use crate::auth::session::authenticate_api_key;
use crate::models::User; use crate::models::ApiActor;
use crate::state::AppState; use crate::state::AppState;
use axum::{ use axum::{
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode}, http::{request::Parts, StatusCode},
Json,
}; };
// Extractor: Authenticate API requests using Bearer token // Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User); pub struct ApiUser(pub ApiActor);
impl ApiUser {
pub fn require_admin(
&self,
) -> Result<&crate::models::User, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::Admin(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Admin privileges required".to_string(),
}),
)),
}
}
pub fn require_tenant(
&self,
) -> Result<&crate::models::TenantUser, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::User(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Tenant privileges required".to_string(),
}),
)),
}
}
}
#[axum::async_trait] #[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser impl<S> FromRequestParts<S> for ApiUser
@@ -25,9 +56,10 @@ where
.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"))?;
let conn = app_state.admin_db.lock().unwrap(); let admin_conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) { let users_conn = app_state.users_db.lock().unwrap();
Ok(Some(user)) => Ok(ApiUser(user)), match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
Ok(Some(actor)) => Ok(ApiUser(actor)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")), Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")), Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
} }
+3 -1
View File
@@ -6,4 +6,6 @@ pub mod session;
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 password::{hash_password, verify_password, verify_sha256};
pub use session::{authenticate_api_key, authenticate_session, generate_token}; pub use session::{
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
};
+255 -17
View File
@@ -1,11 +1,11 @@
use crate::db::admin::{ use crate::db::admin::{
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
}; };
use crate::models::User; use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rand::{thread_rng, RngCore}; use rand::{thread_rng, RngCore};
use rusqlite::Connection; use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded) // Generate a secure random token (hex-encoded)
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key) hex::encode(key)
} }
// Authenticate session from cookies // Authenticate administrator session from cookies
pub fn authenticate_session( pub fn authenticate_admin_session(
conn: &Connection, conn: &Connection,
jar: &CookieJar, jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> { ) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -26,7 +26,33 @@ pub fn authenticate_session(
}; };
let session_id = cookie.value(); let session_id = cookie.value();
let session = match get_session(conn, session_id)? { let session_opt: Option<AdminSession> = conn
.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
[session_id],
|row| {
let id: String = row.get(0)?;
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
let user_id_str: String = match row.get::<_, String>(1) {
Ok(s) => s,
Err(_) => {
let i: i64 = row.get(1)?;
i.to_string()
}
};
let expires_at: String = row.get(2)?;
let created_at: String = row.get(3)?;
Ok(AdminSession {
id,
user_id: user_id_str,
expires_at,
created_at,
})
},
)
.optional()?;
let session = match session_opt {
Some(s) => s, Some(s) => s,
None => return Ok(None), None => return Ok(None),
}; };
@@ -41,19 +67,171 @@ pub fn authenticate_session(
return Ok(None); return Ok(None);
} }
// Get user // Get user (status must be 'active' and account_type = 'admin')
if let Some(user) = get_user_by_id(conn, &session.user_id)? { // If the session user_id looks numeric, bind as integer when querying users.db
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[id_i64],
|row| {
let id_str = row.get::<_, i64>(0)?.to_string();
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[session.user_id.as_str()],
|row| {
// admin DB stores UUID string ids, so read as String
let id_str: String = row.get(0)?;
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} {
Ok(opt) => (opt, false),
Err(_) => (None, true),
};
if let Some(user) = user_opt {
return Ok(Some((user, session.id)));
}
if extended_failed {
// Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns
// Try a simpler lookup by id only.
let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[id_i64],
|row| {
Ok(User {
id: row.get::<_, i64>(0)?.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[session.user_id.as_str()],
|row| {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
};
if let Some(user) = fallback_user_opt {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
// Authenticate user session from cookies
pub fn authenticate_user_session(
users_conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(TenantUser, String)>, rusqlite::Error> {
let cookie = match jar.get("bzod_user_session") {
Some(c) => c,
None => return Ok(None),
};
let session_id = cookie.value();
// Get session from sessions table in users.db
let mut stmt = users_conn
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let session_opt: Option<UserSession> = stmt
.query_row([session_id], |row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
let session = match session_opt {
Some(s) => s,
None => return Ok(None),
};
// Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() {
return Ok(None);
}
} else {
return Ok(None);
}
// Get tenant user (status must be 'active')
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([session.user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
Ok(Some((user, session.id))) Ok(Some((user, session.id)))
} else { } else {
Ok(None) Ok(None)
} }
} }
// Authenticate API key from Authorization header // Authenticate API key/token from Authorization header (unified)
pub fn authenticate_api_key( pub fn authenticate_api_key(
conn: &Connection, admin_conn: &Connection,
users_conn: &Connection,
auth_header: &str, auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> { ) -> Result<Option<ApiActor>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") { if !auth_header.starts_with("Bearer ") {
return Ok(None); return Ok(None);
} }
@@ -68,13 +246,73 @@ pub fn authenticate_api_key(
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)? { // 1. Check user API tokens in users.db
// Update last used timestamp let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
update_api_key_last_used(conn, &api_key_rec.id)?; let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
// Get user if let Some(user_id) = user_id_opt {
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { let mut stmt = users_conn.prepare(
return Ok(Some(user)); "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user)));
}
}
// 2. Check admin system API keys in admin.db
if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(admin_conn, &api_key_rec.id)?;
// Get admin user from users.db (users_conn)
// Try to interpret the api_key user_id as an integer referencing users.db
if let Ok(user_id_i64) = api_key_rec.user_id.parse::<i64>() {
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
)?;
let user_opt = stmt
.query_row([user_id_i64], |row| {
let id_i64: i64 = row.get(0)?;
Ok(User {
id: id_i64.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::Admin(user)));
}
}
// Fallback: admin DB may store users with string UUIDs. Try looking up directly in admin_conn.
if let Ok(Some(admin_user)) = get_admin_user_by_id(admin_conn, &api_key_rec.user_id) {
return Ok(Some(ApiActor::Admin(User {
id: admin_user.id,
username: admin_user.username,
password_hash: admin_user.password_hash,
created_at: admin_user.created_at,
})));
} }
} }
+5
View File
@@ -0,0 +1,5 @@
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
Some(commit) => commit,
None => "unknown",
};
+152
View File
@@ -0,0 +1,152 @@
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
target_admin_id: i64,
data_dir: Option<String>,
dry_run: bool,
force: bool,
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)?;
// 1. Verify target admin exists and is an admin
let target_user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, target_admin_id)?
};
let target_user = match target_user {
Some(u) => u,
None => {
error!("Target admin ID {} not found", target_admin_id);
return Ok(());
}
};
if target_user.account_type != "admin" {
error!(
"Target user '{}' (ID {}) is not an admin account.",
target_user.username, target_admin_id
);
return Ok(());
}
if target_admin_id == 1 {
error!("Target admin ID cannot be 1 (legacy admin).");
return Ok(());
}
// 2. Open databases
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
if !legacy_content_path.exists() {
info!(
"No legacy admin content database found at {:?}",
legacy_content_path
);
return Ok(());
}
db.init_user_databases(target_admin_id)?;
let target_content_path = config
.data_dir
.join("users")
.join(target_admin_id.to_string())
.join("content.db");
let mut legacy_conn = Connection::open(&legacy_content_path)?;
let mut target_conn = Connection::open(&target_content_path)?;
let mut system_conn = db.system.lock().unwrap();
println!("Scanning legacy admin content database...");
// 3. Count items
let urls = {
let mut stmt = legacy_conn.prepare("SELECT * FROM urls;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let url_count = urls.len();
let pages = {
let mut stmt = legacy_conn.prepare("SELECT * FROM landing_pages;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let page_count = pages.len();
println!(
"Found {} URLs and {} Landing Pages owned by legacy admin (ID 1).",
url_count, page_count
);
if dry_run {
println!("Dry run mode enabled. No changes will be made.");
return Ok(());
}
if !force {
println!("Migration requires the --force flag to execute. Aborting.");
return Ok(());
}
println!(
"Starting migration to Admin '{}' (ID {})...",
target_user.username, target_admin_id
);
// 4. Perform Migration (using ATTACH DATABASE for fast copy)
// We attach the legacy db to the target db to do INSERT INTO ... SELECT * FROM
target_conn.execute(
"ATTACH DATABASE ?1 AS legacy;",
rusqlite::params![legacy_content_path.to_string_lossy()],
)?;
let tx = target_conn.transaction()?;
tx.execute("INSERT OR IGNORE INTO urls SELECT * FROM legacy.urls;", [])?;
tx.execute(
"INSERT OR IGNORE INTO landing_pages SELECT * FROM legacy.landing_pages;",
[],
)?;
tx.commit()?;
target_conn.execute("DETACH DATABASE legacy;", [])?;
// 5. Update global registry
let sys_tx = system_conn.transaction()?;
let updated_slugs = sys_tx.execute(
"UPDATE global_slugs SET owner_user_id = ?1 WHERE owner_user_id = 1;",
rusqlite::params![target_admin_id],
)?;
sys_tx.commit()?;
// 6. Delete from legacy
let legacy_tx = legacy_conn.transaction()?;
legacy_tx.execute("DELETE FROM urls;", [])?;
legacy_tx.execute("DELETE FROM landing_pages;", [])?;
legacy_tx.commit()?;
println!("Migration Complete!");
println!("-------------------");
println!("Migrated {} URLs.", url_count);
println!("Migrated {} Landing Pages.", page_count);
println!("Updated {} slugs in global registry.", updated_slugs);
println!("Cleared legacy content database.");
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
use crate::config::Config;
use crate::db::Db;
use crate::services::destination_audit::{audit_all_destinations, format_report};
use std::path::PathBuf;
use tracing::info;
/// Read-only audit of all stored redirect destinations.
///
/// Does not rewrite, delete, or "repair" any records.
pub async fn run(
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);
}
info!("Starting read-only destination audit...");
let db = Db::init(&config)?;
let report = audit_all_destinations(&db)?;
print!("{}", format_report(&report));
if report.invalid > 0 {
// Non-zero exit so automation can detect findings without treating them as crashes.
Err(format!(
"destination audit found {} invalid stored URL(s)",
report.invalid
)
.into())
} else {
Ok(())
}
}
+150
View File
@@ -0,0 +1,150 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::fs::File;
use std::path::{Path, PathBuf};
use tar::{Builder, Header};
use tracing::{error, info};
use zstd::Encoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
username: String,
out: Option<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 username_clean = username.trim().to_lowercase();
// 1. Get user details from users.db
let user_details = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_username(&conn, &username_clean)?
};
let user = match user_details {
Some(u) => u,
None => {
error!("User '{}' not found", username_clean);
return Ok(());
}
};
let user_id = user.id;
// 2. Fetch user's quotas
let quotas = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;",
[user_id],
|row| {
Ok(UserBackupQuotas {
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
})
}
)?
};
// 3. Define output path
let tar_path = match out {
Some(p) => PathBuf::from(p),
None => {
if !config.backup_dir.exists() {
std::fs::create_dir_all(&config.backup_dir)?;
}
config.backup_dir.join(format!(
"{}-{}.tar.zst",
username_clean,
Utc::now().format("%Y%m%d")
))
}
};
info!(
"Backing up user {} (ID: {}) to {:?}",
username_clean, user_id, tar_path
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
// 5. Create tar.zst archive
let file = File::create(&tar_path)?;
let zst_enc = Encoder::new(file, 3)?;
let mut tar = Builder::new(zst_enc);
// Write metadata.json directly into tar
let metadata_obj = UserBackupMetadata {
id: user.id,
username: user.username,
password_hash: user.password_hash,
status: user.status,
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
let mut header = Header::new_gnu();
header.set_size(metadata_bytes.len() as u64);
header.set_path("metadata.json")?;
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, &metadata_bytes[..])?;
// Append database files
let mut append_file =
|name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box<dyn std::error::Error>> {
if path_on_disk.exists() {
let mut file = File::open(path_on_disk)?;
tar.append_file(name_in_archive, &mut file)?;
}
Ok(())
};
append_file("content.db", &user_dir.join("content.db"))?;
append_file("analytics.db", &user_dir.join("analytics.db"))?;
append_file("profile.db", &user_dir.join("profile.db"))?;
tar.into_inner()?.finish()?;
info!("User backup generated successfully at {:?}", tar_path);
Ok(())
}
+3 -2
View File
@@ -32,8 +32,9 @@ 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.users.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!( info!(
"Successfully created admin user: {} (ID: {})", "Successfully created admin user: {} (ID: {})",
u.username, u.id u.username, u.id
+86
View File
@@ -0,0 +1,86 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<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 final_username = match username {
Some(u) => u,
None => read_input("Enter username: "),
};
let username_clean = final_username.trim().to_lowercase();
if username_clean.is_empty() {
error!("Username cannot be empty");
return Ok(());
}
if username_clean.len() < 3 {
error!("Username must be at least 3 characters");
return Ok(());
}
if !username_clean
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
error!("Username must contain only alphanumeric characters, hyphens, or underscores");
return Ok(());
}
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
// Check if user already exists
{
let conn = db.users.lock().unwrap();
if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() {
error!("User already exists: {}", username_clean);
return Ok(());
}
}
// Create user in DB (this seeds default quotas too)
let new_user = {
let conn = db.users.lock().unwrap();
crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)?
};
// Initialize their user specific directory and DB files (content.db, analytics.db, profile.db)
db.init_user_databases(new_user.id)?;
info!(
"Successfully created standard user: {} (ID: {})",
new_user.username, new_user.id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+92
View File
@@ -0,0 +1,92 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
force: bool,
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)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, user_id)? {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
let conn = db.users.lock().unwrap();
crate::db::users::delete_user(&conn, user_id)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DELETION",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
}
info!(
"Successfully deleted user {} (ID: {}) and all associated content",
user_details.username, user_id
);
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
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)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "disabled" {
info!("User {} is already disabled", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "disabled")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DISABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!("User {} (ID: {}) has been disabled", user.username, user_id);
Ok(())
}
+106 -3
View File
@@ -22,11 +22,25 @@ pub async fn run(
println!("Data directory: {:?}", config.data_dir); println!("Data directory: {:?}", config.data_dir);
println!(); println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true; let mut all_healthy = true;
for db_name in &databases { // Define target databases in the new layout
let db_path = config.data_dir.join(format!("{}.db", db_name)); let admin_dir = config.data_dir.join("admin");
let legacy_user_dir = config.data_dir.join("users").join("1");
let dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", legacy_user_dir.join("content.db")),
("legacy analytics", legacy_user_dir.join("analytics.db")),
];
for (db_name, db_path) in dbs {
// Skip legacy databases if they don't exist
if db_name.starts_with("legacy") && !db_path.exists() {
continue;
}
if !db_path.exists() { if !db_path.exists() {
println!("Database: {}", db_name); println!("Database: {}", db_name);
@@ -75,6 +89,95 @@ pub async fn run(
println!(); println!();
} }
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if system_db_path.exists() && users_db_path.exists() {
match (
Connection::open(&system_db_path),
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::services::registry_validator::RegistryValidator::scan(
&sys_conn,
&usr_conn,
&config.data_dir,
None,
) {
Ok(issues) => {
if issues.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
println!(" Status: ISSUES DETECTED");
all_healthy = false;
for issue in &issues {
println!();
println!("ERROR");
println!();
println!("Slug:");
println!(" {}", issue.slug);
println!();
println!("Type:");
println!(
" {}",
if issue.target_type == "url" {
"URL"
} else if issue.target_type == "page" {
"Landing Page"
} else {
&issue.target_type
}
);
println!();
println!("Owner:");
println!(" User ID {}", issue.owner_user_id);
println!();
println!("Database:");
println!(" {}", issue.database_path.display());
println!();
println!("Target UUID:");
println!(" {}", issue.target_id);
println!();
println!("Issue:");
println!(" {:?}", issue.issue_type);
println!();
println!("Description:");
println!(" {}", issue.description);
println!();
println!("Suggested Repair:");
println!();
if issue.slug != "*" {
println!(
" bzod repair registry --slug {} --dry-run",
issue.slug
);
} else {
println!(" bzod repair registry --dry-run");
}
println!("--------------------");
}
}
}
Err(e) => {
println!(" Status: ERROR running registry scan: {}", e);
all_healthy = false;
}
}
}
_ => {
println!(" Status: ERROR opening system.db or users.db for integrity check");
all_healthy = false;
}
}
} else {
println!(" Status: SKIPPED (system.db/users.db not found)");
}
println!();
println!("--------------------"); println!("--------------------");
if all_healthy { if all_healthy {
println!("Overall status: HEALTHY"); println!("Overall status: HEALTHY");
+58
View File
@@ -0,0 +1,58 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
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)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "active" {
info!("User {} is already active", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "active")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_ENABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"User {} (ID: {}) has been enabled (active)",
user.username, user_id
);
Ok(())
}
+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()),
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
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 users = {
let conn = db.users.lock().unwrap();
crate::db::users::list_users(&conn)?
};
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
"ID", "Username", "Status", "Type", "Created At"
);
println!(
"{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}",
"", "", "", "", ""
);
for u in users {
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
u.id, u.username, u.status, u.account_type, u.created_at
);
}
Ok(())
}
+134
View File
@@ -1,16 +1,30 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
pub mod admin_migrate;
pub mod audit_destinations;
pub mod backup; pub mod backup;
pub mod backup_user;
pub mod create_admin; pub mod create_admin;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod doctor; pub mod doctor;
pub mod enable_user;
pub mod expand;
pub mod list_users;
pub mod migrate; pub mod migrate;
pub mod repair;
pub mod reset_password;
pub mod restore; pub mod restore;
pub mod restore_user;
pub mod serve; pub mod serve;
pub mod shorten;
pub mod stats; pub mod stats;
pub mod validate; pub mod validate;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bzod")] #[command(name = "bzod")]
#[command(version)]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
pub struct Cli { pub struct Cli {
#[command(subcommand)] #[command(subcommand)]
@@ -60,6 +74,11 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
AuditDestinations {
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new administrator user in the database /// Create a new administrator user in the database
CreateAdmin { CreateAdmin {
#[arg(long)] #[arg(long)]
@@ -72,4 +91,119 @@ 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>,
},
/// Create a new standard user in the database
CreateUser {
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Delete a standard user and all their databases/slugs
DeleteUser {
/// User ID to delete
user_id: i64,
/// Force deletion of system account/legacy_admin
#[arg(long)]
force: bool,
#[arg(long)]
data_dir: Option<String>,
},
/// Disable a standard user
DisableUser {
/// User ID to disable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Enable a standard user
EnableUser {
/// User ID to enable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Reset standard user's password
ResetPassword {
/// User ID to reset
user_id: i64,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// List all standard/system users
ListUsers {
#[arg(long)]
data_dir: Option<String>,
},
/// Backup a standard user's databases to a .tar.zst package
BackupUser {
/// Username to backup
username: String,
/// Output .tar.zst filepath
#[arg(long)]
out: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Restore a standard user's databases from a .tar.zst package
RestoreUser {
/// Input .tar.zst package path
#[arg(long, required = true)]
file: String,
#[arg(long)]
data_dir: Option<String>,
},
/// FUTURE: Migrate legacy admin content to a specific admin tenant database
AdminMigrate {
/// Target Admin ID
target_admin_id: i64,
#[arg(long)]
data_dir: Option<String>,
/// Preview what would be moved without making changes
#[arg(long)]
dry_run: bool,
/// Force the migration to execute
#[arg(long)]
force: bool,
},
/// Repair registry and database inconsistencies
Repair {
#[command(subcommand)]
command: RepairCommands,
},
}
#[derive(clap::Subcommand)]
pub enum RepairCommands {
/// Repair Global Slug Registry inconsistencies
Registry {
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
} }
+178
View File
@@ -0,0 +1,178 @@
use crate::cli::RepairCommands;
use crate::config::Config;
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
command: RepairCommands,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
RepairCommands::Registry {
dry_run,
force,
slug,
data_dir,
} => {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if !dry_run && !force {
println!("Error: You must specify either --dry-run or --force");
return Ok(());
}
if dry_run && force {
println!("Error: Cannot specify both --dry-run and --force");
return Ok(());
}
let start_time = std::time::Instant::now();
let admin_dir = config.data_dir.join("admin");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if !system_db_path.exists() || !users_db_path.exists() {
println!("Error: system.db or users.db not found.");
return Ok(());
}
let mut sys_conn = Connection::open(&system_db_path)?;
let usr_conn = Connection::open(&users_db_path)?;
let slug_filter = slug.as_deref();
if dry_run {
println!("BZOD Registry Repair\n");
println!("Scanning Global Slug Registry...");
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
println!("\nDetected:");
println!("\nPages:\n {} orphaned", orphaned_pages);
println!("\nURLs:\n {} orphaned", orphaned_urls);
if !orphaned.is_empty() {
println!("\nThe following entries would be removed:");
for issue in &orphaned {
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
}
}
println!("\nNo changes have been made.");
println!(
"\nRun again with:\n\n bzod repair registry --force{}",
if let Some(s) = slug_filter {
format!(" --slug {}", s)
} else {
"".to_string()
}
);
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, start_time.elapsed()
);
} else if force {
let tx = sys_conn.transaction()?;
let issues =
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
if orphaned.is_empty() {
println!("No repairs required.");
return Ok(());
}
if let Some(s) = slug_filter {
println!("Checking slug:\n\n{}\n", s);
if let Some(issue) = orphaned.first() {
println!("Owner:\n\n{}\n", issue.owner_user_id);
println!("Status:\n\nOrphaned\n");
}
}
let mut removed_count = 0;
for issue in &orphaned {
let rows = tx.execute(
"DELETE FROM global_slugs WHERE slug = ?1",
rusqlite::params![issue.slug],
)?;
removed_count += rows;
}
tx.commit()?;
if slug_filter.is_some() {
println!("Removed:\n\nSUCCESS");
} else {
println!("Repair Complete\n");
println!("Removed:\n");
println!("Pages:\n {}\n", orphaned_pages);
println!("URLs:\n {}\n", orphaned_urls);
let remaining: i64 =
sys_conn
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
println!("Remaining Registry Entries:\n {}\n", remaining);
let post_issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
let post_orphaned = post_issues
.iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.count();
println!(
"Integrity:\n {}",
if post_orphaned == 0 { "PASS" } else { "FAIL" }
);
}
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
);
}
}
}
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
password: Option<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)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
let final_password = match password {
Some(p) => p,
None => read_input("Enter new password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
{
let conn = db.users.lock().unwrap();
crate::db::users::reset_user_password(&conn, user_id, &hash)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_PASSWORD_RESET",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"Password for user {} (ID: {}) has been reset successfully",
user.username, user_id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+381 -6
View File
@@ -1,10 +1,388 @@
use crate::config::Config; use crate::config::Config;
use crate::services::registry_validator::RegistryIssueType;
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use tar::Archive; use tar::Archive;
use tracing::{error, info}; use tracing::{error, info, warn};
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
let manifest_path = temp_dir.join("backup_manifest.json");
if !manifest_path.exists() {
return false;
}
match std::fs::read_to_string(&manifest_path) {
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
Err(_) => false,
},
Err(_) => false,
}
}
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
fn is_flat_layout(temp_dir: &Path) -> bool {
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
}
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
///
/// This function:
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
/// 2. Reads the actual administrator identity from admin.db (preserving
/// the original username and argon2id password hash — no manufacturing).
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
/// 4. Creates an admin account with the original credentials.
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
/// accounts for any missing tenants.
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
let users_db_path = temp_dir.join("admin").join("users.db");
let admin_db_path = temp_dir.join("admin").join("admin.db");
let system_db_path = temp_dir.join("admin").join("system.db");
// Check if users.db already has the users table (i.e., not a legacy backup)
{
let conn = rusqlite::Connection::open(&users_db_path)?;
let has_users_table: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_users_table {
info!("users.db already has users table; skipping legacy bootstrap");
return Ok(());
}
}
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
// Step 1: Run migrations to create the users.db schema
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
crate::db::sqlite::enable_wal(&users_conn, "users")?;
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
// Step 2: Read the actual administrator identity from admin.db
let (admin_username, admin_password_hash) = {
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
// The legacy admin.db users table has schema:
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
// Read the actual admin — typically the first (and often only) user.
let result: Result<(String, String), _> = admin_conn.query_row(
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((username, hash)) => {
info!(
"Preserved administrator identity from legacy admin.db: username='{}'",
username
);
(username, hash)
}
Err(e) => {
return Err(format!(
"Failed to read administrator credentials from legacy admin.db: {}",
e
)
.into());
}
}
};
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
// This account owns the content.db/analytics.db from the flat backup (users/1/).
// It uses the original admin's password hash so no synthetic credentials are introduced.
let now = chrono::Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
1i64,
"legacy_admin",
&admin_password_hash,
"disabled",
&now,
"system"
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
info!("Created legacy_admin system account (id=1) for tenant content ownership");
// Step 4: Create the actual admin account with original credentials
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![
&admin_username,
&admin_password_hash,
"active",
&now,
"admin"
],
)?;
let admin_id = users_conn.last_insert_rowid();
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
info!(
"Created admin account '{}' (id={}) with original credentials",
admin_username, admin_id
);
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
if system_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let has_global_slugs: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_global_slugs {
let mut stmt =
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let owner_id: i64 = row.get(0)?;
// Skip user 1 (legacy_admin) and the admin we just created
if owner_id == 1 || owner_id == admin_id {
continue;
}
// Check if this user already exists in users.db
let exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_id],
|r| r.get(0),
)
.unwrap_or(false);
if !exists {
// Create a disabled placeholder so RegistryValidator can resolve ownership.
// The tenant's actual databases were not included in the flat backup.
let placeholder_name = format!("restored_user_{}", owner_id);
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
owner_id,
&placeholder_name,
&admin_password_hash,
"disabled",
&now,
"standard",
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
warn!(
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
owner_id
);
}
}
}
}
Ok(())
}
/// Classify registry issues into hard errors vs warnings for legacy restore.
///
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
/// TenantAdminHasIsolatedContent
fn classify_registry_issues(
issues: &[crate::services::registry_validator::RegistryIssue],
is_legacy: bool,
) -> (
Vec<&crate::services::registry_validator::RegistryIssue>,
Vec<&crate::services::registry_validator::RegistryIssue>,
) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
match issue.issue_type {
RegistryIssueType::DuplicateSlug
| RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => {
errors.push(issue);
}
RegistryIssueType::MissingOwner if !is_legacy => {
errors.push(issue);
}
_ => {
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
// These represent pre-existing inconsistencies in the backup data,
// not restore corruption.
warnings.push(issue);
}
}
}
(errors, warnings)
}
pub fn perform_restore(
file_path: &Path,
data_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open and unpack the archive to a temporary directory
let f = File::open(file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
let temp_dir =
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir)?;
if let Err(e) = archive.unpack(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(e.into());
}
// 2. Detect backup format
let is_legacy = is_legacy_flat_backup(&temp_dir);
let needs_normalization = is_flat_layout(&temp_dir);
if is_legacy {
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
}
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
if needs_normalization {
info!("Normalizing flat database layout into multi-tenant structure...");
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to normalize legacy layout: {}", e).into());
}
}
// 4. For legacy backups: bootstrap the empty users.db with the current schema
// and populate it from admin.db credentials
if is_legacy {
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
}
}
// 5. Run validation on the normalized temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
// Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
Ok(report) => {
if !report.duplicates.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
);
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
}
}
// Registry integrity check
let system_db_path = temp_dir.join("admin").join("system.db");
let users_db_path = temp_dir.join("admin").join("users.db");
if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let users_conn = rusqlite::Connection::open(&users_db_path)?;
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&temp_dir,
None,
) {
Ok(issues) => {
if !issues.is_empty() {
let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
// Log all warnings
for w in &warnings {
warn!(
"Legacy restore warning: {:?} — {}",
w.issue_type, w.description
);
}
// Abort only on hard errors
if !hard_errors.is_empty() {
let descriptions: Vec<String> = hard_errors
.iter()
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
.collect();
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!(
"Registry integrity errors in backup ({} critical): {}",
hard_errors.len(),
descriptions.join("; ")
)
.into());
}
if !warnings.is_empty() {
info!(
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
warnings.len()
);
}
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
}
}
}
// 6. If validation succeeds, atomically replace data_dir contents
if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir);
}
std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to copy restored files: {}", e).into());
}
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(())
}
pub async fn run( pub async fn run(
file: String, file: String,
@@ -40,10 +418,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(())
+182
View File
@@ -0,0 +1,182 @@
use crate::config::Config;
use crate::db::Db;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use tracing::{error, info};
use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
file: 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 file_path = PathBuf::from(file);
if !file_path.exists() {
error!("Backup file not found: {:?}", file_path);
return Ok(());
}
let db = Db::init(&config)?;
// 1. Read metadata.json from the tar.zst archive
let f = File::open(&file_path)?;
let zst_dec = Decoder::new(f)?;
let mut archive = Archive::new(zst_dec);
let mut metadata_opt: Option<UserBackupMetadata> = None;
for entry_res in archive.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "metadata.json" {
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry)?;
metadata_opt = Some(meta);
break;
}
}
let metadata = match metadata_opt {
Some(m) => m,
None => {
error!("Archive is missing metadata.json");
return Ok(());
}
};
info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db
let target_user_id = {
let users_conn = db.users.lock().unwrap();
let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user {
Some(u) => {
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
)?;
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
u.id
}
None => {
let id_taken: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[metadata.id],
|row| row.get(0),
)
.unwrap_or(false);
let new_id = if !id_taken {
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
users_conn.last_insert_rowid()
};
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
new_id
}
}
};
// 3. Extract database files to /data/users/<target_user_id>/
let dest_dir = config
.data_dir
.join("users")
.join(target_user_id.to_string());
std::fs::create_dir_all(&dest_dir)?;
let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?;
let mut archive2 = Archive::new(zst_dec2);
for entry_res in archive2.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"content.db" => {
let mut out_file = File::create(dest_dir.join("content.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"analytics.db" => {
let mut out_file = File::create(dest_dir.join("analytics.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"profile.db" => {
let mut out_file = File::create(dest_dir.join("profile.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
_ => {}
}
}
// 4. Register slugs in global_slugs using the shared helper
{
let system_conn = db.system.lock().unwrap();
crate::db::users::register_restored_user_slugs(
&system_conn,
target_user_id,
&dest_dir.join("content.db"),
)?;
}
// 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
info!(
"User '{}' (ID: {}) successfully restored from backup.",
metadata.username, target_user_id
);
Ok(())
}
+111 -17
View File
@@ -7,6 +7,30 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use tracing::info; use tracing::info;
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
pub async fn run( pub async fn run(
host: Option<String>, host: Option<String>,
port: Option<u16>, port: Option<u16>,
@@ -29,45 +53,91 @@ pub async fn run(
// Init DBs // Init DBs
let db = Db::init(&config)?; let db = Db::init(&config)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut join_handles = Vec::new();
// Init Queue // Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000); let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
join_handles.push(("analytics_worker", analytics_handle));
// Spawn background tasks // Spawn background tasks
let link_checker_db = db.clone(); let link_checker_db = db.clone();
let link_checker_interval = config.link_check_interval_mins; let link_checker_interval = config.link_check_interval_mins;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await; join_handles.push((
}); "link_checker",
tokio::spawn(async move {
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
}),
));
let aggregator_db = db.clone(); let aggregator_db = db.clone();
let aggregator_interval = config.aggregation_interval_mins; let aggregator_interval = config.aggregation_interval_mins;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await; join_handles.push((
}); "aggregator",
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
}),
));
let retention_db = db.clone(); let retention_db = db.clone();
let retention_days = config.data_retention_days; let retention_days = config.data_retention_days;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_retention_cleaner(retention_db, retention_days).await; join_handles.push((
}); "retention_cleaner",
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
}),
));
// Spawn optional backup scheduler // Spawn optional backup scheduler
let backup_db = db.clone(); let backup_db = db.clone();
let backup_config = config.clone(); let backup_config = config.clone();
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await; join_handles.push((
}); "backup_scheduler",
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
}),
));
let expiry_db = db.clone(); let expiry_db = db.clone();
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_expiry_checker(expiry_db).await; join_handles.push((
}); "expiry_checker",
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db, rx).await;
}),
));
let reconcile_db = db.clone();
let reconcile_interval_hours = {
let conn = db.system.lock().unwrap();
conn.query_row(
"SELECT value FROM settings WHERE key = 'quota_reconcile_interval_hours';",
[],
|row| row.get::<_, String>(0),
)
.ok()
.and_then(|val| val.parse::<u64>().ok())
.unwrap_or(24)
};
let rx = shutdown_rx.clone();
join_handles.push((
"quota_reconciliation",
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).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(),
analytics_db: db.analytics.clone(), analytics_db: db.analytics.clone(),
system_db: db.system.clone(), system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
db: db.clone(), db: db.clone(),
config: config.clone(), config: config.clone(),
analytics_queue: queue, analytics_queue: queue,
@@ -80,7 +150,31 @@ pub async fn run(
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)
.with_graceful_shutdown(async move {
shutdown_signal().await;
info!("Shutdown signal received");
info!("Stopping HTTP server...");
let _ = shutdown_tx.send(true);
})
.await?;
info!("Stopping background workers...");
let timeout_duration = std::time::Duration::from_secs(10);
let deadline = tokio::time::Instant::now() + timeout_duration;
for (name, handle) in join_handles {
match tokio::time::timeout_at(deadline, handle).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
}
}
info!("Background workers stopped");
info!("BZOD shutdown complete");
Ok(()) Ok(())
} }
+95
View File
@@ -0,0 +1,95 @@
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. Register slug in system.db with status 'reserving' and check availability
{
let system_conn = db.system.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? {
return Err("Short code/slug already exists".into());
}
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
}
// 4. 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(url) => {
// Activate slug in system.db
{
let system_conn = db.system.lock().unwrap();
system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
}
// Increment quota for user ID 1
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
}
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(e) => {
let system_conn = db.system.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Err(e.into())
}
}
}
+18 -6
View File
@@ -14,14 +14,26 @@ pub async fn run(
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![
for f in files { ("admin.db", config.data_dir.join("admin/admin.db")),
let p = config.data_dir.join(f); ("system.db", config.data_dir.join("admin/system.db")),
if p.exists() { ("users.db", config.data_dir.join("admin/users.db")),
let sz = std::fs::metadata(&p)?.len(); (
"legacy content.db",
config.data_dir.join("users/1/content.db"),
),
(
"legacy analytics.db",
config.data_dir.join("users/1/analytics.db"),
),
];
for (name, path) in files {
if path.exists() {
let sz = std::fs::metadata(&path)?.len();
println!( println!(
" File: {} - Size: {} bytes ({:.2} MB)", " File: {} - Size: {} bytes ({:.2} MB)",
f, name,
sz, sz,
sz as f64 / 1_048_576.0 sz as f64 / 1_048_576.0
); );
+14 -4
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> { ) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
conn.execute( // Bind `user_id` as integer when it appears to be numeric so that numeric
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", // user IDs inserted into `users.db` keep the integer affinity and avoid
params![session_id, user_id, expires_at_rfc3339, created_at], // InvalidColumnType errors when read as i64 elsewhere.
)?; if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session { Ok(Session {
id: session_id.to_string(), id: session_id.to_string(),
+241 -3
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"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, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);" VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?; )?;
for r in records { for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer, r.referer,
r.accept_language, r.accept_language,
r.country, r.country,
r.status_code r.status_code,
r.owner_user_id
])?; ])?;
} }
} }
@@ -508,6 +509,243 @@ 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, owner_user_id 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")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
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, owner_user_id 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")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
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::*;
+13
View File
@@ -47,6 +47,19 @@ 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. /// 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"; 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";
+189 -5
View File
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
// Migration definitions // Migration definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration { pub const ADMIN_MIGRATIONS: &[Migration] = &[
version: 1, Migration {
name: "initial_schema", version: 1,
sql: r#" name: "initial_schema",
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,
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
value TEXT NOT NULL value TEXT NOT NULL
); );
"#, "#,
}]; },
Migration {
version: 2,
name: "remove_api_keys_fk",
sql: r#"
CREATE TABLE api_keys_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[ pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration { Migration {
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp); CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#, "#,
}, },
Migration {
version: 3,
name: "add_owner_user_id",
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
},
]; ];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#, "#,
}, },
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
]; ];
+433 -46
View File
@@ -1,6 +1,7 @@
use crate::config::Config; use crate::config::Config;
use crate::db::migrations::{ use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS, run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
}; };
use crate::db::sqlite::{enable_foreign_keys, enable_wal}; use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection; use rusqlite::Connection;
@@ -15,6 +16,7 @@ pub mod migrations;
pub mod preview; pub mod preview;
pub mod qr; pub mod qr;
pub mod sqlite; pub mod sqlite;
pub mod users;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -22,70 +24,108 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>, pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>, pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>, pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
} }
impl Db { impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> { pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists // Ensure data directory exists
if !config.data_dir.exists() { if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?; fs::create_dir_all(&config.data_dir)?;
} }
let admin_path = config.data_dir.join("admin.db"); let admin_dir = config.data_dir.join("admin");
let content_path = config.data_dir.join("content.db"); let users_dir = config.data_dir.join("users");
let analytics_path = config.data_dir.join("analytics.db"); fs::create_dir_all(&admin_dir)?;
let system_path = config.data_dir.join("system.db"); fs::create_dir_all(&users_dir)?;
use tracing::info; // Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
tracing::warn!("LEGACY DETECTED: admin.db found at root. Moving administrative databases to multi-tenant admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Pre-migration safety net: audit slug namespace for duplicates / format errors
match crate::db::users::audit_slug_namespace(config) {
Ok(report) => {
if !report.duplicates.is_empty() {
tracing::error!(
"Namespace conflicts detected before database migration: {:?}",
report.duplicates
);
return Err(format!(
"Database upgrade aborted due to slug conflicts: {:?}",
report.duplicates
)
.into());
}
}
Err(e) => {
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
info!("Opening admin.db"); info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?; let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db"); info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?; let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?; enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?; enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
// Enable foreign key support
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"
);
enable_foreign_keys(&content_conn, "content")?;
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?; enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// 1. Run migrations for system.db first, as it receives secondary audit records // Run migrations for system.db first
info!("Running system migrations"); info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?; run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn)); let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging // Pre-migration detection of admin account repair
let repair_needed = {
let stmt = users_conn.prepare(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
);
match stmt {
Ok(mut s) => s
.query_row([], |row| row.get::<_, bool>(0))
.unwrap_or(false),
Err(_) => false,
}
};
// Run migrations for admin.db and users.db
info!("Running admin migrations"); info!("Running admin migrations");
run_migrations( run_migrations(
&mut admin_conn, &mut admin_conn,
@@ -93,14 +133,131 @@ impl Db {
ADMIN_MIGRATIONS, ADMIN_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
info!("Running content migrations"); info!("Running users migrations");
run_migrations(
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
// Post-migration: audit log if repaired
if repair_needed {
let admin_is_now_admin: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
[],
|row| row.get(0),
)
.unwrap_or(false);
if admin_is_now_admin {
let system_conn = system_arc.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"admin",
"migration_repair",
"users",
"admin",
Some("Repaired standard account type to admin"),
);
}
}
// Clean up expired sessions from users.db on startup
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations( run_migrations(
&mut content_conn, &mut content_conn,
"content", "content",
CONTENT_MIGRATIONS, CONTENT_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
info!("Running analytics migrations");
run_migrations( run_migrations(
&mut analytics_conn, &mut analytics_conn,
"analytics", "analytics",
@@ -108,26 +265,256 @@ impl Db {
Some(&system_arc), Some(&system_arc),
)?; )?;
Ok(Self { // If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)), admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)), content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)), analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc, system: system_arc,
}) users: Arc::new(Mutex::new(users_conn)),
data_dir: config.data_dir.clone(),
};
let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&config.data_dir,
None,
) {
Ok(issues) => {
for issue in issues {
tracing::error!(
"Global registry integrity issue: {:?} for slug {}",
issue.issue_type,
issue.slug
);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db)
} }
pub fn compact(&self) -> Result<(), rusqlite::Error> { pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap(); let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?; let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap(); let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?; let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap(); let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?; let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap(); let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?; let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = user_dir.join("content.db");
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(()) Ok(())
} }
+869
View File
@@ -0,0 +1,869 @@
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
let u = username.trim().to_lowercase();
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
}
pub fn create_admin_user(
conn: &Connection,
username: &str,
password_hash: &str,
) -> rusqlite::Result<TenantUser> {
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
})
}
pub fn create_user(
conn: &Connection,
username: &str,
password_hash: &str,
account_type: &str,
metadata: Option<&str>,
) -> rusqlite::Result<TenantUser> {
if is_reserved_username(username) {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Username is reserved".to_string()),
));
}
let created_at = Utc::now().to_rfc3339();
let status = "active";
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
],
)?;
let id = conn.last_insert_rowid();
// Seed default quotas
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
Ok(TenantUser {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
status: status.to_string(),
created_at,
last_login: None,
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
params![id],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
params![username],
|row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET status = ?1 WHERE id = ?2;",
params![status, id],
)?;
Ok(())
}
pub fn update_user_account_type(
conn: &Connection,
id: i64,
account_type: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
params![account_type, id],
)?;
Ok(())
}
pub fn reset_user_password(
conn: &Connection,
id: i64,
new_password_hash: &str,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
params![new_password_hash, id],
)?;
Ok(())
}
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
params![now, id],
)?;
Ok(())
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})?;
let mut users = Vec::new();
for u in rows {
users.push(u?);
}
Ok(users)
}
pub fn log_username_change(
conn: &Connection,
user_id: i64,
old_username: &str,
new_username: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
params![user_id, old_username, new_username, now],
)?;
conn.execute(
"UPDATE users SET username = ?1 WHERE id = ?2;",
params![new_username, user_id],
)?;
Ok(())
}
// --- Session Operations ---
pub fn create_user_session(
conn: &Connection,
session_id: &str,
user_id: i64,
expires_at_rfc3339: &str,
) -> rusqlite::Result<UserSession> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(UserSession {
id: session_id.to_string(),
user_id,
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_user_session(
conn: &Connection,
session_id: &str,
) -> rusqlite::Result<Option<UserSession>> {
conn.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
params![session_id],
|row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
}
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
// --- Quota Operations ---
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
conn.query_row(
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
current_urls, current_landings, current_api_tokens, current_storage_mb
FROM quotas WHERE user_id = ?1;",
params![user_id],
|row| {
Ok(UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
},
)
.optional()
}
pub fn check_quota_limit(conn: &Connection, user_id: i64, field: &str) -> rusqlite::Result<bool> {
if let Some(quotas) = get_user_quotas(conn, user_id)? {
match field {
"urls" => Ok(quotas.current_urls < quotas.max_urls),
"landings" => Ok(quotas.current_landings < quotas.max_landings),
"api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens),
_ => Ok(false),
}
} else {
Ok(false)
}
}
pub fn update_user_quotas(
conn: &Connection,
user_id: i64,
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
WHERE user_id = ?5;",
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
)?;
Ok(())
}
pub fn increment_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
"landings" => {
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
}
"api_tokens" => {
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
}
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn decrement_quota_counter(
conn: &Connection,
user_id: i64,
field: &str,
) -> rusqlite::Result<()> {
let sql = match field {
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
_ => return Err(rusqlite::Error::InvalidQuery),
};
conn.execute(sql, params![user_id])?;
Ok(())
}
pub fn update_quota_storage(
conn: &Connection,
user_id: i64,
storage_mb: i64,
) -> rusqlite::Result<()> {
conn.execute(
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
params![storage_mb, user_id],
)?;
Ok(())
}
// --- API Token Operations ---
pub fn create_user_api_token(
conn: &Connection,
user_id: i64,
token_hash: &str,
) -> rusqlite::Result<UserApiToken> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
params![user_id, token_hash, created_at],
)?;
let id = conn.last_insert_rowid();
// Increment api token counter
let _ = increment_quota_counter(conn, user_id, "api_tokens");
Ok(UserApiToken {
id,
user_id,
token_hash: token_hash.to_string(),
created_at,
})
}
pub fn list_user_api_tokens(
conn: &Connection,
user_id: i64,
) -> rusqlite::Result<Vec<UserApiToken>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})?;
let mut tokens = Vec::new();
for t in rows {
tokens.push(t?);
}
Ok(tokens)
}
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
let deleted = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
params![id, user_id],
)?;
if deleted > 0 {
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
}
Ok(())
}
// --- Global Slug & Quota Reconciliation Helpers ---
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
if reserved {
return Ok(false);
}
// 2. Check global slugs
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
.unwrap_or(false);
Ok(!exists)
}
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
target_type: &str,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, NULL, ?2, 'created', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
owner_user_id: i64,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
system_conn.execute(
"UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug],
)?;
// Insert history
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'deleted', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
Ok(())
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugAuditReport {
pub duplicates: Vec<String>,
pub invalid_entries: Vec<String>,
pub warnings: Vec<String>,
}
pub fn audit_slug_namespace(
config: &crate::config::Config,
) -> Result<SlugAuditReport, Box<dyn std::error::Error>> {
use std::collections::HashMap;
let mut duplicates = Vec::new();
let mut invalid_entries = Vec::new();
let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db");
if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
}
}
}
}
// Landing Pages
if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1);
}
}
}
}
}
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users");
if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() {
let content_db_path = path.join("content.db");
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
// Landing pages
if let Ok(mut stmt) =
conn.prepare("SELECT code FROM landing_pages;")
{
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
}
}
}
}
}
}
}
// 3. Populate report
for (slug, owners) in slug_owners {
if owners.len() > 1 {
duplicates.push(format!(
"Slug '{}' is defined in multiple content databases by owners {:?}",
slug, owners
));
}
// Validate slug format
let valid_url = crate::utils::validation::validate_redirect_code(&slug);
let valid_page = crate::utils::validation::validate_page_code(&slug);
if !valid_url && !valid_page {
invalid_entries.push(format!("Slug '{}' is format-invalid", slug));
}
}
Ok(SlugAuditReport {
duplicates,
invalid_entries,
warnings,
})
}
pub fn cleanup_stale_reservations(
system_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<usize, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut cleaned_count = 0;
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';"
)?;
let mut rows = stmt.query([])?;
let mut stale_slugs = Vec::new();
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let created_at_str: String = row.get(3)?;
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
let mut target_exists = false;
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
if target_type == "url" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
} else if target_type == "page" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
}
}
}
if !target_exists {
stale_slugs.push((slug, owner_user_id));
}
}
}
}
drop(rows);
drop(stmt);
for (slug, owner_user_id) in stale_slugs {
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?;
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
cleaned_count += 1;
}
Ok(cleaned_count)
}
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
restored_content_db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let restored_content_conn = Connection::open(restored_content_db_path)?;
let mut urls = Vec::new();
let mut landing_pages = Vec::new();
// 1. Read URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
urls.push((code, id, created_at, status));
}
}
// 2. Read Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
landing_pages.push((code, id, created_at, state));
}
}
// 3. Check for collisions across all URLs and landing pages
let mut conflicting_slugs = Vec::new();
for (slug, _, _, _) in &urls {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
for (slug, _, _, _) in &landing_pages {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
if !conflicting_slugs.is_empty() {
return Err(format!(
"Restore failed. Conflicting slugs: {}",
conflicting_slugs.join(", ")
)
.into());
}
// 4. Perform registration
system_conn.execute(
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
)?;
for (slug, target_id, created_at, status) in urls {
let now = Utc::now().to_rfc3339();
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status],
)?;
}
for (slug, target_id, created_at, state) in landing_pages {
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
Ok(())
}
pub fn reconcile_user_quotas(
users_conn: &Connection,
user_id: i64,
content_conn: &Connection,
) -> rusqlite::Result<()> {
let urls_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
.unwrap_or(0);
let landings_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
.unwrap_or(0);
let api_tokens_count: i64 = users_conn
.query_row(
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
[user_id],
|row| row.get(0),
)
.unwrap_or(0);
users_conn.execute(
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
)?;
Ok(())
}
+44 -17
View File
@@ -5,28 +5,58 @@ use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day; use crate::analytics::aggregate_day;
use crate::db::Db; use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) { pub async fn run_aggregator(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop { loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Analytics aggregator shutting down...");
break;
}
}
info!("Running background analytics aggregator..."); info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
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 { let mut failed = false;
Ok(_) => log_job_end(&db.system, &job_id, "success", None), let mut err_msg = None;
Err(e) => {
let err_str = e.to_string(); for user_id in user_ids {
error!("Error performing aggregation: {}", err_str); if let Err(e) = perform_aggregation(&db, user_id).await {
log_job_end(&db.system, &job_id, "failed", Some(&err_str)); failed = true;
err_msg = Some(e.to_string());
} }
} }
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
log_job_end(&db.system, &job_id, "success", None);
}
} }
} }
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> { pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let date_range = { let mut conn = super::open_user_analytics_conn(db, user_id)?;
let conn = db.analytics.lock().unwrap(); let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
crate::db::analytics::get_visits_date_range(&conn)?
};
if let Some((min_date, max_date)) = date_range { if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?; let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
@@ -35,10 +65,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
let mut curr = min; let mut curr = min;
while curr <= max { while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string(); let date_str = curr.format("%Y-%m-%d").to_string();
{ aggregate_day(&mut conn, &date_str)?;
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
if curr == max { if curr == max {
break; break;
} }
+50 -8
View File
@@ -4,7 +4,11 @@ use crate::db::Db;
use std::time::Duration; use std::time::Duration;
use tracing::{error, info}; use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) { pub async fn run_backup_scheduler(
db: Db,
config: Config,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
if !config.backup_enabled { if !config.backup_enabled {
info!("Background backup scheduler is disabled."); info!("Background backup scheduler is disabled.");
return; return;
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
); );
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::select! {
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Backup scheduler shutting down...");
break;
}
}
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");
@@ -51,6 +61,36 @@ pub async fn perform_backup(
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);", []);
}
if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
for user_id in user_ids {
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
let _ = u_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);
@@ -59,12 +99,14 @@ pub async fn perform_backup(
let enc = GzEncoder::new(file, Compression::default()); let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc); let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; let admin_dir = config.data_dir.join("admin");
for f in files { if admin_dir.exists() {
let db_file = config.data_dir.join(f); tar.append_dir_all("admin", &admin_dir)?;
if db_file.exists() { }
tar.append_path_with_name(&db_file, f)?;
} let users_dir = config.data_dir.join("users");
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
} }
tar.into_inner()?.finish()?; tar.into_inner()?.finish()?;
+67 -8
View File
@@ -1,22 +1,81 @@
use crate::db::Db; use crate::db::Db;
use std::time::Duration; use std::time::Duration;
use tracing::info; use tracing::{error, info, warn};
/// Background job that marks expired URLs. /// Background job that marks expired URLs.
/// ///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0` /// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`. /// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) { ///
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
/// authoritative and returns 410 without depending on this sweeper. The sweeper
/// is maintenance (persist `expired=1`) and must remain idempotent.
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
_ = shutdown_rx.changed() => {
info!("Expiry checker shutting down...");
break;
}
}
let count = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = match db.users.lock() {
crate::db::content::expire_urls(&conn).unwrap_or(0) Ok(c) => c,
Err(e) => {
error!(error = %e, "expiry job: users_db mutex poisoned");
continue;
}
};
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!(error = %e, "expiry job: failed to list users");
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!(error = %e, "expiry job: failed to map user ids");
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
}; };
if count > 0 { let mut total_expired = 0;
info!(expired_count = count, "Expired URLs marked"); for user_id in user_ids {
match super::open_user_content_conn(&db, user_id) {
Ok(conn) => match crate::db::content::expire_urls(&conn) {
Ok(count) => total_expired += count,
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: expire_urls failed"
);
}
},
Err(e) => {
// Missing content.db for a user is common; only log open errors that are unexpected.
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: could not open content.db"
);
}
}
}
}
if total_expired > 0 {
info!(
expired_count = total_expired,
"Expired URLs marked across users"
);
} }
} }
} }
+53 -32
View File
@@ -8,7 +8,11 @@ use uuid::Uuid;
use super::{log_job_end, log_job_start}; use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
pub async fn run_link_checker(db: Db, interval_mins: u64) { pub async fn run_link_checker(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
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")
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
loop { loop {
// 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::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Link checker shutting down...");
break;
}
}
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");
@@ -37,40 +48,50 @@ pub async fn perform_link_check(
db: &Db, db: &Db,
client: &Client, client: &Client,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let urls = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = db.users.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)? let mut stmt = conn.prepare("SELECT id FROM users;")?;
let rows = stmt.query_map([], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
}; };
for (id, dest) in urls { for user_id in user_ids {
let (status, detail_status, status_code, latency_ms, err_msg) = let conn = match super::open_user_content_conn(db, user_id) {
check_url_health(client, &dest).await; Ok(c) => c,
{ Err(_) => continue,
let conn = db.content.lock().unwrap(); };
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks let urls = crate::db::content::list_urls_for_health_check(&conn)?;
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests for (id, dest) in urls {
tokio::time::sleep(Duration::from_millis(200)).await; let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks
{
let sys_conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = sys_conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
}
} }
Ok(()) Ok(())
} }
+36
View File
@@ -1,3 +1,4 @@
use crate::db::Db;
use chrono::Utc; use chrono::Utc;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use std::sync::Mutex; use std::sync::Mutex;
@@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Op
); );
} }
} }
pub mod quota_reconcile;
pub use quota_reconcile::run_quota_reconciliation;
// --- Database Connection Helpers for User-specific Databases ---
pub fn open_user_content_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
Ok(conn)
}
pub fn open_user_analytics_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
Ok(conn)
}
+52
View File
@@ -0,0 +1,52 @@
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(
db: Db,
interval_hours: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop {
// Sleep first
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Quota reconciliation shutting down...");
break;
}
}
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare select user IDs: {:?}", e);
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!("Failed to query user IDs: {:?}", e);
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
}
}
}
info!("Quota reconciliation finished.");
}
}
+54 -12
View File
@@ -4,7 +4,11 @@ use tracing::{error, info};
use super::{log_job_end, log_job_start}; use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
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>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let retention_days = match retention_days_opt { let retention_days = match retention_days_opt {
Some(days) => days, Some(days) => days,
None => return, None => return,
@@ -12,21 +16,59 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
loop { loop {
// Check once every 24 hours // Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Retention cleaner shutting down...");
break;
}
}
info!("Running background data retention cleanup..."); info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
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 mut total_cleaned = 0;
match crate::db::analytics::retention_cleanup(&conn, retention_days) { let mut failed = false;
Ok(count) => { let mut err_msg = None;
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None); for user_id in user_ids {
} match super::open_user_analytics_conn(&db, user_id) {
Err(e) => { Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
let err_str = e.to_string(); Ok(count) => total_cleaned += count,
error!("Error running retention cleaner: {:?}", err_str); Err(e) => {
log_job_end(&db.system, &job_id, "failed", Some(&err_str)); failed = true;
err_msg = Some(e.to_string());
}
},
Err(e) => {
failed = true;
err_msg = Some(e.to_string());
}
} }
} }
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
info!(
"Cleaned up {} expired visits across all user databases",
total_cleaned
);
log_job_end(&db.system, &job_id, "success", None);
}
} }
} }
+1
View File
@@ -1,5 +1,6 @@
pub mod analytics; pub mod analytics;
pub mod auth; pub mod auth;
pub mod build_info;
pub mod charts; pub mod charts;
pub mod cli; pub mod cli;
pub mod config; pub mod config;
+65
View File
@@ -38,12 +38,77 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Validate { data_dir } => { Commands::Validate { data_dir } => {
bzod::cli::validate::run(data_dir, config).await?; bzod::cli::validate::run(data_dir, config).await?;
} }
Commands::AuditDestinations { data_dir } => {
bzod::cli::audit_destinations::run(data_dir, config).await?;
}
Commands::CreateAdmin { username, data_dir } => { Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?; bzod::cli::create_admin::run(username, data_dir, config).await?;
} }
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?;
}
Commands::CreateUser {
username,
password,
data_dir,
} => {
bzod::cli::create_user::run(username, password, data_dir, config).await?;
}
Commands::DeleteUser {
user_id,
force,
data_dir,
} => {
bzod::cli::delete_user::run(user_id, force, data_dir, config).await?;
}
Commands::DisableUser { user_id, data_dir } => {
bzod::cli::disable_user::run(user_id, data_dir, config).await?;
}
Commands::EnableUser { user_id, data_dir } => {
bzod::cli::enable_user::run(user_id, data_dir, config).await?;
}
Commands::ResetPassword {
user_id,
password,
data_dir,
} => {
bzod::cli::reset_password::run(user_id, password, data_dir, config).await?;
}
Commands::ListUsers { data_dir } => {
bzod::cli::list_users::run(data_dir, config).await?;
}
Commands::BackupUser {
username,
out,
data_dir,
} => {
bzod::cli::backup_user::run(username, out, data_dir, config).await?;
}
Commands::RestoreUser { file, data_dir } => {
bzod::cli::restore_user::run(file, data_dir, config).await?;
}
Commands::AdminMigrate {
target_admin_id,
data_dir,
dry_run,
force,
} => {
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
.await?;
}
Commands::Repair { command } => {
bzod::cli::repair::run(command, config).await?;
}
} }
Ok(()) Ok(())
+4 -1
View File
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
pub use audit::AuditLog; pub use audit::AuditLog;
pub use page::LandingPage; pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url}; pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{Session, User}; pub use user::{
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
UserQuotas, UserSession, UsernameHistory,
};
pub use visit::{SummaryEntry, VisitRecord}; pub use visit::{SummaryEntry, VisitRecord};
+190
View File
@@ -15,3 +15,193 @@ pub struct Session {
pub expires_at: String, pub expires_at: String,
pub created_at: String, pub created_at: String,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TenantUser {
pub id: i64,
pub username: String,
pub password_hash: String,
pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted'
pub created_at: String,
pub last_login: Option<String>,
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserQuotas {
pub user_id: i64,
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub current_urls: i64,
pub current_landings: i64,
pub current_api_tokens: i64,
pub current_storage_mb: i64,
}
impl UserQuotas {
pub fn urls_pct(&self) -> f64 {
if self.max_urls <= 0 {
0.0
} else {
(self.current_urls as f64 / self.max_urls as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn landings_pct(&self) -> f64 {
if self.max_landings <= 0 {
0.0
} else {
(self.current_landings as f64 / self.max_landings as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn api_tokens_pct(&self) -> f64 {
if self.max_api_tokens <= 0 {
0.0
} else {
(self.current_api_tokens as f64 / self.max_api_tokens as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn storage_pct(&self) -> f64 {
if self.max_storage_mb <= 0 {
0.0
} else {
(self.current_storage_mb as f64 / self.max_storage_mb as f64 * 100.0).clamp(0.0, 100.0)
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserApiToken {
pub id: i64,
pub user_id: i64,
pub token_hash: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserSession {
pub id: String,
pub user_id: i64,
pub expires_at: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UsernameHistory {
pub id: i64,
pub user_id: i64,
pub old_username: String,
pub new_username: String,
pub changed_at: String,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlugStatus {
Active,
Flagged,
Disabled,
SoftDeleted,
}
impl SlugStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Flagged => "flagged",
Self::Disabled => "disabled",
Self::SoftDeleted => "soft_deleted",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"active" => Some(Self::Active),
"flagged" => Some(Self::Flagged),
"disabled" => Some(Self::Disabled),
"soft_deleted" => Some(Self::SoftDeleted),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AccountType {
System,
Admin,
Standard,
Organization,
Service,
}
impl AccountType {
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::Admin => "admin",
Self::Standard => "standard",
Self::Organization => "organization",
Self::Service => "service",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"system" => Some(Self::System),
"admin" => Some(Self::Admin),
"standard" => Some(Self::Standard),
"organization" => Some(Self::Organization),
"service" => Some(Self::Service),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModerationSeverity {
Low,
Medium,
High,
Critical,
}
impl ModerationSeverity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"critical" => Some(Self::Critical),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub enum ApiActor {
Admin(User),
User(TenantUser),
}
impl ApiActor {
pub fn username(&self) -> &str {
match self {
Self::Admin(u) => &u.username,
Self::User(u) => &u.username,
}
}
}
+1
View File
@@ -12,6 +12,7 @@ pub struct VisitRecord {
pub accept_language: String, pub accept_language: String,
pub country: String, pub country: String,
pub status_code: u16, pub status_code: u16,
pub owner_user_id: Option<i64>,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
+121
View File
@@ -0,0 +1,121 @@
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
//!
//! Extracted from admin restore handlers so path moves are testable without HTTP.
use std::path::{Path, PathBuf};
use tracing::warn;
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
///
/// Layout:
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let admin_dir = data_dir.join("admin");
let users_1_dir = data_dir.join("users").join("1");
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
let admin_files = [
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
"users.db",
"users.db-wal",
"users.db-shm",
];
for f in admin_files {
let src = data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored admin file into admin/"
);
return Err(e);
}
}
}
let content_files = [
"content.db",
"content.db-wal",
"content.db-shm",
"analytics.db",
"analytics.db-wal",
"analytics.db-shm",
];
for f in content_files {
let src = data_dir.join(f);
if src.exists() {
let dst = users_1_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored content file into users/1/"
);
return Err(e);
}
}
}
Ok(())
}
/// Paths used when reopening connections after restore.
#[derive(Debug, Clone)]
pub struct RestoredDbPaths {
pub admin: PathBuf,
pub system: PathBuf,
pub users: PathBuf,
pub content: PathBuf,
pub analytics: PathBuf,
}
impl RestoredDbPaths {
pub fn from_data_dir(data_dir: &Path) -> Self {
Self {
admin: data_dir.join("admin/admin.db"),
system: data_dir.join("admin/system.db"),
users: data_dir.join("admin/users.db"),
content: data_dir.join("users/1/content.db"),
analytics: data_dir.join("users/1/analytics.db"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn moves_flat_files_into_tenant_layout() {
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("admin.db"), b"a").unwrap();
fs::write(dir.join("system.db"), b"s").unwrap();
fs::write(dir.join("users.db"), b"u").unwrap();
fs::write(dir.join("content.db"), b"c").unwrap();
fs::write(dir.join("analytics.db"), b"an").unwrap();
normalize_restored_layout(&dir).unwrap();
assert!(dir.join("admin/admin.db").exists());
assert!(dir.join("admin/system.db").exists());
assert!(dir.join("admin/users.db").exists());
assert!(dir.join("users/1/content.db").exists());
assert!(dir.join("users/1/analytics.db").exists());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Bulk URL creation business logic (transaction + slug reservation).
//!
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::models::Url;
use crate::utils::validation::validate_redirect_destination;
use rusqlite::{Connection, Transaction};
use std::sync::Mutex;
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
#[derive(Debug, Clone)]
pub struct BulkUrlCreateItem {
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(Debug)]
pub enum BulkUrlError {
BadRequest(String),
Conflict(String),
Forbidden(String),
Internal(String),
}
impl BulkUrlError {
pub fn message(&self) -> &str {
match self {
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
}
}
}
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
for slug in slugs {
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
}
}
/// Check that the tenant can accept `additional` new URLs.
pub fn ensure_url_quota(
users_db: &Mutex<Connection>,
user_id: i64,
additional: i64,
) -> Result<(), BulkUrlError> {
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
match crate::db::users::get_user_quotas(&users_conn, user_id) {
Ok(Some(quotas)) => {
if quotas.current_urls + additional > quotas.max_urls {
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
} else {
Ok(())
}
}
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
}
}
/// Create many URLs inside a single content transaction with global slug reservation.
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
system_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
items: Vec<BulkUrlCreateItem>,
) -> Result<Vec<Url>, BulkUrlError> {
let mut conn = crate::utils::lock_db(content_db, "content_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let tx = conn.transaction().map_err(|e| {
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
})?;
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in items {
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
}
}
Ok(created_urls)
}
fn create_one_in_tx(
tx: &Transaction<'_>,
system_db: &Mutex<Connection>,
owner_user_id: i64,
item: BulkUrlCreateItem,
reserved_slugs: &mut Vec<String>,
) -> Result<Url, BulkUrlError> {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(BulkUrlError::BadRequest(format!(
"Short code '{code}' must be 6 hex characters"
)));
}
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
return Err(BulkUrlError::Conflict(format!(
"Short code '{code}' already exists"
)));
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
owner_user_id,
"url",
"",
"reserving",
) {
return Err(BulkUrlError::Internal(format!(
"Failed to reserve slug '{code}': {e}"
)));
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
return Err(BulkUrlError::Internal(format!(
"Password hashing error: {e}"
)));
}
}
} else {
None
};
if !validate_redirect_destination(&item.destination) {
return Err(BulkUrlError::BadRequest(format!(
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
)));
}
let tags = item.tags.unwrap_or_default();
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,
)
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
}
+261
View File
@@ -0,0 +1,261 @@
//! Read-only audit of stored redirect destinations.
//!
//! Scans tenant content databases and classifies each `urls.destination` using
//! the same rules as write-path validation. Never rewrites or deletes data.
use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection;
use std::path::Path;
use tracing::{error, info, warn};
/// Summary counters for a destination audit run.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DestinationAuditReport {
pub scanned_users: usize,
pub total_urls: usize,
pub valid_http: usize,
pub valid_https: usize,
pub invalid: usize,
pub control_characters: usize,
pub unsupported_scheme: usize,
pub malformed: usize,
pub empty: usize,
pub too_long: usize,
pub non_ascii: usize,
/// Safe sample of invalid records: (owner_user_id, code, class_label).
/// Destination bodies are never included (may contain control chars / secrets).
pub invalid_samples: Vec<InvalidDestinationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidDestinationSample {
pub owner_user_id: i64,
pub code: String,
pub url_id: String,
pub class: &'static str,
pub destination_len: usize,
}
const MAX_SAMPLES: usize = 50;
fn class_label(c: DestinationClass) -> &'static str {
match c {
DestinationClass::ValidHttp => "valid_http",
DestinationClass::ValidHttps => "valid_https",
DestinationClass::Empty => "empty",
DestinationClass::TooLong => "too_long",
DestinationClass::ControlCharacters => "control_characters",
DestinationClass::NonAscii => "non_ascii",
DestinationClass::UnsupportedScheme => "unsupported_scheme",
DestinationClass::Malformed => "malformed",
}
}
/// Classify a single destination and update report counters.
pub fn record_destination(
report: &mut DestinationAuditReport,
owner_user_id: i64,
code: &str,
url_id: &str,
destination: &str,
) {
report.total_urls += 1;
let class = classify_redirect_destination(destination);
match class {
DestinationClass::ValidHttp => report.valid_http += 1,
DestinationClass::ValidHttps => report.valid_https += 1,
DestinationClass::Empty => {
report.empty += 1;
report.invalid += 1;
}
DestinationClass::TooLong => {
report.too_long += 1;
report.invalid += 1;
}
DestinationClass::ControlCharacters => {
report.control_characters += 1;
report.invalid += 1;
}
DestinationClass::NonAscii => {
report.non_ascii += 1;
report.invalid += 1;
}
DestinationClass::UnsupportedScheme => {
report.unsupported_scheme += 1;
report.invalid += 1;
}
DestinationClass::Malformed => {
report.malformed += 1;
report.invalid += 1;
}
}
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
report.invalid_samples.push(InvalidDestinationSample {
owner_user_id,
code: code.to_string(),
url_id: url_id.to_string(),
class: class_label(class),
destination_len: destination.len(),
});
}
}
/// Scan one content database connection for URL destinations.
pub fn audit_content_conn(
conn: &Connection,
owner_user_id: i64,
report: &mut DestinationAuditReport,
) -> rusqlite::Result<()> {
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, code, destination) = row?;
record_destination(report, owner_user_id, &code, &id, &destination);
}
Ok(())
}
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
let path = data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn)
}
/// Audit all tenant content databases found under the configured data directory.
///
/// Read-only: does not modify any records.
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
let mut report = DestinationAuditReport::default();
let user_ids: Vec<i64> = {
let users = db
.users
.lock()
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
let mut stmt = users
.prepare("SELECT id FROM users;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for user_id in user_ids {
match open_user_content(&db.data_dir, user_id) {
Ok(conn) => {
report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
error!(
owner_user_id = user_id,
error = %e,
"destination audit failed for user content.db"
);
return Err(format!("audit user {} content.db: {}", user_id, e));
}
}
Err(rusqlite::Error::InvalidPath(_)) => {
// User has no content DB yet — skip.
}
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"could not open user content.db for destination audit"
);
}
}
}
info!(
total_urls = report.total_urls,
valid = report.valid_http + report.valid_https,
invalid = report.invalid,
"destination audit complete"
);
Ok(report)
}
/// Format a human-readable report for CLI output.
pub fn format_report(report: &DestinationAuditReport) -> String {
let mut out = String::new();
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
out.push_str("===========================================\n");
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
out.push_str(&format!(
" control characters: {}\n",
report.control_characters
));
out.push_str(&format!(
" unsupported scheme: {}\n",
report.unsupported_scheme
));
out.push_str(&format!(" malformed: {}\n", report.malformed));
out.push_str(&format!(" empty: {}\n", report.empty));
out.push_str(&format!(" too long: {}\n", report.too_long));
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
if !report.invalid_samples.is_empty() {
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
for s in &report.invalid_samples {
out.push_str(&format!(
" user={} code={} id={} class={} dest_len={}\n",
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_control_character_destination() {
let mut report = DestinationAuditReport::default();
record_destination(
&mut report,
1,
"ab12cd",
"id-1",
"https://evil.example/\r\nX:1",
);
assert_eq!(report.total_urls, 1);
assert_eq!(report.invalid, 1);
assert_eq!(report.control_characters, 1);
assert_eq!(report.invalid_samples.len(), 1);
assert_eq!(report.invalid_samples[0].class, "control_characters");
// Ensure we never store the destination body in the sample.
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
}
#[test]
fn records_valid_https() {
let mut report = DestinationAuditReport::default();
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 0);
assert!(report.invalid_samples.is_empty());
}
}
+6
View File
@@ -1,6 +1,12 @@
pub mod api_keys; pub mod api_keys;
pub mod audit; pub mod audit;
pub mod backup_layout;
pub mod bulk; pub mod bulk;
pub mod bulk_urls;
pub mod destination_audit;
pub mod landing_pages; pub mod landing_pages;
pub mod qr; pub mod qr;
pub mod registry_validator;
pub mod shortener; pub mod shortener;
pub mod slug_transfer;
pub mod urls;
+285
View File
@@ -0,0 +1,285 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum RegistryIssueType {
DuplicateSlug,
InvalidTargetType,
InvalidStatus,
MissingOwner,
MissingDatabase,
MissingTarget,
StaleReservation,
TenantAdminHasIsolatedContent,
}
#[derive(Debug, Clone)]
pub struct RegistryIssue {
pub slug: String,
pub target_type: String,
pub owner_user_id: i64,
pub database_path: PathBuf,
pub target_id: String,
pub issue_type: RegistryIssueType,
pub description: String,
}
pub struct RegistryValidator;
impl RegistryValidator {
/// Scans the global_slugs registry and returns a list of detected issues.
pub fn scan(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &Path,
slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut issues = Vec::new();
// 1. Check duplicate slugs (only if not filtering by single slug)
if slug_filter.is_none() {
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 = system_conn.query_row(
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
[],
|r| r.get(0),
)?;
if total_count != distinct_count {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: 0,
database_path: data_dir.join("admin/system.db"),
target_id: "".to_string(),
issue_type: RegistryIssueType::DuplicateSlug,
description: format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
),
});
}
}
// 2. Scan global slugs
let (query, params_string) = if let Some(slug) = slug_filter {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
vec![slug.to_string()],
)
} else {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
vec![],
)
};
let mut stmt = system_conn.prepare(query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
let content_db_path = if owner_user_id == 1 {
data_dir.join("users").join("1").join("content.db")
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
// Target type check
if target_type != "url" && target_type != "page" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidTargetType,
description: format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
),
});
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status),
});
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingOwner,
description: format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
),
});
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::StaleReservation,
description: format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
),
});
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
if !content_db_path.exists() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
),
});
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTarget,
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
});
}
}
Err(e) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
),
});
}
}
}
}
}
// 3. Admin Content Reverse Consistency Check (Legacy DB)
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
if slug_filter.is_none() {
let mut stmt = users_conn.prepare(
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
)?;
let mut admin_rows = stmt.query([])?;
while let Some(row) = admin_rows.next()? {
let id: i64 = row.get(0)?;
let username: String = row.get(1)?;
let tenant_db_path = data_dir
.join("users")
.join(id.to_string())
.join("content.db");
if tenant_db_path.exists() {
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
let url_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
.unwrap_or(0);
let page_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
.unwrap_or(0);
if url_count > 0 || page_count > 0 {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: id,
database_path: tenant_db_path.clone(),
target_id: "".to_string(),
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
});
}
}
}
}
}
Ok(issues)
}
}
+13 -2
View File
@@ -10,13 +10,24 @@ pub fn create_url(
description: Option<&str>, description: Option<&str>,
tags: &[String], tags: &[String],
) -> Result<Url, AppError> { ) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap(); if !crate::utils::validation::validate_redirect_destination(destination) {
return Err(AppError::BadRequest(
"Destination must be a valid http(s) URL without control characters".into(),
));
}
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?; let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url) Ok(url)
} }
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> { pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap(); let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::get_url_by_code(&conn, code)?; let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url) Ok(url)
} }
+243
View File
@@ -0,0 +1,243 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates global_slugs
//! ownership. Handlers own admin auth and HTTP mapping.
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
use rusqlite::OptionalExtension;
#[derive(Debug)]
pub enum TransferError {
NotFound(&'static str),
BadRequest(String),
Internal(String),
}
impl TransferError {
pub fn message(&self) -> String {
match self {
Self::NotFound(m) => (*m).to_string(),
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct SlugTransferRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Debug)]
pub struct SlugTransferResult {
pub old_owner_user_id: i64,
pub new_owner_user_id: i64,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in `global_slugs`.
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let mut stmt = system_conn
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let row_opt = stmt
.query_row([slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
.map_err(|e| TransferError::Internal(e.to_string()))?;
match row_opt {
Some(r) => Ok(r),
None => Err(TransferError::NotFound("Slug not found")),
}
}
/// Copy content row between tenants and return the new target id.
fn copy_content(
state: &AppState,
old_dbs: &UserDbs,
new_dbs: &UserDbs,
slug: &str,
target_type: &str,
new_owner_user_id: i64,
) -> Result<String, TransferError> {
let old_conn = lock_db(&old_dbs.content, "old_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let new_conn = lock_db(&new_dbs.content, "new_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if target_type == "url" {
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
Ok(Some(u)) => u,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_urls >= quota.max_urls {
return Err(TransferError::BadRequest(
"New owner has exceeded URL quota limit".into(),
));
}
}
}
let new_url = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
let _ = crate::db::content::delete_url(&old_conn, &url.id);
Ok(new_url.id)
} else if target_type == "page" {
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
Ok(Some(p)) => p,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_landings >= quota.max_landings {
return Err(TransferError::BadRequest(
"New owner has exceeded landing page quota limit".into(),
));
}
}
}
let new_page = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
Ok(new_page.id)
} else {
Err(TransferError::NotFound(
"Content not found in owner database",
))
}
}
/// Perform a full slug transfer (content + registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
if old_owner_user_id == req.new_owner_user_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.get_user_dbs(old_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.get_user_dbs(req.new_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
let new_target_id = copy_content(
state,
&old_dbs,
&new_dbs,
&req.slug,
&target_type,
req.new_owner_user_id,
)?;
{
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![
req.slug,
old_owner_user_id,
req.new_owner_user_id,
now,
admin_username
],
);
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ =
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"SLUG_TRANSFER",
"slug",
&req.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
target_type,
new_target_id,
})
}
+117
View File
@@ -0,0 +1,117 @@
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
//!
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
//! destination preparation that must stay consistent across entry points.
use crate::utils::validation::validate_redirect_destination;
/// Optional UTM parameters applied to a destination URL.
#[derive(Debug, Default, Clone)]
pub struct UtmParams<'a> {
pub source: Option<&'a str>,
pub medium: Option<&'a str>,
pub campaign: Option<&'a str>,
}
/// Normalize and optionally append UTM parameters to a destination.
///
/// Returns `Err` when the base destination fails canonical validation.
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
let mut dest = raw.trim().to_string();
if !validate_redirect_destination(&dest) {
return Err("Destination must be a valid http(s) URL without control characters");
}
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(src) = utm.source {
let src = src.trim();
if !src.is_empty() {
query.append_pair("utm_source", src);
has_utm = true;
}
}
if let Some(med) = utm.medium {
let med = med.trim();
if !med.is_empty() {
query.append_pair("utm_medium", med);
has_utm = true;
}
}
if let Some(camp) = utm.campaign {
let camp = camp.trim();
if !camp.is_empty() {
query.append_pair("utm_campaign", camp);
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
Ok(dest)
}
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut rfc = trimmed.to_string();
if rfc.len() == 16 {
// HTML datetime-local → assume UTC seconds
rfc.push_str(":00Z");
}
Some(rfc)
}
/// Parse optional max-access-count form field.
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_rejects_crlf() {
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
assert!(err.contains("valid http"));
}
#[test]
fn prepare_appends_utm() {
let dest = prepare_destination(
"https://example.com/path",
UtmParams {
source: Some("newsletter"),
medium: Some("email"),
campaign: Some("spring"),
},
)
.unwrap();
assert!(dest.contains("utm_source=newsletter"));
assert!(dest.contains("utm_medium=email"));
assert!(dest.contains("utm_campaign=spring"));
}
#[test]
fn parse_expires_datetime_local() {
assert_eq!(
parse_expires_at_input("2030-01-01T12:00"),
Some("2030-01-01T12:00:00Z".to_string())
);
assert_eq!(parse_expires_at_input(" "), None);
}
}
+75 -5
View File
@@ -2,15 +2,25 @@ use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use rusqlite::Connection; use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>, pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>, pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>, pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>, pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
pub db: Db, pub db: Db,
pub config: Config, pub config: Config,
pub analytics_queue: AnalyticsQueue, pub analytics_queue: AnalyticsQueue,
@@ -18,11 +28,71 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn db_compact(&self) -> Result<(), rusqlite::Error> { pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?; let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?; if let Some(dbs) = pool.get(&user_id) {
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; return Ok(dbs.clone());
self.system_db.lock().unwrap().execute("VACUUM;", [])?; }
// Open connection and run migrations
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
std::fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
// Run migrations
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
let dbs = UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
};
pool.insert(user_id, dbs.clone());
Ok(dbs)
}
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
Ok(()) Ok(())
} }
} }
+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>,
pub is_admin: bool,
}
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>,
pub is_admin: bool,
}
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(),
}
}
}
+301 -6
View File
@@ -1,26 +1,41 @@
pub mod analytics;
pub mod dashboard; pub mod dashboard;
pub mod pages; pub mod pages;
pub mod settings; pub mod settings;
pub mod stats; pub mod stats;
pub mod urls; pub mod urls;
pub mod user_dashboard;
pub mod user_pages;
pub mod user_settings;
pub mod user_urls;
pub mod users;
pub use dashboard::DashboardTemplate; pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate};
pub use urls::UrlsTemplate;
use askama::Template; use askama::Template;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
pub use dashboard::DashboardTemplate;
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate};
pub use urls::UrlsTemplate;
pub use user_dashboard::UserDashboardTemplate;
pub use user_pages::UserPagesTemplate;
pub use user_settings::UserSettingsTemplate;
pub use user_urls::UserUrlsTemplate;
pub use users::UsersTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
pub struct LoginTemplate { pub struct LoginTemplate {
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String, pub csrf_token: String,
pub action: String,
pub title: String,
pub subtitle: String,
pub button_text: String,
} }
impl IntoResponse for LoginTemplate { impl IntoResponse for LoginTemplate {
@@ -79,3 +94,283 @@ impl IntoResponse for PreviewTemplate {
} }
} }
} }
#[derive(Template)]
#[template(path = "users_new.html")]
pub struct UsersNewTemplate {
pub admin_username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersNewTemplate {
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 = "user_detail.html")]
pub struct UserDetailTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub stats: crate::web::admin::UserDetailStats,
pub sessions: Vec<crate::models::UserSession>,
pub tokens: Vec<crate::models::UserApiToken>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserDetailTemplate {
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 = "user_edit.html")]
pub struct UserEditTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub quotas: crate::models::UserQuotas,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserEditTemplate {
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 = "moderation.html")]
pub struct ModerationTemplate {
pub admin_username: String,
pub flagged_items: Vec<crate::web::admin::GlobalSlugRow>,
pub logs: Vec<crate::web::admin::ModerationLogEntry>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ModerationTemplate {
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 = "slugs.html")]
pub struct SlugsTemplate {
pub admin_username: String,
pub slugs: Vec<crate::web::admin::GlobalSlugRow>,
pub history: Vec<crate::web::admin::SlugHistoryRow>,
pub csrf_token: String,
pub search_filter: Option<String>,
pub owner_filter: Option<i64>,
pub status_filter: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SlugsTemplate {
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 = "sessions.html")]
pub struct SessionsTemplate {
pub admin_username: String,
pub sessions: Vec<crate::models::UserSession>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SessionsTemplate {
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 = "quotas.html")]
pub struct QuotasTemplate {
pub admin_username: String,
pub quotas: Vec<crate::models::UserQuotas>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for QuotasTemplate {
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 = "health.html")]
pub struct HealthTemplate {
pub admin_username: String,
pub db_reports: Vec<crate::db::sqlite::DatabaseHealthReport>,
pub total_data_size: String,
pub system_db_size: String,
pub users_db_size: String,
pub admin_db_size: String,
pub tenants_db_size: String,
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub registry_errors: Vec<String>,
pub registry_warnings: Vec<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for HealthTemplate {
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 = "backups.html")]
pub struct BackupsTemplate {
pub admin_username: String,
pub files: Vec<crate::web::admin::BackupFileRow>,
pub history: Vec<crate::web::admin::BackupHistoryRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for BackupsTemplate {
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 = "api_tokens.html")]
pub struct ApiTokensTemplate {
pub admin_username: String,
pub username: String,
pub tokens: Vec<crate::models::UserApiToken>,
pub new_token: Option<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ApiTokensTemplate {
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 = "user_analytics.html")]
pub struct UserAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub total_clicks: i64,
pub unique_visitors: i64,
pub direct_clicks: i64,
pub referred_clicks: i64,
pub referrers_chart: String,
pub browsers_chart: String,
pub visits: Vec<crate::models::VisitRecord>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserAnalyticsTemplate {
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(),
}
}
}
+9
View File
@@ -12,6 +12,15 @@ 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 {
+47
View File
@@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate {
} }
} }
} }
#[derive(Template)]
#[template(path = "user_status.html")]
pub struct UserStatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for UserStatusTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
impl IntoResponse for UserAuditTemplate {
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(),
}
}
}
+9
View File
@@ -14,6 +14,15 @@ pub struct UrlsTemplate {
pub error: Option<String>, pub error: Option<String>,
pub tag_filter: Option<String>, pub tag_filter: Option<String>,
pub base_url: 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 {
+33
View File
@@ -0,0 +1,33 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_dashboard.html")]
pub struct UserDashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for UserDashboardTemplate {
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(),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_pages.html")]
pub struct UserPagesTemplate {
pub admin_username: String,
pub username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserPagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserPagesTemplate {
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(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_settings.html")]
pub struct UserSettingsTemplate {
pub admin_username: String,
pub username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserSettingsTemplate {
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(),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::models::Url;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_urls.html")]
pub struct UserUrlsTemplate {
pub admin_username: String,
pub username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: 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 UserUrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserUrlsTemplate {
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(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use crate::models::TenantUser;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate {
pub admin_username: String,
pub users: Vec<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections.
//!
//! Prefer these on request paths so a poisoned mutex returns a controlled error
//! instead of panicking the worker thread.
use crate::error::AppError;
use std::sync::{Mutex, MutexGuard};
use tracing::error;
/// Acquire a database mutex, mapping poison to [`AppError::Internal`].
///
/// Logs the mutex name (not connection contents or secrets).
pub fn lock_db<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, AppError> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
AppError::Internal(format!("{name} mutex poisoned"))
})
}
/// Acquire a database mutex, mapping poison to a plain error string.
///
/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`.
pub fn lock_db_str<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, String> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
format!("{name} mutex poisoned")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn lock_db_succeeds_on_healthy_mutex() {
let m = Mutex::new(42);
let g = lock_db(&m, "test").unwrap();
assert_eq!(*g, 42);
}
#[test]
fn lock_db_maps_poison() {
let m = Mutex::new(1);
let _ = std::panic::catch_unwind(|| {
let _g = m.lock().unwrap();
panic!("poison");
});
let err = lock_db(&m, "poisoned_db").unwrap_err();
match err {
AppError::Internal(msg) => assert!(msg.contains("poisoned_db")),
other => panic!("unexpected {other:?}"),
}
}
}
+4 -1
View File
@@ -1,11 +1,14 @@
pub mod db_lock;
pub mod hashing; pub mod hashing;
pub mod network; pub mod network;
pub mod random; pub mod random;
pub mod system; pub mod system;
pub mod time; pub mod time;
pub mod validation;
pub use db_lock::{lock_db, lock_db_str};
pub use hashing::sha256_hash; pub use hashing::sha256_hash;
pub use network::get_client_ip; pub use network::{get_client_ip, resolve_cookie_secure};
pub use random::generate_token; pub use random::generate_token;
pub use system::{get_db_file_info, get_memory_usage}; pub use system::{get_db_file_info, get_memory_usage};
pub use time::format_duration; pub use time::format_duration;
+116
View File
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
} }
"127.0.0.1".to_string() "127.0.0.1".to_string()
} }
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
pub(crate) fn extract_hostname(host_header: &str) -> &str {
if host_header.starts_with('[') {
if let Some(end_idx) = host_header.find(']') {
return &host_header[1..end_idx];
}
}
host_header.split(':').next().unwrap_or(host_header)
}
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
///
/// Policy:
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
/// the cookie during local development over cleartext HTTP.
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
// 1. Explicit HTTPS via reverse proxy
if let Some(proto) = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
if proto.eq_ignore_ascii_case("https") {
return true;
}
}
// 2. Exact loopback development (prevent cookie drop)
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
let hostname = extract_hostname(host_hdr);
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
return false;
}
}
// 3. Global config (normally true)
config_secure
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn test_extract_hostname() {
assert_eq!(extract_hostname("localhost"), "localhost");
assert_eq!(extract_hostname("localhost:8080"), "localhost");
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
assert_eq!(extract_hostname("[::1]"), "::1");
assert_eq!(extract_hostname("[::1]:8080"), "::1");
assert_eq!(extract_hostname("example.com"), "example.com");
assert_eq!(extract_hostname("example.com:443"), "example.com");
assert_eq!(
extract_hostname("localhost.example.com"),
"localhost.example.com"
);
}
#[test]
fn test_resolve_cookie_secure() {
let mut headers = HeaderMap::new();
// No headers, global config is true -> Secure=true
assert!(resolve_cookie_secure(true, &headers));
// No headers, global config is false -> Secure=false
assert!(!resolve_cookie_secure(false, &headers));
// Exact loopback matches -> Secure=false
let loopback_hosts = [
"localhost",
"localhost:8080",
"127.0.0.1",
"127.0.0.1:8080",
"[::1]",
"[::1]:8080",
];
for host in loopback_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
!resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// Non-loopback localhost subdomains -> Secure=true (relying on config)
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
for host in public_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// X-Forwarded-Proto = https overrides loopback
headers.insert("host", HeaderValue::from_static("localhost"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(resolve_cookie_secure(true, &headers));
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
// X-Forwarded-Proto = http DOES NOT override global config
headers.insert("host", HeaderValue::from_static("example.com"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
}
}
+5 -4
View File
@@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String {
pub fn get_db_file_info(data_dir: &Path) -> String { pub fn get_db_file_info(data_dir: &Path) -> String {
let mut stats = String::new(); let mut stats = String::new();
let files = vec![ let files = vec![
("admin.db", "Admin DB"), ("admin/admin.db", "Admin DB"),
("content.db", "Content DB"), ("admin/system.db", "System DB"),
("analytics.db", "Analytics DB"), ("admin/users.db", "Users DB"),
("system.db", "System DB"), ("users/1/content.db", "Legacy Content DB"),
("users/1/analytics.db", "Legacy Analytics DB"),
]; ];
for (f, name) in files { for (f, name) in files {
+160
View File
@@ -0,0 +1,160 @@
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)
}
/// Classification of a stored or proposed redirect destination.
///
/// Used by write-path validation and read-only legacy data audits. Order of checks
/// matches `validate_redirect_destination` so both share the same rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DestinationClass {
ValidHttp,
ValidHttps,
Empty,
TooLong,
ControlCharacters,
NonAscii,
UnsupportedScheme,
Malformed,
}
impl DestinationClass {
pub fn is_valid(self) -> bool {
matches!(self, Self::ValidHttp | Self::ValidHttps)
}
}
fn scheme_prefix(dest: &str) -> Option<&str> {
let end = dest.find(':')?;
let scheme = &dest[..end];
if scheme.is_empty() {
return None;
}
if scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
{
Some(scheme)
} else {
None
}
}
/// Classify a destination using the same rules as write-path validation.
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
let dest = destination.trim();
if dest.is_empty() {
return DestinationClass::Empty;
}
if dest.len() > 2048 {
return DestinationClass::TooLong;
}
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
return DestinationClass::ControlCharacters;
}
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
if !dest.is_ascii() {
return DestinationClass::NonAscii;
}
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
if let Some(scheme) = scheme_prefix(dest) {
let scheme_l = scheme.to_ascii_lowercase();
if scheme_l != "http" && scheme_l != "https" {
return DestinationClass::UnsupportedScheme;
}
}
match reqwest::Url::parse(dest) {
Ok(url) => {
if url.host_str().is_none() {
return DestinationClass::Malformed;
}
match url.scheme() {
"http" => DestinationClass::ValidHttp,
"https" => DestinationClass::ValidHttps,
_ => DestinationClass::UnsupportedScheme,
}
}
Err(_) => DestinationClass::Malformed,
}
}
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
///
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
/// values already exist in older data.
pub fn validate_redirect_destination(destination: &str) -> bool {
classify_redirect_destination(destination).is_valid()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_crlf_destination() {
assert!(!validate_redirect_destination(
"https://example.com/\r\nX-Injected: 1"
));
assert!(!validate_redirect_destination(
"https://example.com/\nX-Injected: 1"
));
}
#[test]
fn rejects_non_http_schemes() {
assert!(!validate_redirect_destination("javascript:alert(1)"));
assert!(!validate_redirect_destination("data:text/html,hi"));
assert!(!validate_redirect_destination("/relative/path"));
}
#[test]
fn accepts_normal_https() {
assert!(validate_redirect_destination(
"https://example.com/path?q=1#frag"
));
assert!(validate_redirect_destination("http://localhost:8080/x"));
}
#[test]
fn classifies_destination_categories() {
assert_eq!(
classify_redirect_destination("https://ok.example/"),
DestinationClass::ValidHttps
);
assert_eq!(
classify_redirect_destination("http://ok.example/"),
DestinationClass::ValidHttp
);
assert_eq!(
classify_redirect_destination("javascript:alert(1)"),
DestinationClass::UnsupportedScheme
);
assert_eq!(
classify_redirect_destination("https://x/\r\nX:1"),
DestinationClass::ControlCharacters
);
assert_eq!(
classify_redirect_destination("not a url"),
DestinationClass::Malformed
);
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
}
}
-1303
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+236
View File
@@ -0,0 +1,236 @@
use super::*;
#[derive(Deserialize)]
pub struct CreateApiKeyForm {
pub key_name: String,
pub csrf_token: String,
}
// POST /admin/settings/api-keys/create
pub async fn create_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateApiKeyForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let key_secret = format!("bzo_{}", generate_token(16));
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
let conn = state.admin_db.lock().unwrap();
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
Ok(api_key) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_CREATED",
Some("api_key"),
Some(&api_key.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to(&format!(
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
key_secret
)).into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/settings/api-keys/revoke/:id
pub async fn revoke_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.admin_db.lock().unwrap();
match delete_api_key(&conn, &id) {
Ok(_) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_REVOKED",
Some("api_key"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=API Token revoked").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/settings?error=Failed to revoke key: {}",
e
))
.into_response(),
}
}
// GET /api-tokens
pub async fn api_tokens_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let tokens = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
)
.unwrap();
let rows = stmt
.query_map([user.id], |row| {
Ok(crate::models::UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ApiTokensTemplate {
admin_username: user.username.clone(),
username: user.username,
tokens,
new_token: params.get("new_token").cloned(),
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /api-tokens/create
pub async fn api_tokens_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
use sha2::Digest;
let raw_token = format!("key_{}", generate_token(32));
let mut hasher = sha2::Sha256::new();
hasher.update(raw_token.as_bytes());
let hashed_token = hex::encode(hasher.finalize());
{
let conn = state.users_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
rusqlite::params![user.id, hashed_token, now],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_CREATED",
"api_token",
"new",
None,
);
}
Redirect::to(&format!(
"/api-tokens?new_token={}&success=Token generated successfully",
raw_token
))
.into_response()
}
// POST /api-tokens/revoke/:id
pub async fn api_tokens_revoke_post(
State(state): State<AppState>,
jar: CookieJar,
Path(token_id): Path<i64>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
[token_id, user.id],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_REVOKED",
"api_token",
&token_id.to_string(),
None,
);
}
Redirect::to("/api-tokens?success=API token revoked").into_response()
}
+126
View File
@@ -0,0 +1,126 @@
use super::*;
// GET /admin/audit
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::AuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
// GET /user/audit
pub async fn user_audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events =
crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::UserAuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
+517
View File
@@ -0,0 +1,517 @@
use super::*;
// GET /admin
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
match require_auth(&state, &jar).await {
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
Err(redir) => redir.into_response(),
}
}
// GET /admin/login
pub async fn login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/admin/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/admin/login".to_string(),
title: "Admin Login".to_string(),
subtitle: "Administrative Access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
#[derive(Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
pub csrf_token: String,
}
/// Bootstrap is allowed only when the system has no real admin yet.
pub(crate) fn is_bootstrap_allowed(
user_count: i64,
admin_count: i64,
active_session_count: i64,
) -> bool {
user_count <= 1 && admin_count == 0 && active_session_count == 0
}
fn count_login_bootstrap_state(
conn: &rusqlite::Connection,
) -> Result<(i64, i64, i64), rusqlite::Error> {
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
let a_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|r| r.get(0),
)?;
let now = Utc::now().to_rfc3339();
let s_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
[now],
|r| r.get(0),
)?;
Ok((u_count, a_count, s_count))
}
/// Verify an existing admin tenant user may log into the admin UI.
///
/// Does not log the password. Rejection reasons are structured for observability.
pub(crate) fn verify_admin_credentials(
user: &crate::models::TenantUser,
password: &str,
) -> Result<(), &'static str> {
if user.status != "active" {
return Err("account_disabled");
}
if user.account_type != "admin" {
return Err("insufficient_privileges");
}
if !verify_password(password, &user.password_hash) {
return Err("invalid_credentials");
}
Ok(())
}
fn load_tenant_user_by_username(
conn: &rusqlite::Connection,
username: &str,
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
[username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
}
}
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
)
}
// POST /admin/login
pub async fn login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let bootstrap_allowed = {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match count_login_bootstrap_state(&conn) {
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
Err(e) => {
tracing::error!(error = %e, "login bootstrap state query failed");
false
}
}
};
let user_opt = if bootstrap_allowed
&& form.username == state.config.admin_username
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
{
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => {
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
}
};
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
Ok(u) => {
if let Err(e) = state.db.init_user_databases(u.id) {
tracing::error!(
"Failed to init user databases during admin bootstrap: {:?}",
e
);
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&u.username,
"BOOTSTRAP_USER_PROVISIONED",
"user",
&u.id.to_string(),
Some(&metadata),
);
}
Some(User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
})
}
Err(e) => {
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
None
}
}
} else {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match load_tenant_user_by_username(&conn, &form.username) {
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
Ok(()) => Some(tenant_user_to_admin_user(u)),
Err(reason) => {
tracing::warn!(username = form.username, reason, "login rejected");
None
}
},
Ok(None) => {
tracing::warn!(
username = form.username,
reason = "user_not_found",
"login rejected"
);
None
}
Err(e) => {
tracing::error!(error = %e, "login user lookup failed");
None
}
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
let now = Utc::now().to_rfc3339();
if let Err(e) = conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![session_token, user_id_i64, expires, now],
) {
tracing::error!(error = %e, "failed to insert admin session");
return Redirect::to("/admin/login?error=Internal error").into_response();
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/admin/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response()
}
None => {
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
}
Redirect::to("/admin/login?error=Invalid username or password").into_response()
}
}
}
// GET /logout
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
let cookie = Cookie::build("bzod_user_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/login")).into_response()
}
// GET /login
pub async fn public_login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/login".to_string(),
title: "User Login".to_string(),
subtitle: "Standard account access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
// POST /login
pub async fn public_login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let user_opt: Option<crate::models::TenantUser> = {
let conn = state.users_db.lock().unwrap();
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE username = ?1;",
[&form.username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
}
).optional();
match user_res {
Ok(Some(u)) => {
if u.status != "active" {
None
} else if verify_password(&form.password, &u.password_hash) {
Some(u)
} else {
None
}
}
_ => None,
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_user_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/user/dashboard")).into_response()
}
None => {
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
Redirect::to("/login?error=Invalid username or password").into_response()
}
}
}
// GET /admin/logout
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
}
let cookie = Cookie::build("bzod_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/admin/login")).into_response()
}
#[cfg(test)]
mod login_helpers_tests {
use super::is_bootstrap_allowed;
#[test]
fn bootstrap_only_when_no_admin() {
assert!(is_bootstrap_allowed(0, 0, 0));
assert!(is_bootstrap_allowed(1, 0, 0));
assert!(!is_bootstrap_allowed(2, 0, 0));
assert!(!is_bootstrap_allowed(1, 1, 0));
assert!(!is_bootstrap_allowed(1, 0, 1));
}
}
+300
View File
@@ -0,0 +1,300 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupFileRow {
pub filename: String,
pub size_str: String,
pub created_str: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupHistoryRow {
pub id: String,
pub backup_path: String,
pub status: String,
pub created_at: String,
pub size_bytes: i64,
pub error_message: Option<String>,
}
// GET /admin/backups
pub async fn backups_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut files = vec![];
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
for entry in dir_entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(filename) = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
{
if filename.ends_with(".tar.gz") {
let meta = entry.metadata().unwrap();
let size_str = format_size(meta.len());
let created_str = meta
.created()
.ok()
.map(|c| {
let datetime: chrono::DateTime<chrono::Utc> = c.into();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "-".to_string());
files.push(BackupFileRow {
filename,
size_str,
created_str,
});
}
}
}
}
}
files.sort_by(|a, b| b.filename.cmp(&a.filename));
let history = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(BackupHistoryRow {
id: row.get(0)?,
backup_path: row.get(1)?,
status: row.get(2)?,
created_at: row.get(3)?,
size_bytes: row.get(4)?,
error_message: row.get(5)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::BackupsTemplate {
admin_username: user.username,
files,
history,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/backups/create
pub async fn backups_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (_user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
Ok(path) => {
let filename = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("backup.tar.gz");
Redirect::to(&format!(
"/admin/backups?success=Backup created successfully: {}",
filename
))
.into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to generate backup: {}",
e
))
.into_response(),
}
}
// GET /admin/backups/download/:filename
pub async fn backups_download_get(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
) -> Response {
if require_auth(&state, &jar).await.is_err() {
return StatusCode::UNAUTHORIZED.into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return StatusCode::NOT_FOUND.into_response();
}
match std::fs::read(&backup_path) {
Ok(bytes) => {
let body = axum::body::Body::from(bytes);
Response::builder()
.header("content-type", "application/octet-stream")
.header(
"content-disposition",
format!("attachment; filename=\"{}\"", filename),
)
.body(body)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
// POST /admin/backups/delete/:filename
pub async fn backups_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
}
match std::fs::remove_file(&backup_path) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"BACKUP_DELETE",
"backup",
&filename,
None,
);
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to delete backup file: {}",
e
))
.into_response(),
}
}
// POST /admin/backups/restore
pub async fn backups_restore_post(
State(state): State<AppState>,
jar: CookieJar,
mut multipart: axum::extract::Multipart,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut backup_bytes = vec![];
let mut confirm_text = String::new();
let mut csrf_token = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or_default().to_string();
if name == "backup_file" {
if let Ok(bytes) = field.bytes().await {
backup_bytes = bytes.to_vec();
}
} else if name == "confirm_text" {
if let Ok(text) = field.text().await {
confirm_text = text.trim().to_string();
}
} else if name == "csrf_token" {
if let Ok(text) = field.text().await {
csrf_token = text.trim().to_string();
}
}
}
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if confirm_text != "RESTORE" {
return Redirect::to(
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
)
.into_response();
}
if backup_bytes.is_empty() {
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
.into_response();
}
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
return Redirect::to(&format!(
"/admin/backups?error=Failed to save uploaded file: {}",
e
))
.into_response();
}
match crate::cli::restore::run(
temp_file_path.to_string_lossy().to_string(),
None,
state.config.clone(),
)
.await
{
Ok(_) => {
let _ = std::fs::remove_file(&temp_file_path);
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions;", []);
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"RESTORE_EXECUTION",
"backup",
"upload",
None,
);
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
.into_response()
}
Err(e) => {
let _ = std::fs::remove_file(&temp_file_path);
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
}
}
}
Loaded 100 of 185 files, more files were not shown because too many files have changed in this diff. Show more