Author SHA1 Message Date
thakares fb5d827322 chore: ignore local backups
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 20:54:05 +05:30
thakares feed352c2e fix: only initialize admin explicitly 2026-08-27 19:27:24 +05:30
thakares e42d1a5d80 fix: standardize deployment data directory and CI linting
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:41:24 +05:30
thakares c2e138f823 refactor(config): require explicit BZOD data directory
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:06:05 +05:30
thakares d398341f01 release: finalize BZOD v0.8.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-21 16:23:32 +05:30
thakares d7e0ac7679 refactor: complete v0.8 core architecture 2026-08-21 13:56:36 +05:30
thakares f138a645f8 fix: configure production base URL
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-13 12:23:39 +05:30
thakares ac66945c93 docs: refresh v0.7.1 UI screenshots
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 20:02:40 +05:30
thakares 763a17f8dc fix: update CasaOS production deployment
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 19:08:06 +05:30
thakares 8e0bcbe580 feat: add Docker first-start admin bootstrap
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 14:13:09 +05:30
thakares 25577b4b83 feat: finalize v0.7.0 Docker deployment and image routing
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 13:02:44 +05:30
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
219 changed files with 48427 additions and 3607 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/
+6 -4
View File
@@ -1,17 +1,19 @@
# BZOD Platform Configuration
HOST=0.0.0.0
PORT=8080
DATA_DIR=./data
# Physical BZOD data root.
# REQUIRED: Set this explicitly; there is no implicit ./data fallback.
NX9_BZOD_DATA_DIR=/var/lib/bzod/data
# Security Settings
COOKIE_SECURE=false
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
# Bootstrap Admin Credentials
# Default username: admin
# Default password: admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
# REQUIRED for a fresh deployment.
# Use a strong unique password.
ADMIN_PASSWORD=
# Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60
+80 -17
View File
@@ -1,22 +1,85 @@
name: Rust
name: Rust CI
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
env:
CARGO_TERM_COLOR: always
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
jobs:
test:
name: Test & Quality Checks
runs-on: ubuntu-latest
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@v4
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Install Rust Toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- 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
+5 -1
View File
@@ -1,4 +1,4 @@
/target
/target/
data/
*.db
*.db-wal
@@ -6,3 +6,7 @@ data/
.env
.idea/
bzod.env
# Local database backups
backups/
Generated
+78 -536
View File
@@ -29,24 +29,6 @@ dependencies = [
"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]]
name = "android_system_properties"
version = "0.1.5"
@@ -121,17 +103,6 @@ dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "argon2"
version = "0.5.3"
@@ -144,21 +115,6 @@ dependencies = [
"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]]
name = "askama"
version = "0.12.1"
@@ -200,7 +156,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"nom 7.1.3",
"nom",
]
[[package]]
@@ -226,49 +182,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "axum"
version = "0.7.9"
@@ -289,6 +202,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@@ -381,27 +295,12 @@ dependencies = [
"serde",
]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "blake2"
version = "0.10.6"
@@ -420,12 +319,6 @@ dependencies = [
"generic-array",
]
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]]
name = "bumpalo"
version = "3.20.3"
@@ -452,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.1.0"
version = "0.8.0"
dependencies = [
"argon2",
"askama",
@@ -462,6 +355,7 @@ dependencies = [
"clap",
"dotenvy",
"flate2",
"futures-util",
"hex",
"image",
"qrcode",
@@ -479,6 +373,7 @@ dependencies = [
"tracing-subscriber",
"uuid",
"zip",
"zstd",
]
[[package]]
@@ -559,12 +454,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -582,6 +471,24 @@ dependencies = [
"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]]
name = "core-foundation-sys"
version = "0.8.7"
@@ -606,37 +513,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-utils"
version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -689,18 +571,21 @@ dependencies = [
"syn",
]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -710,26 +595,6 @@ dependencies = [
"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]]
name = "equivalent"
version = "1.0.2"
@@ -746,21 +611,6 @@ dependencies = [
"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]]
name = "fallible-iterator"
version = "0.3.0"
@@ -779,12 +629,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]]
name = "fdeflate"
version = "0.3.7"
@@ -850,6 +694,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "futures-task"
version = "0.3.32"
@@ -863,6 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
@@ -918,27 +774,6 @@ dependencies = [
"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]]
name = "hashbrown"
version = "0.14.5"
@@ -1239,38 +1074,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
[[package]]
name = "indexmap"
version = "2.14.0"
@@ -1283,17 +1091,6 @@ dependencies = [
"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]]
name = "ipnet"
version = "2.12.0"
@@ -1306,15 +1103,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.18"
@@ -1354,28 +1142,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libc"
version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "libm"
version = "0.2.16"
@@ -1405,6 +1177,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -1420,15 +1198,6 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]]
name = "lru-slab"
version = "0.1.2"
@@ -1450,16 +1219,6 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "memchr"
version = "2.8.1"
@@ -1536,21 +1295,6 @@ dependencies = [
"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]]
name = "nom"
version = "7.1.3"
@@ -1561,21 +1305,6 @@ dependencies = [
"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]]
name = "nu-ansi-term"
version = "0.50.3"
@@ -1585,53 +1314,12 @@ dependencies = [
"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]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "num-traits"
version = "0.2.19"
@@ -1687,18 +1375,6 @@ dependencies = [
"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]]
name = "percent-encoding"
version = "2.3.2"
@@ -1774,22 +1450,19 @@ dependencies = [
]
[[package]]
name = "profiling"
version = "1.0.18"
name = "psl-types"
version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
dependencies = [
"profiling-procmacros",
]
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
[[package]]
name = "profiling-procmacros"
version = "1.0.18"
name = "publicsuffix"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb"
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [
"quote",
"syn",
"idna",
"psl-types",
]
[[package]]
@@ -1798,15 +1471,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "qrcode"
version = "0.14.1"
@@ -1816,12 +1480,6 @@ dependencies = [
"image",
]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quinn"
version = "0.11.9"
@@ -1957,76 +1615,6 @@ dependencies = [
"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]]
name = "redox_syscall"
version = "0.5.18"
@@ -2061,6 +1649,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"cookie",
"cookie_store",
"futures-core",
"http",
"http-body",
@@ -2091,12 +1681,6 @@ dependencies = [
"webpki-roots",
]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]]
name = "ring"
version = "0.17.14"
@@ -2320,15 +1904,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "slab"
version = "0.4.12"
@@ -2446,20 +2021,6 @@ dependencies = [
"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]]
name = "time"
version = "0.3.47"
@@ -2775,17 +2336,6 @@ dependencies = [
"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]]
name = "valuable"
version = "0.1.1"
@@ -2955,12 +2505,6 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "windows-core"
version = "0.62.2"
@@ -3295,12 +2839,6 @@ dependencies = [
"rustix",
]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]]
name = "yoke"
version = "0.8.3"
@@ -3440,25 +2978,29 @@ dependencies = [
]
[[package]]
name = "zune-core"
version = "0.5.1"
name = "zstd"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
dependencies = [
"simd-adler32",
"zstd-safe",
]
[[package]]
name = "zune-jpeg"
version = "0.5.15"
name = "zstd-safe"
version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
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]
name = "bzod"
version = "0.1.0"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.8.0"
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]
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"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
@@ -19,7 +39,7 @@ askama = { version = "0.12" }
argon2 = "0.5"
sha2 = "0.10"
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"
flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] }
@@ -27,5 +47,17 @@ hex = "0.4"
time = "0.3"
toml = "0.8"
qrcode = "0.14"
image = "0.25"
image = { version = "0.25", default-features = false, features = ["png"] }
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"
+60 -34
View File
@@ -1,69 +1,95 @@
# ==========================================
# Stage 1: Build
# Stage 1: Builder
# ==========================================
# FROM rust:1.82-slim-bookworm AS builder
FROM rust:1.89-bookworm AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
# Build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy configuration files
# COPY Cargo.toml ./
# Dependency metadata first for Docker layer caching
COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Dummy build to cache Rust dependencies
RUN mkdir -p src && \
printf 'fn main() {}\n' > src/main.rs && \
cargo build --release --locked && \
rm -rf src
# Copy source and templates
# Actual application source and runtime assets
COPY src ./src
COPY templates ./templates
COPY www ./www
# Reproducible production build
RUN cargo build --release --locked
# Trigger rebuilding with actual source
RUN touch src/main.rs
RUN cargo build --release
# ==========================================
# Stage 2: Runner
# Stage 2: Runtime
# ==========================================
FROM debian:bookworm-slim
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
openssl \
# Runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy binary from builder
# Create unprivileged runtime user
RUN groupadd --gid 1000 bzod && \
useradd --uid 1000 --gid 1000 \
--create-home \
--shell /usr/sbin/nologin \
bzod
# Application binary
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create non-root user and data directory
RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod
# Application-owned immutable assets
COPY --from=builder /app/templates /app/templates
COPY --from=builder /app/www /app/www
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
# Persistent runtime directories.
# /app/images is intentionally external/persistent in Compose.
RUN mkdir -p \
/app/data \
/app/config \
/app/images && \
chown -R bzod:bzod \
/app/data \
/app/config \
/app/images \
/app/templates \
/app/www \
/usr/local/bin/bzod \
/usr/local/bin/docker-entrypoint.sh
USER bzod
ENV DATA_DIR=/app/data
ENV PORT=8654
ENV HOST=0.0.0.0
ENV COOKIE_SECURE=true
# Runtime configuration
ENV NX9_BZOD_DATA_DIR=/app/data \
CONFIG_DIR=/app/config \
IMAGES_DIR=/app/images \
PORT=8654 \
HOST=0.0.0.0 \
COOKIE_SECURE=true
EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8654}/status || exit 1
HEALTHCHECK \
--interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
ENTRYPOINT ["bzod"]
USER bzod
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
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.
+1375 -370
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}");
}
}
}
}
+448 -120
View File
@@ -1,147 +1,475 @@
#!/usr/bin/env bash
# BZOD Deployment Script (Debian Native Deployment)
# This script sets up a secure, production-ready systemd service for BZOD.
#
# BZOD Production Docker Deployment
#
# Privacy-First URL Shortener & Landing Page Platform
#
# Usage:
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
#
# Or:
# sudo bash deploy.sh
#
# Environment overrides:
# BZOD_VERSION=0.8.0
# BZOD_IMAGE=nx9-url-shortener
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
# BZOD_PORT=8654
#
set -euo pipefail
# Configurations
SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod"
DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
# ============================================================
# Configuration
# ============================================================
BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
BZOD_PORT="${BZOD_PORT:-8654}"
BASE_URL="${BASE_URL:-https://bzo.in}"
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
NX9_BZOD_DATA_DIR="${BZOD_ROOT}/data"
CONFIG_DIR="${BZOD_ROOT}/config"
IMAGES_DIR="${BZOD_ROOT}/images"
COMPOSE_DIR="${BZOD_ROOT}/compose"
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
ENV_FILE="${COMPOSE_DIR}/bzod.env"
BACKUP_ROOT="${BZOD_ROOT}/backups"
IMAGE="${BZOD_IMAGE}:${BZOD_VERSION}"
# ============================================================
# Output
# ============================================================
# Color outputs
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}"
info() {
echo -e "${BLUE}$*${NC}"
}
# 1. Check Root Privileges
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}"
success() {
echo -e "${GREEN}$*${NC}"
}
warning() {
echo -e "${YELLOW}$*${NC}"
}
error() {
echo -e "${RED}$*${NC}" >&2
}
die() {
error "$*"
exit 1
}
# ============================================================
# Root check
# ============================================================
if [[ "${EUID}" -ne 0 ]]; then
die "This script must be run as root. Use: sudo bash deploy.sh"
fi
# 2. Install Package Dependencies
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}"
apt-get update
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
echo
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
echo -e "${BLUE}============================================================${NC}"
echo
echo "Version: ${BZOD_VERSION}"
echo "Image: ${IMAGE}"
echo "Application: ${BZOD_ROOT}"
echo "Data: ${NX9_BZOD_DATA_DIR}"
echo "Config: ${CONFIG_DIR}"
echo "Images: ${IMAGES_DIR}"
echo "Port: ${BZOD_PORT}"
echo
# 3. Compile Production Build Locally
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}"
if ! command -v cargo &> /dev/null; then
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}"
exit 1
# ============================================================
# 1. Install Docker
# ============================================================
info "[1/8] Checking Docker..."
if ! command -v docker >/dev/null 2>&1; then
info "Docker is not installed. Installing Docker..."
apt-get update -qq
apt-get install -y \
ca-certificates \
curl
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL \
https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
. /etc/os-release
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
fi
cargo build --release
echo -e "${GREEN}Release build completed.${NC}"
# 4. Install Binary
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}"
cp target/release/bzod "${INSTALL_PATH}"
chmod 755 "${INSTALL_PATH}"
chown root:root "${INSTALL_PATH}"
echo -e "${GREEN}Binary installed successfully.${NC}"
# 5. Create Dedicated locked-down System User
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}"
if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
else
echo "User '${SERVICE_USER}' already exists."
if ! docker info >/dev/null 2>&1; then
systemctl enable --now docker
fi
# 6. Configure Directory Trees and Permissions
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}"
mkdir -p "${CONFIG_DIR}"
mkdir -p "${DATA_DIR}"
if ! docker compose version >/dev/null 2>&1; then
die "Docker Compose plugin is unavailable."
fi
success "✓ Docker and Docker Compose available"
# ============================================================
# 2. Create persistent directories
# ============================================================
info "[2/8] Creating persistent application directories..."
mkdir -p \
"${NX9_BZOD_DATA_DIR}" \
"${CONFIG_DIR}" \
"${IMAGES_DIR}" \
"${COMPOSE_DIR}" \
"${BACKUP_ROOT}"
chmod 700 "${CONFIG_DIR}"
chmod 755 "${IMAGES_DIR}"
success "✓ Persistent directories ready"
# ============================================================
# 3. Configuration
# ============================================================
info "[3/8] Preparing configuration..."
if [[ ! -f "${ENV_FILE}" ]]; then
cat > "${ENV_FILE}" <<EOF
BZOD_VERSION=${BZOD_VERSION}
BZOD_IMAGE=${BZOD_IMAGE}
# Copy .env file if it exists, otherwise prompt/generate
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then
echo "Copying local .env file to ${ENV_FILE}..."
cp .env "${ENV_FILE}"
elif [ ! -f "${ENV_FILE}" ]; then
echo "Generating default configuration file at ${ENV_FILE}..."
cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0
PORT=8080
DATA_DIR=${DATA_DIR}
PORT=8654
NX9_BZOD_DATA_DIR=/app/data
CONFIG_DIR=/app/config
IMAGES_DIR=/app/images
COOKIE_SECURE=true
SESSION_SECRET=$(openssl rand -hex 32)
ADMIN_USERNAME=admin
# SHA-256 for bootstrap (Default: admin)
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
EOF
fi
chmod 600 "${ENV_FILE}"
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}"
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
# 7. Initialise DB as the service user (avoids file permission conflicts)
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}Databases initialised.${NC}"
# 8. Set Up Systemd Service
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}"
[Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform
After=network.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR}
Restart=on-failure
RestartSec=5s
# Hardening / Sandboxing options for security
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/var/lib/bzod
[Install]
WantedBy=multi-user.target
RUST_LOG=info
BASE_URL=${BASE_URL}
EOF
chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload
echo -e "${GREEN}Systemd service registered.${NC}"
chmod 600 "${ENV_FILE}"
# 9. Enable and Start the Service
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}"
systemctl enable bzod
systemctl restart bzod
success "✓ New Docker configuration created"
sleep 2
if systemctl is-active --quiet bzod; then
echo -e "${GREEN}BZOD service is running successfully!${NC}"
echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}"
echo -e "You can access BZOD at http://localhost:8080"
echo -e "Admin Login Dashboard is at http://localhost:8080/admin"
echo -e "System service logs: journalctl -u bzod -f"
echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}"
else
echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}"
warning "Existing Docker configuration preserved"
# Update image/version while preserving all other settings.
sed -i \
-E "s#^BZOD_VERSION=.*#BZOD_VERSION=${BZOD_VERSION}#" \
"${ENV_FILE}" || true
sed -i \
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
"${ENV_FILE}" || true
if grep -q '^BASE_URL=' "${ENV_FILE}"; then
sed -i \
-E "s#^BASE_URL=.*#BASE_URL=${BASE_URL}#" \
"${ENV_FILE}" || true
else
echo "BASE_URL=${BASE_URL}" >> "${ENV_FILE}"
fi
fi
# ============================================================
# 4. Create Compose definition
# ============================================================
info "[4/8] Writing Docker Compose configuration..."
cat > "${COMPOSE_FILE}" <<'EOF'
services:
bzod:
image: ${BZOD_IMAGE}:${BZOD_VERSION}
container_name: bzod
restart: unless-stopped
ports:
- "${PORT:-8654}:8654"
environment:
HOST: "${HOST:-0.0.0.0}"
PORT: "${PORT:-8654}"
NX9_BZOD_DATA_DIR: "/app/data"
CONFIG_DIR: "/app/config"
IMAGES_DIR: "/app/images"
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
RUST_LOG: "${RUST_LOG:-info}"
BASE_URL: "${BASE_URL:-https://bzo.in}"
volumes:
# Persistent application databases.
- ${BZOD_ROOT}/data:/app/data
# Persistent application configuration.
- ${BZOD_ROOT}/config:/app/config
# User-uploaded / application images.
#
# IMPORTANT:
# /app/images is required by the image router.
- ${BZOD_ROOT}/images:/app/images
healthcheck:
test:
[
"CMD",
"curl",
"-fsS",
"http://127.0.0.1:8654/status"
]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
security_opt:
- no-new-privileges:true
EOF
# Append BZOD_ROOT because compose needs it.
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
fi
# Port variable expected by compose.
if ! grep -q '^PORT=' "${ENV_FILE}"; then
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
fi
success "✓ Docker Compose configuration written"
# ============================================================
# 5. Backup existing installation
# ============================================================
info "[5/8] Creating pre-upgrade backup..."
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}"
if [[ -d "${NX9_BZOD_DATA_DIR}" ]]; then
cp -a "${NX9_BZOD_DATA_DIR}" "${BACKUP_DIR}/data"
fi
if [[ -d "${CONFIG_DIR}" ]]; then
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
fi
if [[ -d "${IMAGES_DIR}" ]]; then
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
fi
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
success "✓ Backup created:"
echo " ${BACKUP_DIR}"
# ============================================================
# 6. Pull new image
# ============================================================
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
# Build locally from the current deployment tree. The package/repository is
# nx9-url-shortener; the application binary and container remain named bzod.
if ! docker build \
--tag "${IMAGE}" \
--file "${BZOD_ROOT}/Dockerfile" \
"${BZOD_ROOT}"; then
die "Unable to build ${IMAGE}"
fi
success "✓ Docker image built locally"
# ============================================================
# 7. Deploy
# ============================================================
info "[7/8] Deploying BZOD..."
cd "${COMPOSE_DIR}"
# Stop/remove the existing container through Compose.
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
down \
--remove-orphans
# Start the requested image.
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
up -d
success "✓ BZOD container started"
# ============================================================
# 8. Validation
# ============================================================
info "[8/8] Validating deployment..."
sleep 5
if ! docker inspect \
--format '{{.State.Running}}' \
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
error "BZOD container failed to start."
echo
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
logs --tail=100
error
error "Deployment failed. Existing data was not removed."
error "Backup: ${BACKUP_DIR}"
exit 1
fi
success "✓ Container is running"
# ------------------------------------------------------------
# Health check
# ------------------------------------------------------------
HEALTH_OK=0
for _ in {1..12}; do
if curl -fsS \
"http://127.0.0.1:${BZOD_PORT}/status" \
>/dev/null 2>&1; then
HEALTH_OK=1
break
fi
sleep 2
done
if [[ "${HEALTH_OK}" -eq 1 ]]; then
success "✓ HTTP health check passed"
else
warning "⚠ HTTP health check did not respond yet"
warning "The container is running; inspect logs if necessary:"
echo
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
fi
# ============================================================
# Verify image and binary
# ============================================================
echo
info "Installed image:"
docker image inspect "${IMAGE}" \
--format ' {{.RepoTags}} ({{.Id}})' \
2>/dev/null || true
echo
info "Container:"
docker inspect "${CONTAINER_NAME}" \
--format ' {{.Name}} {{.Config.Image}}' \
2>/dev/null || true
echo
info "Persistent mounts:"
docker inspect "${CONTAINER_NAME}" \
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
2>/dev/null || true
# ============================================================
# Final status
# ============================================================
echo
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
echo -e "${GREEN}============================================================${NC}"
echo
echo "Web UI:"
echo " http://<server-ip>:${BZOD_PORT}"
echo
echo "Persistent data:"
echo " ${NX9_BZOD_DATA_DIR}"
echo
echo "Persistent images:"
echo " ${IMAGES_DIR}"
echo
echo "Docker Compose:"
echo " ${COMPOSE_FILE}"
echo
echo "Backup:"
echo " ${BACKUP_DIR}"
echo
echo "Useful commands:"
echo " docker compose -f ${COMPOSE_FILE} ps"
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
echo
success "Deployment complete."
+44 -16
View File
@@ -2,20 +2,32 @@ name: app-bzod
services:
bzod:
image: nx9-url-shortener:v0.1.0
build:
context: .
context: /DATA/AppData/nx9-url-shortener
dockerfile: Dockerfile
container_name: bzod
deploy:
resources:
limits:
memory: 31940M
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- CONFIG_DIR=/app/config
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- NX9_BZOD_DATA_DIR=/app/data
- HOST=0.0.0.0
- IMAGES_DIR=/app/images
- PORT=8654
- RUST_LOG=info
- BASE_URL=${BASE_URL}
hostname: bzod
image: nx9-url-shortener:v0.8.0
ports:
- mode: ingress
@@ -25,43 +37,59 @@ services:
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- type: bind
source: /DATA/AppData/bzod/data
source: /DATA/AppData/nx9-url-shortener/data
target: /app/data
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/bzod/config
source: /DATA/AppData/nx9-url-shortener/config
target: /app/config
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/nx9-url-shortener/www
target: /app/www
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/nx9-url-shortener/images
target: /app/images
bind:
create_host_path: true
devices: []
cap_add: []
command: []
networks:
- default
hostname: bzod
privileged: false
cpu_shares: 90
deploy:
resources:
limits:
memory: 31940M
networks:
default:
name: app_default
x-casaos:
hostname: ""
scheme: http
index: /
port_map: "8654"
author: self
category: self
hostname: ""
icon: ""
index: /
is_uncontrolled: false
port_map: "8654"
scheme: http
title:
custom: nx9-url-shortener
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec /usr/local/bin/bzod "$@"
+888
View File
@@ -0,0 +1,888 @@
# BZOD Administrator Guide
Version: v0.8.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.8.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.8.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.
+433
View File
@@ -0,0 +1,433 @@
# 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.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening
## Added
* Core Admin is strictly platform-operator-only and has no tenant application storage.
* Inspection-only global URL and landing-page registries for Admin.
* Strict Admin/tenant route boundary with HTTP 403 enforcement.
* TenantId-based active ownership and tenant filesystem topology.
* Tenant-aware analytics worker grouping.
* Deterministic cross-role session invalidation and cookie clearing.
## Changed
* Removed active production dependencies on the legacy `system.db.global_slugs` registry.
* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations.
* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`.
* User login and Admin login now establish role-specific sessions and clear the opposite-role session.
## Compatibility
* Historical v0.7.x migration and legacy restore compatibility remains preserved.
* Legacy database/schema identifiers are retained only where required for migration and historical restore support.
---
---
# 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
+310
View File
@@ -0,0 +1,310 @@
# 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.8.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
---
# Data Directory Configuration
BZOD requires an explicit physical data directory root. There is **no implicit `./data` fallback**.
### Configuration Precedence
1. **CLI `--data-dir`** (Highest priority)
```bash
bzod serve --data-dir /var/lib/bzod/data
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
2. **Environment Variable `NX9_BZOD_DATA_DIR`**
```bash
export NX9_BZOD_DATA_DIR=/var/lib/bzod/data
bzod serve
```
3. **Configuration File (`bzod.toml` / `config.toml`)**
```toml
data_dir = "/var/lib/bzod/data"
```
4. **Error**: If no data directory is provided via CLI, `NX9_BZOD_DATA_DIR`, or config file, BZOD terminates with an actionable error.
+354
View File
@@ -0,0 +1,354 @@
# BZOD v0.8.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.8.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
+559
View File
@@ -0,0 +1,559 @@
# 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
```
## Automated Administrator Bootstrap (First Start Only)
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
```yaml
environment:
ADMIN_USERNAME: "admin"
ADMIN_PASSWORD: "<your-secure-password>"
```
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
## Manual Administrator Creation
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
```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
NX9_BZOD_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 |
| NX9_BZOD_DATA_DIR | Database directory | (required) |
| 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.8.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.8.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.8.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.8.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.
+492
View File
@@ -0,0 +1,492 @@
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
Release Date: 2026-08-21
## Highlights
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
## Verification
- Phase 5 Core Separation tests: **4/4 passed**
- Admin capability boundary tests: **11/11 passed**
- Admin/user route and session boundary tests: **27/27 passed**
- Phase 6A elimination tests: **6/6 passed**
- Workspace regression suite: **all tests passed**
- `cargo fmt --all -- --check`: **PASS**
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
---
# 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.8.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.8.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.8.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.8.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.
+807
View File
@@ -0,0 +1,807 @@
# Upgrade Guide
Version: v0.8.0
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0.
---
# BZOD v0.8.0 Upgrade Overview
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
Key upgrade characteristics:
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
* Active production operations no longer use `system.db.global_slugs`.
* Active tenant ownership is represented by immutable `TenantId`.
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
# 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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+8 -4
View File
@@ -8,15 +8,19 @@ pub struct 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);
// Spawn background worker to batch-write records
tokio::spawn(async move {
super::worker::run_worker(db, receiver).await;
let handle = tokio::spawn(async move {
super::worker::run_worker(db, receiver, shutdown_rx).await;
});
Self { sender }
(Self { sender }, handle)
}
// Attempt to queue a visit. Non-blocking.
+71 -7
View File
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
use crate::db::Db;
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 batch_size = 50;
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);
}
}
_ = shutdown_rx.changed() => {
info!("Analytics worker flushing pending records");
flush_batch(&db, &mut batch);
break;
}
}
}
}
@@ -46,11 +55,66 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
return;
}
info!("Flushing {} visits to analytics database", batch.len());
let mut conn_lock = db.analytics.lock().unwrap();
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) {
error!("Failed to write analytics batch to database: {:?}", e);
} else {
batch.clear();
info!(
"Flushing {} visits to tenant analytics databases",
batch.len()
);
// Group visits by owner_tenant_id (or legacy user 1 fallback)
let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
std::collections::HashMap::new();
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
for record in batch.drain(..) {
if let Some(tenant_id) = record.owner_tenant_id {
groups.entry(tenant_id).or_default().push(record);
} else if record.owner_user_id == Some(1) {
legacy_user1_visits.push(record);
} else {
error!("Dropping analytics visit with no owner_tenant_id");
}
}
for (tenant_id, tenant_visits) in groups {
let db_path = db.topology.tenant_analytics_db(tenant_id);
if !db_path.exists() {
error!(
"Refusing to write analytics for non-existent tenant analytics path: {:?}",
db_path
);
continue;
}
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, &tenant_visits) {
error!(
"Failed to write analytics batch to tenant {} database: {:?}",
tenant_id, e
);
}
}
Err(e) => {
error!(
"Failed to open analytics database for tenant {}: {:?}",
tenant_id, e
);
}
}
}
if !legacy_user1_visits.is_empty() {
if let Ok(legacy_db_path) = db.topology.analytics_db("1") {
if legacy_db_path.exists() {
if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
let _ = insert_visits_batch(&mut conn, &legacy_user1_visits);
}
}
}
}
}
+37 -5
View File
@@ -1,13 +1,44 @@
use crate::auth::session::authenticate_api_key;
use crate::models::User;
use crate::models::ApiActor;
use crate::state::AppState;
use axum::{
extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode},
Json,
};
// 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]
impl<S> FromRequestParts<S> for ApiUser
@@ -25,9 +56,10 @@ where
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) {
Ok(Some(user)) => Ok(ApiUser(user)),
let admin_conn = app_state.admin_db.lock().unwrap();
let users_conn = app_state.users_db.lock().unwrap();
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
Ok(Some(actor)) => Ok(ApiUser(actor)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
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 middleware::ApiUser;
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,
};
+221 -17
View File
@@ -1,11 +1,11 @@
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 chrono::Utc;
use rand::{thread_rng, RngCore};
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded)
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key)
}
// Authenticate session from cookies
pub fn authenticate_session(
// Authenticate administrator session from cookies
pub fn authenticate_admin_session(
conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -26,7 +26,33 @@ pub fn authenticate_session(
};
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,
None => return Ok(None),
};
@@ -41,19 +67,154 @@ pub fn authenticate_session(
return Ok(None);
}
// Get user
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
// Get user (status must be 'active' and account_type = 'admin')
// 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', account_type != 'admin', and tenant_id is Some)
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
if let Some(user) = user_opt {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
}
// Authenticate API key from Authorization header
// Authenticate API key/token from Authorization header (unified)
pub fn authenticate_api_key(
conn: &Connection,
admin_conn: &Connection,
users_conn: &Connection,
auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> {
) -> Result<Option<ApiActor>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
@@ -68,13 +229,56 @@ pub fn authenticate_api_key(
hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(conn, &api_key_rec.id)?;
// 1. Check user API tokens in users.db
let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
// Get user
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
return Ok(Some(user));
if let Some(user_id) = user_id_opt {
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
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",
};
+149
View File
@@ -0,0 +1,149 @@
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 topology = crate::db::topology::Topology::new(&config.data_dir);
let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?;
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 = topology.content_db_i64(target_admin_id)?;
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(())
}
}
+156
View File
@@ -0,0 +1,156 @@
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>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: 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 = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?;
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,
tenant_id: user.tenant_id.map(|t| t.to_string()),
uuid: user.uuid,
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(())
}
+9 -5
View File
@@ -7,6 +7,7 @@ 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>> {
@@ -25,15 +26,18 @@ pub async fn run(
return Ok(());
}
let password = read_input("Enter password: ");
if password.trim().is_empty() {
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(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
let conn = db.users.lock().unwrap();
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
info!(
"Successfully created admin user: {} (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()
}
+113
View File
@@ -0,0 +1,113 @@
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)?;
// 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(());
}
}
};
if user_details.account_type == "admin" && !force {
error!("Deleting administrator account requires --force flag");
return Ok(());
}
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
let now = Utc::now().to_rfc3339();
if let Some(ref tid) = user_details.tenant_id {
let tid_str = tid.to_string();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let system_conn = db.system.lock().unwrap();
// Get all URL slugs owned by tenant
let mut stmt =
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?;
let url_slugs: Vec<String> = stmt
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Get all page slugs owned by tenant
let mut stmt2 = pages_conn
.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?;
let page_slugs: Vec<String> = stmt2
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Record history and retire/delete slugs
for slug in url_slugs {
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
for slug in page_slugs {
let _ =
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
}
// 2. Delete tenant directory from disk
if let Some(ref tid) = user_details.tenant_id {
let user_dir = db.topology.tenant_dir(*tid);
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(())
}
+117 -3
View File
@@ -22,11 +22,36 @@ pub async fn run(
println!("Data directory: {:?}", config.data_dir);
println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true;
for db_name in &databases {
let db_path = config.data_dir.join(format!("{}.db", db_name));
let topology = crate::db::topology::Topology::new(&config.data_dir);
let dbs = vec![
("admin", topology.admin_db()),
("system", topology.system_db()),
("users", topology.users_registry_db()),
("global_urls", topology.global_urls_db()),
("global_landing_pages", topology.global_landing_pages_db()),
("reserved", topology.reserved_db()),
(
"legacy content",
topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
(
"legacy analytics",
topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
];
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() {
println!("Database: {}", db_name);
@@ -75,6 +100,95 @@ pub async fn run(
println!();
}
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = topology.system_db();
let users_db_path = topology.users_registry_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!(" Tenant ID {}", issue.owner_tenant_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!("--------------------");
if all_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(())
}
+49
View File
@@ -0,0 +1,49 @@
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 owner_info = {
let conn = db.global_urls.lock().unwrap();
conn.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';",
rusqlite::params![normalized_code],
|row| row.get::<_, String>(0),
)
.ok()
};
let tid_str = match owner_info {
Some(t) => t,
None => return Err(format!("Short code not found: {}", normalized_code).into()),
};
let tid = tid_str
.parse::<crate::identity::TenantId>()
.map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?;
let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let url_opt = 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()),
}
}
+56
View File
@@ -0,0 +1,56 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::env;
use std::path::PathBuf;
use tracing::{error, info};
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 admin_count: i64 = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|row| row.get(0),
)?
};
if admin_count > 0 {
info!("Administrator already exists; initialization skipped.");
return Ok(());
}
let username = match env::var("ADMIN_USERNAME") {
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let password = match env::var("ADMIN_PASSWORD") {
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let hash = hash_password(&password).map_err(|e| e.to_string())?;
{
let conn = db.users.lock().unwrap();
let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?;
}
info!("Administrator initialized successfully.");
Ok(())
}
+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(())
}
+81 -2
View File
@@ -15,12 +15,91 @@ pub async fn run(
if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied.");
info!("Data directory: {:?}", config.data_dir);
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, true, false).await?;
println!("\nIdentity Migration Preflight Report (Dry Run):");
println!("----------------------------------------------");
println!("Total users: {}", id_report.total_users);
println!(
"Users needing TenantId: {}",
id_report.users_assigned_tenant_id
);
println!("Users needing UUID: {}", id_report.users_assigned_uuid);
println!("Directories to move: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin (users/1) preserved: {}",
id_report.legacy_admin_preserved
);
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?;
println!("\nGlobal Slug Migration Preflight Report (Dry Run):");
println!("-------------------------------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated);
println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated);
println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated);
return Ok(());
}
info!("Running database migrations...");
info!("Running database schema migrations...");
let _db = Db::init(&config)?;
info!("Database migrations applied successfully.");
info!("Database schema migrations applied successfully.");
info!("Running identity & directory migration lifecycle...");
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, false, false).await?;
println!("\nIdentity Migration Report:");
println!("--------------------------");
println!("Total users: {}", id_report.total_users);
println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id);
println!("UUIDs assigned: {}", id_report.users_assigned_uuid);
println!("Directories migrated: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin preserved: {}",
id_report.legacy_admin_preserved
);
println!("Validation passed: {}", id_report.validation_passed);
if !id_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &id_report.warnings {
println!(" - {}", w);
}
}
info!("Running global slug migration lifecycle...");
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?;
println!("\nGlobal Slug Migration Report:");
println!("-----------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs migrated: {}", slug_report.url_slugs_migrated);
println!("Page slugs migrated: {}", slug_report.page_slugs_migrated);
println!(
"Reserved slugs verified: {}",
slug_report.reserved_slugs_migrated
);
println!(
"Existing target records verified: {}",
slug_report.existing_records_verified
);
println!("Validation passed: {}", slug_report.validation_passed);
if !slug_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &slug_report.warnings {
println!(" - {}", w);
}
}
Ok(())
}
+171
View File
@@ -1,16 +1,31 @@
use clap::{Parser, Subcommand};
pub mod admin_migrate;
pub mod audit_destinations;
pub mod backup;
pub mod backup_user;
pub mod create_admin;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod doctor;
pub mod enable_user;
pub mod expand;
pub mod init_admin;
pub mod list_users;
pub mod migrate;
pub mod repair;
pub mod reset_password;
pub mod restore;
pub mod restore_user;
pub mod serve;
pub mod shorten;
pub mod stats;
pub mod validate;
#[derive(Parser)]
#[command(name = "bzod")]
#[command(version)]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
pub struct Cli {
#[command(subcommand)]
@@ -60,6 +75,11 @@ pub enum Commands {
#[arg(long)]
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
CreateAdmin {
#[arg(long)]
@@ -67,9 +87,160 @@ pub enum Commands {
#[arg(long)]
data_dir: Option<String>,
},
/// Initialize the first administrator for automated/container deployments
InitAdmin {
#[arg(long)]
data_dir: Option<String>,
},
/// Run database diagnostics and health checks
Doctor {
#[arg(long)]
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>,
},
}
impl Commands {
pub fn data_dir(&self) -> Option<&str> {
match self {
Commands::Serve { data_dir, .. } => data_dir.as_deref(),
Commands::Backup { data_dir, .. } => data_dir.as_deref(),
Commands::Restore { data_dir, .. } => data_dir.as_deref(),
Commands::Migrate { data_dir, .. } => data_dir.as_deref(),
Commands::Stats { data_dir, .. } => data_dir.as_deref(),
Commands::Validate { data_dir, .. } => data_dir.as_deref(),
Commands::AuditDestinations { data_dir, .. } => data_dir.as_deref(),
Commands::CreateAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::InitAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::Doctor { data_dir, .. } => data_dir.as_deref(),
Commands::Shorten { data_dir, .. } => data_dir.as_deref(),
Commands::Expand { data_dir, .. } => data_dir.as_deref(),
Commands::CreateUser { data_dir, .. } => data_dir.as_deref(),
Commands::DeleteUser { data_dir, .. } => data_dir.as_deref(),
Commands::DisableUser { data_dir, .. } => data_dir.as_deref(),
Commands::EnableUser { data_dir, .. } => data_dir.as_deref(),
Commands::ResetPassword { data_dir, .. } => data_dir.as_deref(),
Commands::ListUsers { data_dir, .. } => data_dir.as_deref(),
Commands::BackupUser { data_dir, .. } => data_dir.as_deref(),
Commands::RestoreUser { data_dir, .. } => data_dir.as_deref(),
Commands::AdminMigrate { data_dir, .. } => data_dir.as_deref(),
Commands::Repair { command } => match command {
RepairCommands::Registry { data_dir, .. } => data_dir.as_deref(),
},
}
}
}
+196
View File
@@ -0,0 +1,196 @@
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 topology = crate::db::topology::Topology::new(&config.data_dir);
let system_db_path = topology.system_db();
let users_db_path = topology.users_registry_db();
let urls_db_path = topology.global_urls_db();
let pages_db_path = topology.global_landing_pages_db();
if !system_db_path.exists()
|| !users_db_path.exists()
|| !urls_db_path.exists()
|| !pages_db_path.exists()
{
println!("Error: Core databases (system.db, users.db, slugs/*.db) not found.");
return Ok(());
}
let sys_conn = Connection::open(&system_db_path)?;
let usr_conn = Connection::open(&users_db_path)?;
let mut urls_conn = Connection::open(&urls_db_path)?;
let mut pages_conn = Connection::open(&pages_db_path)?;
let slug_filter = slug.as_deref();
if dry_run {
println!("BZOD Registry Repair (v0.8)\n");
println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)...");
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let true_orphans = issues
.iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingTenant
)
})
.collect::<Vec<_>>();
let corrupt = issues
.iter()
.filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase)
.collect::<Vec<_>>();
let access_failures = issues
.iter()
.filter(|i| i.issue_type == RegistryIssueType::AccessFailure)
.collect::<Vec<_>>();
println!("\nDetected Issues (Total: {}):", issues.len());
println!(" Orphaned/Missing Targets: {}", true_orphans.len());
println!(" Corrupt Databases (Protected): {}", corrupt.len());
println!(" Access Failures (Protected): {}", access_failures.len());
if !true_orphans.is_empty() {
println!("\nThe following orphaned entries would be repaired/removed:");
for issue in &true_orphans {
println!(
" {} [{}] — Tenant: {}",
issue.slug,
issue.target_type.to_uppercase(),
issue.owner_tenant_id
);
}
}
if !corrupt.is_empty() {
println!("\nProtected from destructive repair (Corrupt DBs):");
for issue in &corrupt {
println!(
" {} [{}] — Path: {:?}",
issue.slug,
issue.target_type.to_uppercase(),
issue.database_path
);
}
}
println!("\nNo changes have been made (dry-run).");
println!(
"Run again with:\n bzod repair registry --force{}",
if let Some(s) = slug_filter {
format!(" --slug {}", s)
} else {
"".to_string()
}
);
info!(
"Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}",
true_orphans.len(),
corrupt.len(),
start_time.elapsed()
);
} else if force {
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
// Only repair true orphans, missing targets, or missing tenants.
// Never delete records with CorruptDatabase or AccessFailure per safety policies.
let repairable = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingTenant
)
})
.collect::<Vec<_>>();
if repairable.is_empty() {
println!("No repairable registry issues found.");
return Ok(());
}
let tx_urls = urls_conn.transaction()?;
let tx_pages = pages_conn.transaction()?;
let mut removed_count = 0;
for issue in &repairable {
if issue.target_type == "url" {
removed_count += tx_urls
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
} else {
removed_count += tx_pages.execute(
"DELETE FROM global_landing_pages WHERE slug = ?1;",
[&issue.slug],
)?;
}
}
tx_urls.commit()?;
tx_pages.commit()?;
// Record audit event in system.db
let _ = crate::db::audit_events::write_audit_event(
&sys_conn,
"cli",
"REGISTRY_REPAIR",
"registry",
slug_filter.unwrap_or("*"),
Some(&format!(
"Repaired/removed {} orphaned slug entries",
removed_count
)),
);
println!("Registry Repair Complete.");
println!("Repaired/Removed: {} entries", removed_count);
info!(
"Registry Repair Complete. Removed: {}. Duration: {:?}",
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()
}
+376 -6
View File
@@ -1,10 +1,383 @@
use crate::config::Config;
use crate::services::registry_validator::RegistryIssueType;
use flate2::read::GzDecoder;
use std::fs::File;
use std::io::{self, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
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::Conflict
| RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => {
errors.push(issue);
}
RegistryIssueType::MissingTenant if !is_legacy => {
errors.push(issue);
}
_ => {
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 temp_config = Config::load_with_cli(Some(&temp_dir))?;
// 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(
file: String,
@@ -40,10 +413,7 @@ pub async fn run(
}
info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
perform_restore(&file_path, &config.data_dir)?;
info!("Database files successfully restored.");
Ok(())
+424
View File
@@ -0,0 +1,424 @@
use crate::config::Config;
use crate::db::Db;
use crate::identity::TenantId;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use tracing::{error, info};
use uuid::Uuid;
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>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: 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 identity per Correction #1:
// Order:
// 1. Archive contains TenantId + UUID -> preserve exactly.
// 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID.
// 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID.
let (target_user_id, target_tenant_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) => {
let tenant_id = if let Some(tid) = u.tenant_id {
tid
} else if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid) = u.uuid {
uid.clone()
} else if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;",
rusqlite::params![
metadata.password_hash,
metadata.status,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid,
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, tenant_id)
}
None => {
let tenant_id = if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
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, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
rusqlite::params![
metadata.id,
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
rusqlite::params![
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?;
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, tenant_id)
}
}
};
// 3. Extract database files to /data/users/<TenantId>/
let dest_dir = db.topology.tenant_dir(target_tenant_id);
std::fs::create_dir_all(&dest_dir)?;
std::fs::create_dir_all(dest_dir.join("extensions"))?;
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 restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
let content_path = dest_dir.join("content.db");
if content_path.exists() {
let restored_content_conn = rusqlite::Connection::open(&content_path)?;
let now = Utc::now().to_rfc3339();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
// 4a. Validate URL slugs for collisions
let mut url_slugs = Vec::new();
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 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"
};
// Check collision with global_urls
let existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
// Check collision with global_landing_pages
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})"
)
.into());
}
}
// Check reserved
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: URL slug '{code}' is a reserved system keyword"
)
.into());
}
url_slugs.push((code, target_id, created_at, global_status));
}
// 4b. Validate Landing Page slugs for collisions
let mut page_slugs = Vec::new();
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 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 existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})"
)
.into());
}
}
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: Landing page slug '{code}' is a reserved system keyword"
)
.into());
}
page_slugs.push((code, target_id, created_at, global_status));
}
// 4c. Register validated URL slugs
for (code, target_id, created_at, global_status) in url_slugs {
let _ = urls_conn.execute(
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
}
// 4d. Register validated Landing Page slugs
for (code, target_id, created_at, global_status) in page_slugs {
let _ = pages_conn.execute(
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
}
// 5. Reconcile quotas for restored user
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
}
info!(
"User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
metadata.username, target_user_id, target_tenant_id
);
Ok(())
}
+111 -19
View File
@@ -7,6 +7,30 @@ use std::path::PathBuf;
use std::time::Instant;
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(
host: Option<String>,
port: Option<u16>,
@@ -29,45 +53,89 @@ pub async fn run(
// Init DBs
let db = Db::init(&config)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut join_handles = Vec::new();
// 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
let link_checker_db = db.clone();
let link_checker_interval = config.link_check_interval_mins;
tokio::spawn(async move {
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await;
});
let rx = shutdown_rx.clone();
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_interval = config.aggregation_interval_mins;
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
});
let rx = shutdown_rx.clone();
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_days = config.data_retention_days;
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
});
let rx = shutdown_rx.clone();
join_handles.push((
"retention_cleaner",
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
}),
));
// Spawn optional backup scheduler
let backup_db = db.clone();
let backup_config = config.clone();
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
});
let rx = shutdown_rx.clone();
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();
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
let rx = shutdown_rx.clone();
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 {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.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(),
config: config.clone(),
analytics_queue: queue,
@@ -80,7 +148,31 @@ pub async fn run(
let listener = tokio::net::TcpListener::bind(&addr).await?;
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(())
}
+117
View File
@@ -0,0 +1,117 @@
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)?;
// Resolve active standard user tenant
let (user_id, tid) = {
let users_conn = db.users.lock().unwrap();
let users = crate::db::users::list_users(&users_conn)?;
let active_user = users.into_iter().find(|u| {
u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some()
});
match active_user {
Some(u) => (u.id, u.tenant_id.unwrap()),
None => {
return Err(
"Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`."
.into(),
)
}
}
};
// 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 global_urls with status 'reserving'
{
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
if let Err(e) =
crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid)
{
return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into());
}
}
// 4. Persist URL in tenant content DB
let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let _ = crate::db::sqlite::enable_wal(&conn, "content");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content");
let res = crate::db::content::create_url_extended(
&conn,
&code,
&target_url,
None,
None,
&[],
None,
None,
None,
);
match res {
Ok(url) => {
// Activate slug in global_urls
{
let urls_conn = db.global_urls.lock().unwrap();
crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
}
// Increment quota
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, user_id, "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 urls_conn = db.global_urls.lock().unwrap();
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
Err(e.into())
}
}
}
+58 -12
View File
@@ -14,14 +14,34 @@ pub async fn run(
println!("=== BZOD Database Stats ===");
println!("Storage Directory: {:?}", config.data_dir);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let p = config.data_dir.join(f);
if p.exists() {
let sz = std::fs::metadata(&p)?.len();
let files = vec![
("admin.db", db.topology.admin_db()),
("system.db", db.topology.system_db()),
("users.db", db.topology.users_registry_db()),
("global_urls.db", db.topology.global_urls_db()),
(
"global_landing_pages.db",
db.topology.global_landing_pages_db(),
),
("reserved.db", db.topology.reserved_db()),
(
"legacy content.db",
db.topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
),
(
"legacy analytics.db",
db.topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
),
];
for (name, path) in files {
if path.exists() {
let sz = std::fs::metadata(&path)?.len();
println!(
" File: {} - Size: {} bytes ({:.2} MB)",
f,
name,
sz,
sz as f64 / 1_048_576.0
);
@@ -35,8 +55,29 @@ pub async fn run(
println!("Users Count: {}", users_count);
let (urls_total, urls_active, urls_dead) = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)?
let conn = db.global_urls.lock().unwrap();
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let active: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let dead: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
[],
|r| r.get(0),
)
.unwrap_or(0);
(total, active, dead)
};
println!(
"Shortened URLs: {} total ({} active / {} dead)",
@@ -44,14 +85,19 @@ pub async fn run(
);
let pages_count = {
let conn = db.content.lock().unwrap();
crate::db::content::get_landing_page_count(&conn)?
let conn = db.global_landing_pages.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0)
};
println!("Landing Pages: {}", pages_count);
let total_visits = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_total_clicks(&conn)?
let users_conn = db.users.lock().unwrap();
crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
};
println!("Redirect Clicks: {}", total_visits);
+147 -61
View File
@@ -3,6 +3,45 @@ use std::env;
use std::fs;
use std::path::PathBuf;
pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
MissingDataDir,
InvalidConfig(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDataDir => write!(
f,
"data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=<path>"
),
Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataDirSource {
Cli,
Env,
ConfigFile,
}
impl std::fmt::Display for DataDirSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cli => write!(f, "CLI"),
Self::Env => write!(f, "NX9_BZOD_DATA_DIR"),
Self::ConfigFile => write!(f, "config.toml"),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
}
impl Config {
pub fn load() -> Self {
// 1. Built-in defaults
pub fn load() -> Result<Self, ConfigError> {
Self::load_with_cli(None::<&std::path::Path>)
}
pub fn load_with_cli<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
) -> Result<Self, ConfigError> {
Self::load_from_sources(cli_data_dir, true)
}
pub fn load_from_sources<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
load_dotenv: bool,
) -> Result<Self, ConfigError> {
// 1. Built-in defaults (no implicit data_dir default)
let mut host = "0.0.0.0".to_string();
let mut port = 8080u16;
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret =
@@ -63,71 +114,84 @@ impl Config {
let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
toml_path = "config/bzod.toml".to_string();
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host {
host = h;
}
if let Some(p) = toml_config.port {
port = p;
}
if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d);
}
if let Some(u) = toml_config.admin_username {
admin_username = u;
}
if let Some(s) = toml_config.bootstrap_password_sha256 {
bootstrap_password_sha256 = s;
}
if let Some(sec) = toml_config.session_secret {
session_secret = sec;
}
if let Some(c) = toml_config.cookie_secure {
cookie_secure = c;
}
if let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
} else if let Ok(parsed) = ret.parse::<i64>() {
data_retention_days = Some(parsed);
let mut resolved_data_dir: Option<PathBuf> = None;
let mut data_dir_source: Option<DataDirSource> = None;
// 2. Load bzod.toml / config.toml if it exists
let possible_tomls = [
"bzod.toml",
"config/bzod.toml",
"config.toml",
"config/config.toml",
];
for toml_path in possible_tomls {
if let Ok(toml_content) = fs::read_to_string(toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host {
host = h;
}
}
if let Some(lc) = toml_config.link_check_interval_mins {
link_check_interval_mins = lc;
}
if let Some(ag) = toml_config.aggregation_interval_mins {
aggregation_interval_mins = ag;
}
if let Some(b) = toml_config.backup {
if let Some(be) = b.enabled {
backup_enabled = be;
if let Some(p) = toml_config.port {
port = p;
}
if let Some(bi) = b.interval_mins {
backup_interval_mins = bi;
if let Some(d) = toml_config.data_dir {
if !d.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d));
data_dir_source = Some(DataDirSource::ConfigFile);
}
}
if let Some(bo) = b.out_dir {
backup_dir = PathBuf::from(bo);
if let Some(u) = toml_config.admin_username {
admin_username = u;
}
}
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
if let Some(s) = toml_config.bootstrap_password_sha256 {
bootstrap_password_sha256 = s;
}
if let Some(sec) = toml_config.session_secret {
session_secret = sec;
}
if let Some(c) = toml_config.cookie_secure {
cookie_secure = c;
}
if let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
} else if let Ok(parsed) = ret.parse::<i64>() {
data_retention_days = Some(parsed);
}
}
if let Some(lc) = toml_config.link_check_interval_mins {
link_check_interval_mins = lc;
}
if let Some(ag) = toml_config.aggregation_interval_mins {
aggregation_interval_mins = ag;
}
if let Some(b) = toml_config.backup {
if let Some(be) = b.enabled {
backup_enabled = be;
}
if let Some(bi) = b.interval_mins {
backup_interval_mins = bi;
}
if let Some(bo) = b.out_dir {
backup_dir = PathBuf::from(bo);
}
}
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
}
break;
}
}
}
// 3. Load .env if present
let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env");
if load_dotenv {
let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env");
}
}
// 4. Load from Environment Variables (taking highest precedence)
// 4. Load from Environment Variables (taking precedence over config file)
if let Ok(h) = env::var("HOST") {
host = h;
}
@@ -136,8 +200,11 @@ impl Config {
port = p;
}
}
if let Ok(d_str) = env::var("DATA_DIR") {
data_dir = PathBuf::from(d_str);
if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
if !d_str.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d_str));
data_dir_source = Some(DataDirSource::Env);
}
}
if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u;
@@ -187,7 +254,26 @@ impl Config {
base_url = Some(bu);
}
Self {
// 5. CLI override (highest precedence)
if let Some(cli_dir) = cli_data_dir {
let path_ref = cli_dir.as_ref();
if !path_ref.as_os_str().is_empty() {
resolved_data_dir = Some(path_ref.to_path_buf());
data_dir_source = Some(DataDirSource::Cli);
}
}
let data_dir = match resolved_data_dir {
Some(dir) => dir,
None => return Err(ConfigError::MissingDataDir),
};
if let Some(source) = data_dir_source {
tracing::info!("Data directory: {}", data_dir.display());
tracing::info!("Data directory source: {}", source);
}
Ok(Self {
host,
port,
data_dir,
@@ -202,6 +288,6 @@ impl Config {
backup_interval_mins,
backup_dir,
base_url,
}
})
}
}
+14 -4
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> {
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],
)?;
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// 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 {
id: session_id.to_string(),
+243 -3
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
"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, ?11);"
)?;
for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer,
r.accept_language,
r.country,
r.status_code
r.status_code,
r.owner_user_id
])?;
}
}
@@ -508,6 +509,245 @@ pub fn get_metric_rankings_raw(
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_tenant_id: None,
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_tenant_id: None,
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)]
mod tests {
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)
}
/// Get the total count of URLs associated with a specific tag name.
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
let tag_name = tag.trim().to_lowercase();
conn.query_row(
"SELECT COUNT(*) FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1;",
params![tag_name],
|row| row.get(0),
)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
+398
View File
@@ -0,0 +1,398 @@
//! Explicit Phase 3 Identity & Directory Migration Engine.
//!
//! Lifecycle:
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
//! 2. Backup (create pre-migration tarball)
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
//! 5. Validation (verify all users, directories, and databases)
//! 6. Completion Marker (record audit event and system setting)
//!
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
//! to a normal TenantId directory.
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::{is_v08_user_id, Topology};
use crate::db::Db;
use crate::identity::TenantId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct IdentityMigrationReport {
pub total_users: usize,
pub users_assigned_tenant_id: usize,
pub users_assigned_uuid: usize,
pub directories_moved: usize,
pub directories_already_migrated: usize,
pub legacy_admin_preserved: bool,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PreflightPlan {
pub total_users: usize,
pub normal_users: usize,
pub users_needing_tenant_id: Vec<(i64, String)>,
pub users_needing_uuid: Vec<(i64, String)>,
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
pub directories_already_migrated: usize,
}
/// Run the full explicit identity migration lifecycle.
pub async fn run_identity_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
let topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db for Core connections
let db = Db::init(config)?;
// 2. Preflight
let plan = {
let users_conn = db.users.lock().unwrap();
inspect_preflight(&users_conn, &topology)?
};
info!(
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
plan.total_users,
plan.normal_users,
plan.users_needing_tenant_id.len(),
plan.users_needing_uuid.len(),
plan.directories_to_move.len()
);
if dry_run {
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
return Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
users_assigned_uuid: plan.users_needing_uuid.len(),
directories_moved: plan.directories_to_move.len(),
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if there is work to do and backup is not skipped)
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|| !plan.users_needing_uuid.is_empty()
|| !plan.directories_to_move.is_empty();
if needs_migration && !skip_backup {
info!("Creating pre-migration backup...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Identity Migration (users.db backfill)
let (assigned_tids, assigned_uuids) = {
let mut users_conn = db.users.lock().unwrap();
migrate_user_table_identities(&mut users_conn)?
};
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
let moved_dirs = {
let users_conn = db.users.lock().unwrap();
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
};
// 6. Validation
let validation_passed = {
let users_conn = db.users.lock().unwrap();
validate_identity_migration(&users_conn, &topology, &mut warnings)?
};
// 7. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
assigned_tids, assigned_uuids, moved_dirs
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"IDENTITY_MIGRATION_COMPLETED",
"identity",
"users.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
[&now],
);
}
Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: assigned_tids,
users_assigned_uuid: assigned_uuids,
directories_moved: moved_dirs,
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed,
warnings,
})
}
/// Inspect existing database and filesystem to build a preflight plan.
pub fn inspect_preflight(
users_conn: &Connection,
topology: &Topology,
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let total_users = users.len();
let mut normal_users = 0;
let mut users_needing_tenant_id = Vec::new();
let mut users_needing_uuid = Vec::new();
let mut directories_to_move = Vec::new();
let mut directories_already_migrated = 0;
for user in &users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
normal_users += 1;
if user.tenant_id.is_none() {
users_needing_tenant_id.push((user.id, user.username.clone()));
}
if user.uuid.is_none() {
users_needing_uuid.push((user.id, user.username.clone()));
}
if let Some(tid) = user.tenant_id {
let legacy_dir = topology.user_dir_i64(user.id)?;
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
directories_to_move.push((user.id, legacy_dir, tid));
} else if target_dir.exists() {
directories_already_migrated += 1;
}
}
}
Ok(PreflightPlan {
total_users,
normal_users,
users_needing_tenant_id,
users_needing_uuid,
directories_to_move,
directories_already_migrated,
})
}
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
/// Restart-safe: never regenerates or modifies existing identities.
pub fn migrate_user_table_identities(
users_conn: &mut Connection,
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut assigned_tids = 0;
let mut assigned_uuids = 0;
let tx = users_conn.transaction()?;
for user in users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
let mut needs_update = false;
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
let mut uuid_str = user.uuid;
if tid_str.is_none() {
// Allocate unique TenantId
let tid = crate::identity::TenantId::generate();
tid_str = Some(tid.as_str().to_string());
assigned_tids += 1;
needs_update = true;
}
if uuid_str.is_none() {
// Allocate unique UUID
let u = uuid::Uuid::new_v4().to_string();
uuid_str = Some(u);
assigned_uuids += 1;
needs_update = true;
}
if needs_update {
tx.execute(
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
rusqlite::params![tid_str, uuid_str, user.id],
)?;
}
}
tx.commit()?;
Ok((assigned_tids, assigned_uuids))
}
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
/// Legacy users/1 is preserved.
pub fn migrate_tenant_directories(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<usize, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut moved = 0;
for user in users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
// Preserve Core / system accounts intact
continue;
}
let Some(tid) = user.tenant_id else {
warnings.push(format!(
"User '{}' (ID {}) has no TenantId; skipping directory move",
user.username, user.id
));
continue;
};
let legacy_dir = match topology.user_dir_i64(user.id) {
Ok(d) => d,
Err(_) => continue,
};
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
if !target_dir.exists() {
// Atomic rename on same filesystem
fs::rename(&legacy_dir, &target_dir)?;
let _ = fs::create_dir_all(target_dir.join("extensions"));
info!(
"Migrated tenant directory for user '{}': {:?} -> {:?}",
user.username, legacy_dir, target_dir
);
moved += 1;
} else {
// Target already exists (interrupted / partial run)
warn!(
"Target directory {:?} already exists for user '{}'. Verifying contents.",
target_dir, user.username
);
// Ensure extensions dir exists
let _ = fs::create_dir_all(target_dir.join("extensions"));
// If legacy directory is now empty or duplicate, clean it up safely
if let Ok(entries) = fs::read_dir(&legacy_dir) {
if entries.count() == 0 {
let _ = fs::remove_dir(&legacy_dir);
}
}
}
}
}
Ok(moved)
}
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
pub fn validate_identity_migration(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut valid = true;
for user in &users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
// Validate TenantId
match user.tenant_id {
Some(tid) => {
if !is_v08_user_id(tid.as_str()) {
warnings.push(format!(
"Invalid TenantId format '{}' for user '{}'",
tid.as_str(),
user.username
));
valid = false;
}
let target_dir = topology.tenant_dir(tid);
if !target_dir.exists() {
// Check if content db was initialized or if directory was not yet created
warnings.push(format!(
"Tenant directory {:?} does not exist for user '{}'",
target_dir, user.username
));
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing TenantId",
user.username, user.id
));
valid = false;
}
}
// Validate UUID
match &user.uuid {
Some(u) => {
if uuid::Uuid::parse_str(u).is_err() {
warnings.push(format!(
"Invalid UUID format '{}' for user '{}'",
u, user.username
));
valid = false;
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing UUID",
user.username, user.id
));
valid = false;
}
}
}
Ok(valid)
}
+224 -6
View File
@@ -35,7 +35,21 @@ pub fn run_migrations(
);
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
match tx.execute_batch(m.sql) {
Ok(()) => (),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("duplicate column name") {
tracing::warn!(
database = db_name,
version = m.version,
"Column already exists during migration, continuing"
);
} else {
return Err(e.into());
}
}
}
tx.commit()?;
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
@@ -111,10 +125,11 @@ pub fn print_migration_plan(
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
@@ -156,7 +171,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
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] = &[
Migration {
@@ -315,6 +349,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
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] = &[
@@ -384,4 +423,183 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
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';
"#,
},
Migration {
version: 3,
name: "tenant_id",
sql: r#"
ALTER TABLE users ADD COLUMN tenant_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
ON users(tenant_id)
WHERE tenant_id IS NOT NULL;
"#,
},
Migration {
version: 4,
name: "uuid",
sql: r#"
ALTER TABLE users ADD COLUMN uuid TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid)
WHERE uuid IS NOT NULL;
"#,
},
];
+331 -81
View File
@@ -1,91 +1,137 @@
use crate::config::Config;
use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use tracing::info;
pub mod admin;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod identity_migrate;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod schema_v08;
pub mod slug_migrate;
pub mod slugs;
pub mod sqlite;
pub mod tenant;
pub mod topology;
pub mod users;
use crate::db::schema_v08::{
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
};
use crate::db::topology::Topology;
#[derive(Clone)]
pub struct Db {
pub admin: Arc<Mutex<Connection>>,
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub global_urls: Arc<Mutex<Connection>>,
pub global_landing_pages: Arc<Mutex<Connection>>,
pub reserved: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
pub topology: Topology,
}
fn open_prepared(
path: &std::path::Path,
name: &str,
) -> Result<Connection, Box<dyn std::error::Error>> {
info!("Opening {}.db", name);
let conn = Connection::open(path)?;
enable_wal(&conn, name)?;
enable_foreign_keys(&conn, name)?;
Ok(conn)
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
use chrono::Utc;
let topology = Topology::new(&config.data_dir);
if !topology.root().exists() {
fs::create_dir_all(topology.root())?;
}
topology.ensure_core_dirs()?;
let admin_dir = topology.admin_dir();
// Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = topology.legacy_flat_admin_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 = topology.root().join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
let admin_path = config.data_dir.join("admin.db");
let content_path = config.data_dir.join("content.db");
let analytics_path = config.data_dir.join("analytics.db");
let system_path = config.data_dir.join("system.db");
// 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);
}
}
use tracing::info;
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?;
// Enable foreign key support
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?;
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
// Run migrations for system.db first
info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging
// 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");
run_migrations(
&mut admin_conn,
@@ -93,41 +139,247 @@ impl Db {
ADMIN_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running content migrations");
info!("Running users migrations");
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&system_arc),
)?;
info!("Running analytics migrations");
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
Ok(Self {
// 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]);
let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
let mut global_landing_pages_conn =
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
run_migrations(
&mut global_urls_conn,
"global_urls",
GLOBAL_URLS_MIGRATIONS,
Some(&system_arc),
)?;
run_migrations(
&mut global_landing_pages_conn,
"global_landing_pages",
GLOBAL_LANDING_PAGES_MIGRATIONS,
Some(&system_arc),
)?;
run_migrations(
&mut reserved_conn,
"reserved",
RESERVED_SLUGS_MIGRATIONS,
Some(&system_arc),
)?;
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
})
users: Arc::new(Mutex::new(users_conn)),
global_urls: Arc::new(Mutex::new(global_urls_conn)),
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
reserved: Arc::new(Mutex::new(reserved_conn)),
data_dir: config.data_dir.clone(),
topology,
};
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
{
if let (Ok(urls_conn), Ok(pages_conn)) =
(db.global_urls.lock(), db.global_landing_pages.lock())
{
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
Ok(count) => {
if count > 0 {
tracing::info!(
"Cleaned up {} stale reserving slugs from v0.8 registry",
count
);
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
}
Ok(db)
}
pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?;
let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?;
let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?;
let _ = admin.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?;
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
let global_urls = self.global_urls.lock().unwrap();
let _ = global_urls.execute("VACUUM;", []);
let global_landing_pages = self.global_landing_pages.lock().unwrap();
let _ = global_landing_pages.execute("VACUUM;", []);
let reserved = self.reserved.lock().unwrap();
let _ = reserved.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user = {
let conn = self.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
};
let location = crate::db::tenant::location_for_user(&user)?;
let user_dir = location.dir(&self.topology)?;
fs::create_dir_all(user_dir.join("extensions"))?;
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 content_path = match self.topology.content_db_i64(user_id) {
Ok(p) => p,
Err(_) => continue,
};
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(())
}
@@ -136,16 +388,14 @@ impl Db {
#[cfg(test)]
mod db_init_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_db_init() {
let temp_dir = PathBuf::from("./temp_test_db_dir");
let temp_dir = std::env::temp_dir().join(format!("test_db_init_{}", uuid::Uuid::new_v4()));
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
let db = Db::init(&config);
// Cleanup
+90
View File
@@ -0,0 +1,90 @@
//! Independently versioned v0.8 schemas.
//!
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
use super::migrations::Migration;
/// `slugs/global_urls.db` — globally unique URL slugs.
///
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
/// migrate it to the 12-hex user id.
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_urls (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
"#,
},
];
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_landing_pages (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
"#,
},
];
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
"#,
}];
/// Allowed statuses for the v0.8 slug databases.
///
/// `reserving` is an allocation lock, not a published state.
/// Frozen published states: `active`, `disabled`, `retired`.
pub const SLUG_STATUS_RESERVING: &str = "reserving";
pub const SLUG_STATUS_ACTIVE: &str = "active";
pub const SLUG_STATUS_DISABLED: &str = "disabled";
pub const SLUG_STATUS_RETIRED: &str = "retired";
+538
View File
@@ -0,0 +1,538 @@
//! Explicit Phase 4 Global Slug Migration Engine.
//!
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
//! - `slugs/global_urls.db` (`global_urls` table)
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
//! - `slugs/reserved.db` (`reserved_slugs` table)
//!
//! Lifecycle:
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
//! 2. Backup (create pre-migration tarball)
//! 3. Transactional Migration (insert into target databases with restart safety)
//! 4. Validation (row counts, field parity, global uniqueness across databases)
//! 5. Completion Marker (record audit event and system setting)
use rusqlite::Connection;
use std::collections::HashMap;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::Topology;
use crate::db::Db;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SlugMigrationReport {
pub total_legacy_slugs: usize,
pub url_slugs_migrated: usize,
pub page_slugs_migrated: usize,
pub reserved_slugs_migrated: usize,
pub existing_records_verified: usize,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SlugPreflightReport {
pub total_slugs: usize,
pub url_slugs: usize,
pub page_slugs: usize,
pub reserved_slugs: usize,
pub unresolvable_owners: Vec<(String, i64)>,
pub unknown_target_types: Vec<(String, String)>,
}
/// Helper struct for legacy slug record
struct LegacySlugRecord {
slug: String,
owner_user_id: i64,
target_type: String,
target_id: String,
created_at: String,
updated_at: String,
status: String,
deleted_at: Option<String>,
}
/// Run the full explicit global slug migration.
pub async fn run_global_slug_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
let _topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db
let db = Db::init(config)?;
// 2. Preflight
let preflight = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
inspect_slug_preflight(&system_conn, &users_conn)?
};
info!(
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
preflight.total_slugs,
preflight.url_slugs,
preflight.page_slugs,
preflight.reserved_slugs,
preflight.unresolvable_owners.len(),
preflight.unknown_target_types.len()
);
if !preflight.unresolvable_owners.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
preflight.unresolvable_owners.len(),
preflight.unresolvable_owners
);
return Err(msg.into());
}
if !preflight.unknown_target_types.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unknown target_type: {:?}",
preflight.unknown_target_types.len(),
preflight.unknown_target_types
);
return Err(msg.into());
}
if dry_run {
info!("Dry run enabled: no slug records will be migrated.");
return Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: preflight.url_slugs,
page_slugs_migrated: preflight.page_slugs,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: 0,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if needed and not skipped)
if preflight.total_slugs > 0 && !skip_backup {
info!("Creating pre-migration backup before slug migration...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Transactional Migration
let (migrated_urls, migrated_pages, verified_existing) = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
let mut urls_conn = db.global_urls.lock().unwrap();
let mut pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
migrate_slugs_transactional(
&system_conn,
&users_conn,
&mut urls_conn,
&mut pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 5. Validation
let validation_passed = {
let system_conn = db.system.lock().unwrap();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
validate_slug_migration(
&system_conn,
&urls_conn,
&pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 6. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
migrated_urls, migrated_pages, verified_existing
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"GLOBAL_SLUG_MIGRATION_COMPLETED",
"slugs",
"slugs/*.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
[&now],
);
}
Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: migrated_urls,
page_slugs_migrated: migrated_pages,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: verified_existing,
validation_passed,
warnings,
})
}
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
pub fn inspect_slug_preflight(
system_conn: &Connection,
users_conn: &Connection,
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
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),
)?;
if !has_global_slugs {
return Ok(SlugPreflightReport {
total_slugs: 0,
url_slugs: 0,
page_slugs: 0,
reserved_slugs: 0,
unresolvable_owners: Vec::new(),
unknown_target_types: Vec::new(),
});
}
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records = stmt.query_map([], |row| {
Ok(LegacySlugRecord {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})?;
// Build owner map from users.db: user_id -> TenantId/legacy_admin
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
let mut total_slugs = 0;
let mut url_slugs = 0;
let mut page_slugs = 0;
let mut unresolvable_owners = Vec::new();
let mut unknown_target_types = Vec::new();
for r in records {
let rec = r?;
total_slugs += 1;
if !owner_map.contains_key(&rec.owner_user_id) {
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
}
match rec.target_type.as_str() {
"url" => url_slugs += 1,
"page" => page_slugs += 1,
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
}
}
let reserved_slugs: usize = system_conn
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
.unwrap_or(0);
Ok(SlugPreflightReport {
total_slugs,
url_slugs,
page_slugs,
reserved_slugs,
unresolvable_owners,
unknown_target_types,
})
}
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
pub fn migrate_slugs_transactional(
system_conn: &Connection,
users_conn: &Connection,
urls_conn: &mut Connection,
pages_conn: &mut Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
// 1. Build owner map: user_id -> TenantId
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
// 2. Fetch all legacy slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records: Vec<LegacySlugRecord> = stmt
.query_map([], |row| {
Ok(LegacySlugRecord {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})?
.collect::<Result<_, _>>()?;
let mut migrated_urls = 0;
let mut migrated_pages = 0;
let mut verified_existing = 0;
let tx_urls = urls_conn.transaction()?;
let tx_pages = pages_conn.transaction()?;
for rec in records {
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
Some(t) => t.clone(),
None => {
warnings.push(format!(
"Unresolvable owner_user_id {} for slug '{}'; skipping",
rec.owner_user_id, rec.slug
));
continue;
}
};
let retired_at = rec.deleted_at;
if rec.target_type == "url" {
// Check if record already exists in global_urls.db
let existing: Option<(String, String)> = tx_urls
.query_row(
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
rec.slug
));
}
} else {
tx_urls.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_urls += 1;
}
} else if rec.target_type == "page" {
// Check if record already exists in global_landing_pages.db
let existing: Option<(String, String)> = tx_pages
.query_row(
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
rec.slug
));
}
} else {
tx_pages.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_pages += 1;
}
}
}
tx_urls.commit()?;
tx_pages.commit()?;
// Seed reserved slugs
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
Ok((migrated_urls, migrated_pages, verified_existing))
}
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
pub fn validate_slug_migration(
system_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut valid = true;
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),
)?;
if !has_global_slugs {
return Ok(true);
}
let legacy_url_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
[],
|r| r.get(0),
)?;
let legacy_page_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
[],
|r| r.get(0),
)?;
let target_url_count: usize =
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
let target_page_count: usize =
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
r.get(0)
})?;
if target_url_count < legacy_url_count {
warnings.push(format!(
"URL slug count mismatch: legacy has {}, target has {}",
legacy_url_count, target_url_count
));
valid = false;
}
if target_page_count < legacy_page_count {
warnings.push(format!(
"Page slug count mismatch: legacy has {}, target has {}",
legacy_page_count, target_page_count
));
valid = false;
}
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
let collisions_urls_pages: usize = urls_conn.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_urls_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
collisions_urls_pages
));
valid = false;
}
let collisions_reserved_urls: usize = urls_conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
)
.unwrap_or(0);
if collisions_reserved_urls > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
collisions_reserved_urls
));
valid = false;
}
let collisions_reserved_pages: usize = pages_conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_reserved_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
collisions_reserved_pages
));
valid = false;
}
let _ = reserved_conn;
Ok(valid)
}
+472
View File
@@ -0,0 +1,472 @@
//! Frozen v0.8 Slug Registry Layer.
//!
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
//!
//! Guarantees:
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
//! - Concurrency-safe global allocations.
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use crate::db::schema_v08::{
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
};
use crate::identity::TenantId;
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
("admin", "System route"),
("login", "System route"),
("logout", "System route"),
("dashboard", "System route"),
("api", "System route"),
("docs", "System route"),
("assets", "System route"),
("static", "System route"),
("favicon.ico", "System route"),
("robots.txt", "System route"),
("health", "System route"),
("metrics", "System route"),
("install", "System route"),
("setup", "System route"),
("support", "System route"),
("help", "System route"),
("security", "System route"),
("abuse", "System route"),
("billing", "System route"),
("status", "System route"),
("legacy_admin", "System reserved"),
("administrator", "System reserved"),
("system", "System reserved"),
("root", "System reserved"),
("www", "System reserved"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SlugTargetType {
Url,
LandingPage,
}
impl SlugTargetType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Url => "url",
Self::LandingPage => "page",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedSlugInfo {
pub slug: String,
pub owner_tenant_id: String,
pub target_type: SlugTargetType,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub retired_at: Option<String>,
}
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
let mut inserted = 0usize;
for (slug, reason) in CORE_RESERVED_SLUGS {
let n = conn.execute(
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
params![slug, reason],
)?;
inserted += n;
}
Ok(inserted)
}
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
}
/// Check whether a slug is reserved in `slugs/reserved.db`.
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
reserved_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
}
/// Check whether a slug is available for a new registration.
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
/// in any state (including `retired`).
pub fn is_slug_available(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
if is_slug_reserved(reserved_conn, slug)? {
return Ok(false);
}
let in_urls: bool = urls_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_urls {
return Ok(false);
}
let in_pages: bool = pages_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_pages {
return Ok(false);
}
Ok(true)
}
/// Lookup a slug in `slugs/global_urls.db`.
pub fn lookup_url_slug(
urls_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
urls_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Lookup a slug in `slugs/global_landing_pages.db`.
pub fn lookup_landing_page_slug(
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
pages_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
pub fn lookup_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
return Ok(Some(info));
}
lookup_landing_page_slug(pages_conn, slug)
}
/// Register a URL slug in `slugs/global_urls.db`.
pub fn register_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Register a landing page slug in `slugs/global_landing_pages.db`.
pub fn register_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
pub fn reserve_url_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
pub fn reserve_landing_page_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Release a reserving URL slug (e.g. if content creation fails).
pub fn release_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
urls_conn.execute(
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Release a reserving Landing Page slug (e.g. if content creation fails).
pub fn release_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
pages_conn.execute(
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Update URL slug target and activate after reservation.
pub fn activate_url_slug(
urls_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Update Landing Page slug target and activate after reservation.
pub fn activate_landing_page_slug(
pages_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Retire a slug permanently so it cannot be reused.
pub fn retire_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
Ok(n_pages > 0)
}
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
pub fn disable_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
Ok(n_pages > 0)
}
/// Transfer slug ownership to a new tenant.
pub fn transfer_slug_owner(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
new_owner_tenant_id: &TenantId,
new_target_id: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
Ok(n_pages > 0)
}
/// List all slugs owned by a specific tenant.
pub fn list_slugs_by_tenant(
urls_conn: &Connection,
pages_conn: &Connection,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
let mut results = Vec::new();
let mut stmt = urls_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
let mut stmt = pages_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Clean up stale reservations older than threshold seconds.
pub fn cleanup_stale_reservations(
urls_conn: &Connection,
pages_conn: &Connection,
older_than_seconds: i64,
) -> rusqlite::Result<usize> {
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
let n1 = urls_conn.execute(
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
let n2 = pages_conn.execute(
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
Ok(n1 + n2)
}
+266
View File
@@ -0,0 +1,266 @@
//! Tenant database access boundary.
//!
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
//! Unknown ids must not create filesystem databases.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::db::topology::Topology;
use crate::error::AppError;
use crate::identity::TenantId;
use crate::models::TenantUser;
/// Open tenant SQLite connections (content, analytics, profile).
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
/// How a tenant database may be opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TenantOpenMode {
/// Authenticated tenant user / API actor. Status must be `active`.
Ordinary,
/// Public slug/QR/gate resolution. User must exist and not be deleted.
PublicContent,
/// Core jobs / admin inspection. User must exist and not be deleted.
/// Existing files only — will not create a database.
CoreJob,
/// Explicit provisioning after a Core user row was inserted.
Provision,
}
/// Resolved tenant filesystem location.
///
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
/// and exists only until Phase 3 directory migration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TenantLocation {
Id(TenantId),
Legacy(i64),
}
impl TenantLocation {
pub fn cache_key(&self) -> String {
match self {
Self::Id(id) => id.as_str().to_string(),
Self::Legacy(row_id) => format!("legacy:{row_id}"),
}
}
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
match self {
Self::Id(id) => Ok(topology.tenant_dir(*id)),
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
}
}
}
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
if let Some(id) = user.tenant_id {
return Ok(TenantLocation::Id(id));
}
if user.id <= 0 {
return Err(AppError::NotFound("invalid user id".into()));
}
Ok(TenantLocation::Legacy(user.id))
}
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
match mode {
TenantOpenMode::Ordinary => status == "active",
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
status != "deleted"
}
}
}
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
if !status_allows_open(&user.status, mode) {
return Err(AppError::Unauthorized(format!(
"tenant access denied for status '{}'",
user.status
)));
}
Ok(())
}
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
pub fn open_for_row_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
user_id: i64,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
pub fn open_for_tenant_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
tenant_id: TenantId,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
pub fn open_location(
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
location: &TenantLocation,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let key = location.cache_key();
if let Some(dbs) = pool.get(&key) {
return Ok(dbs.clone());
}
let user_dir = location
.dir(topology)
.map_err(|e| AppError::BadRequest(e.to_string()))?;
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 create = matches!(
mode,
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
);
if !create && !content_path.exists() {
return Err(AppError::NotFound(format!(
"tenant database missing at {}",
content_path.display()
)));
}
if create {
std::fs::create_dir_all(user_dir.join("extensions"))?;
}
let dbs = open_files(
&content_path,
&analytics_path,
&profile_path,
system_db,
create,
)?;
pool.insert(key, dbs.clone());
Ok(dbs)
}
fn open_files(
content_path: &Path,
analytics_path: &Path,
profile_path: &Path,
system_db: &Arc<Mutex<Connection>>,
create: bool,
) -> Result<UserDbs, AppError> {
if !create && !content_path.exists() {
return Err(AppError::NotFound("content.db missing".into()));
}
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")?;
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
})
}
/// Job/helper path: registered user, existing content.db only, never create.
pub fn existing_content_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
pub fn existing_analytics_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("analytics.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
+345
View File
@@ -0,0 +1,345 @@
//! Frozen v0.8.0 database topology under a configurable physical root.
//!
//! The logical layout is:
//!
//! ```text
//! <data_dir>/
//! ├── admin/{admin,system,users}.db
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
//! └── users/<user-key>/{profile,content,analytics}.db
//! └── extensions/<extension>/<extension>.db
//! ```
//!
//! `<data_dir>` is `Config.data_dir` (CLI `--data-dir`, env `NX9_BZOD_DATA_DIR`, or config file).
//! It is not renamed to `database/`. Production Docker, CasaOS, and
//! `deploy.sh` bind this physical root.
use std::path::{Path, PathBuf};
use crate::identity::TenantId;
/// Directory name of the migration-era `legacy_admin` tenant (`users.db` id = 1).
pub const LEGACY_ADMIN_USER_KEY: &str = "1";
/// Frozen first-party extension names. There is no runtime plugin loader.
pub const FIRST_PARTY_EXTENSIONS: &[&str] = &["cv", "certificates", "documents", "portfolio"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TopologyError {
InvalidUserDir { name: String },
InvalidExtension { name: String },
}
impl std::fmt::Display for TopologyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidUserDir { name } => {
write!(f, "invalid tenant directory name: {name:?}")
}
Self::InvalidExtension { name } => {
write!(f, "invalid extension name: {name:?}")
}
}
}
}
impl std::error::Error for TopologyError {}
/// Authoritative resolver for every BZOD database path.
#[derive(Clone, Debug)]
pub struct Topology {
root: PathBuf,
}
impl Topology {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn admin_dir(&self) -> PathBuf {
self.root.join("admin")
}
pub fn slugs_dir(&self) -> PathBuf {
self.root.join("slugs")
}
pub fn users_dir(&self) -> PathBuf {
self.root.join("users")
}
pub fn admin_db(&self) -> PathBuf {
self.admin_dir().join("admin.db")
}
pub fn system_db(&self) -> PathBuf {
self.admin_dir().join("system.db")
}
pub fn users_registry_db(&self) -> PathBuf {
self.admin_dir().join("users.db")
}
pub fn global_urls_db(&self) -> PathBuf {
self.slugs_dir().join("global_urls.db")
}
pub fn global_landing_pages_db(&self) -> PathBuf {
self.slugs_dir().join("global_landing_pages.db")
}
pub fn reserved_db(&self) -> PathBuf {
self.slugs_dir().join("reserved.db")
}
/// Pre-multi-tenant files that may still exist at the physical root.
pub fn legacy_flat_admin_db(&self) -> PathBuf {
self.root.join("admin.db")
}
pub fn legacy_flat_system_db(&self) -> PathBuf {
self.root.join("system.db")
}
pub fn legacy_flat_users_db(&self) -> PathBuf {
self.root.join("users.db")
}
pub fn legacy_flat_content_db(&self) -> PathBuf {
self.root.join("content.db")
}
pub fn legacy_flat_analytics_db(&self) -> PathBuf {
self.root.join("analytics.db")
}
pub fn legacy_admin_dir(&self) -> PathBuf {
self.users_dir().join(LEGACY_ADMIN_USER_KEY)
}
/// Frozen tenant directory: `users/<12-hex-TenantId>/`.
pub fn tenant_dir(&self, tenant_id: TenantId) -> PathBuf {
self.users_dir().join(tenant_id.as_str())
}
pub fn tenant_content_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("content.db")
}
pub fn tenant_analytics_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("analytics.db")
}
pub fn tenant_profile_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("profile.db")
}
pub fn user_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_user_dir_name(user_key) {
return Err(TopologyError::InvalidUserDir {
name: user_key.to_string(),
});
}
Ok(self.users_dir().join(user_key))
}
pub fn user_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.user_dir(&user_id.to_string())
}
pub fn content_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("content.db"))
}
pub fn analytics_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("analytics.db"))
}
pub fn profile_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("profile.db"))
}
pub fn content_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.content_db(&user_id.to_string())
}
pub fn analytics_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.analytics_db(&user_id.to_string())
}
pub fn profile_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.profile_db(&user_id.to_string())
}
pub fn extensions_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("extensions"))
}
pub fn extensions_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.extensions_dir(&user_id.to_string())
}
pub fn extension_db(&self, user_key: &str, extension: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_extension_name(extension) {
return Err(TopologyError::InvalidExtension {
name: extension.to_string(),
});
}
Ok(self
.extensions_dir(user_key)?
.join(extension)
.join(format!("{extension}.db")))
}
/// Create `admin/`, `slugs/`, and `users/` under the physical root.
pub fn ensure_core_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(self.admin_dir())?;
std::fs::create_dir_all(self.slugs_dir())?;
std::fs::create_dir_all(self.users_dir())?;
Ok(())
}
/// Create a tenant directory and its `extensions/` folder.
pub fn ensure_user_dirs(&self, user_key: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = self.user_dir(user_key)?;
std::fs::create_dir_all(&dir)?;
std::fs::create_dir_all(dir.join("extensions"))?;
Ok(dir)
}
pub fn ensure_user_dirs_i64(
&self,
user_id: i64,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
self.ensure_user_dirs(&user_id.to_string())
}
}
/// Tenant directory names: 12 lowercase hex (v0.8) or a positive decimal id (v0.7).
pub fn is_valid_user_dir_name(name: &str) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
if name
.as_bytes()
.iter()
.any(|b| *b == b'/' || *b == b'\\' || *b == 0 || *b == b'.')
{
return false;
}
if is_v08_user_id(name) {
return true;
}
is_legacy_integer_user_id(name)
}
pub fn is_v08_user_id(name: &str) -> bool {
name.len() == 12 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn is_legacy_integer_user_id(name: &str) -> bool {
if name.is_empty() || name.as_bytes()[0] == b'0' {
return false;
}
name.bytes().all(|b| b.is_ascii_digit()) && name.parse::<i64>().map(|n| n > 0).unwrap_or(false)
}
/// First-party extension directory names: `^[a-z][a-z0-9_]{0,31}$`.
pub fn is_valid_extension_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
name.len() <= 32
&& name
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn physical_root_is_not_renamed_to_database() {
let t = Topology::new("/var/lib/bzod/data");
assert_eq!(t.root(), Path::new("/var/lib/bzod/data"));
assert!(t.admin_dir().ends_with("data/admin"));
assert!(t.slugs_dir().ends_with("data/slugs"));
assert!(t.users_dir().ends_with("data/users"));
assert!(!t.root().ends_with("database"));
}
#[test]
fn frozen_core_paths() {
let t = Topology::new("/app/data");
assert_eq!(t.admin_db(), PathBuf::from("/app/data/admin/admin.db"));
assert_eq!(t.system_db(), PathBuf::from("/app/data/admin/system.db"));
assert_eq!(
t.users_registry_db(),
PathBuf::from("/app/data/admin/users.db")
);
assert_eq!(
t.global_urls_db(),
PathBuf::from("/app/data/slugs/global_urls.db")
);
assert_eq!(
t.global_landing_pages_db(),
PathBuf::from("/app/data/slugs/global_landing_pages.db")
);
assert_eq!(
t.reserved_db(),
PathBuf::from("/app/data/slugs/reserved.db")
);
}
#[test]
fn tenant_paths_legacy_integer_and_v08_hex() {
let t = Topology::new("/app/data");
assert_eq!(
t.content_db_i64(2).unwrap(),
PathBuf::from("/app/data/users/2/content.db")
);
let tid = crate::identity::TenantId::parse("a1b2c3d4e5f6").unwrap();
assert_eq!(
t.tenant_content_db(tid),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/content.db")
);
assert_eq!(
t.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/extensions/cv/cv.db")
);
}
#[test]
fn rejects_path_traversal_and_forged_names() {
let t = Topology::new("/app/data");
assert!(t.user_dir("..").is_err());
assert!(t.user_dir("../2").is_err());
assert!(t.user_dir("2/../3").is_err());
assert!(t.user_dir("2/foo").is_err());
assert!(t.user_dir("-1").is_err());
assert!(t.user_dir("0").is_err());
assert!(t.user_dir("01").is_err());
assert!(t.user_dir("").is_err());
assert!(t.user_dir("ABCDEFABCDEF").is_err());
assert!(t.content_db_i64(-5).is_err());
assert!(t.content_db_i64(0).is_err());
assert!(t.extension_db("2", "../cv").is_err());
assert!(t.extension_db("2", "cv/db").is_err());
assert!(t.extension_db("2", "").is_err());
assert!(t.extension_db("2", "CV").is_err());
}
#[test]
fn first_party_extension_names_are_valid() {
for name in FIRST_PARTY_EXTENSIONS {
assert!(is_valid_extension_name(name), "{name}");
}
}
}
+992
View File
@@ -0,0 +1,992 @@
use crate::identity::TenantId;
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
let tenant_raw: Option<String> = row.get(9)?;
let tenant_id = match tenant_raw {
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
})?),
None => None,
};
let uuid: Option<String> = row.get(10)?;
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)?,
tenant_id,
uuid,
})
}
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
for _ in 0..16 {
let candidate = TenantId::generate();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
[candidate.as_str()],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique TenantId".into()),
))
}
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
for _ in 0..16 {
let candidate = uuid::Uuid::new_v4().to_string();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
[&candidate],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique UUID".into()),
))
}
// --- 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";
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
user_uuid,
],
)?;
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,
tenant_id: None,
uuid: Some(user_uuid),
})
}
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";
let tenant_id = allocate_unique_tenant_id(conn)?;
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata,
tenant_id.as_str(),
user_uuid,
],
)?;
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()),
tenant_id: Some(tenant_id),
uuid: Some(user_uuid),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
params![id],
map_user,
)
.optional()
}
pub fn get_user_by_tenant_id(
conn: &Connection,
tenant_id: TenantId,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
params![tenant_id.as_str()],
map_user,
)
.optional()
}
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
params![uuid],
map_user,
)
.optional()
}
pub fn get_user_by_username(
conn: &Connection,
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
params![username],
map_user,
)
.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(&format!(
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
))?;
let rows = stmt.query_map([], map_user)?;
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 ---
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
)]
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)
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
)]
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(())
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
)]
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(())
}
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
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<String>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path =
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_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".to_string());
}
}
}
}
// 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".to_string());
}
}
}
}
}
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
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 crate::db::topology::is_valid_user_dir_name(name_str) {
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(name_str.to_string());
}
}
}
}
// 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(name_str.to_string());
}
}
}
}
}
}
}
}
}
}
}
// 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,
})
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
)]
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 topology = crate::db::topology::Topology::new(data_dir);
let content_db_path = {
let users_path = topology.users_registry_db();
if let Ok(users_conn) = Connection::open(&users_path) {
crate::db::tenant::existing_content_path(
&users_conn,
&topology,
owner_user_id,
)
.ok()
.or_else(|| {
if owner_user_id == 1 {
Some(topology.legacy_flat_content_db())
} else {
None
}
})
.unwrap_or_else(|| topology.legacy_flat_content_db())
} else if owner_user_id == 1 {
topology.legacy_flat_content_db()
} else {
topology
.content_db_i64(owner_user_id)
.unwrap_or_else(|_| topology.legacy_flat_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)
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
)]
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(())
}
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
pub fn get_platform_total_clicks(
topology: &crate::db::topology::Topology,
users_conn: &Connection,
) -> Option<i64> {
let mut stmt = users_conn
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
.ok()?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
let mut total = 0i64;
for tid_str in rows.flatten() {
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
let analytics_path = topology.tenant_analytics_db(tid);
if analytics_path.exists() {
if let Ok(conn) = Connection::open(&analytics_path) {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
.unwrap_or(0);
total += count;
}
}
}
}
Some(total)
}
+139
View File
@@ -0,0 +1,139 @@
//! Frozen v0.8 tenant identity.
//!
//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12
//! lowercase hexadecimal characters, CSPRNG-generated, independent of username
//! and of SQLite row ids.
use rand::{thread_rng, RngCore};
use std::fmt;
use std::str::FromStr;
/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TenantId {
hex: [u8; Self::LEN],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantIdError {
InvalidLength { got: usize },
InvalidCharacter { found: char },
}
impl fmt::Display for TenantIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLength { got } => {
write!(
f,
"TenantId must be {} lowercase hex characters, got {got}",
TenantId::LEN
)
}
Self::InvalidCharacter { found } => {
write!(f, "TenantId must be lowercase hexadecimal, found {found:?}")
}
}
}
}
impl std::error::Error for TenantIdError {}
impl TenantId {
pub const LEN: usize = 12;
/// Cryptographically secure random TenantId. Never derived from user data.
pub fn generate() -> Self {
let mut bytes = [0u8; 6];
thread_rng().fill_bytes(&mut bytes);
let encoded = hex::encode(bytes);
Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars")
}
pub fn parse(s: &str) -> Result<Self, TenantIdError> {
if s.len() != Self::LEN {
return Err(TenantIdError::InvalidLength { got: s.len() });
}
if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) {
return Err(TenantIdError::InvalidCharacter { found });
}
let mut hex = [0u8; Self::LEN];
hex.copy_from_slice(s.as_bytes());
Ok(Self { hex })
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex")
}
}
impl fmt::Display for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TenantId").field(&self.as_str()).finish()
}
}
impl FromStr for TenantId {
type Err = TenantIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl serde::Serialize for TenantId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for TenantId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_lowercase_12_hex() {
let id = TenantId::parse("fafafa12c3e4").unwrap();
assert_eq!(id.as_str(), "fafafa12c3e4");
assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap());
}
#[test]
fn parse_rejects_uppercase_and_wrong_length() {
assert!(matches!(
TenantId::parse("FAFAFA12C3E4"),
Err(TenantIdError::InvalidCharacter { found: 'F' })
));
assert!(matches!(
TenantId::parse("abc"),
Err(TenantIdError::InvalidLength { got: 3 })
));
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
assert!(TenantId::parse("../etc/passwd").is_err());
assert!(TenantId::parse("gggggggggggg").is_err());
}
#[test]
fn generate_is_opaque_lowercase_hex() {
let a = TenantId::generate();
let b = TenantId::generate();
assert_ne!(a, b);
assert_eq!(a.as_str().len(), 12);
assert!(a
.as_str()
.chars()
.all(|c| matches!(c, '0'..='9' | 'a'..='f')));
}
}
+44 -17
View File
@@ -5,28 +5,58 @@ use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day;
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 {
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...");
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");
match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
let mut failed = false;
let mut err_msg = None;
for user_id in user_ids {
if let Err(e) = perform_aggregation(&db, user_id).await {
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>> {
let date_range = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_visits_date_range(&conn)?
};
pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let mut conn = super::open_user_analytics_conn(db, user_id)?;
let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
@@ -35,10 +65,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
let mut curr = min;
while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string();
{
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
aggregate_day(&mut conn, &date_str)?;
if curr == max {
break;
}
+73 -8
View File
@@ -4,7 +4,11 @@ use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) {
pub async fn run_backup_scheduler(
db: Db,
config: Config,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
if !config.backup_enabled {
info!("Background backup scheduler is disabled.");
return;
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
);
loop {
// 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...");
let job_id = log_job_start(&db.system, "database_backup");
@@ -51,6 +61,54 @@ pub async fn perform_backup(
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.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let user_ids: Vec<i64> = 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)) {
rows.filter_map(|r| r.ok()).collect()
} else {
Vec::new()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
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);", []);
}
}
if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name);
@@ -59,12 +117,19 @@ pub async fn perform_backup(
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let db_file = config.data_dir.join(f);
if db_file.exists() {
tar.append_path_with_name(&db_file, f)?;
}
let admin_dir = db.topology.admin_dir();
if admin_dir.exists() {
tar.append_dir_all("admin", &admin_dir)?;
}
let slugs_dir = db.topology.slugs_dir();
if slugs_dir.exists() {
tar.append_dir_all("slugs", &slugs_dir)?;
}
let users_dir = db.topology.users_dir();
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
}
tar.into_inner()?.finish()?;
+67 -8
View File
@@ -1,22 +1,81 @@
use crate::db::Db;
use std::time::Duration;
use tracing::info;
use tracing::{error, info, warn};
/// Background job that marks expired URLs.
///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) {
///
/// 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 {
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 conn = db.content.lock().unwrap();
crate::db::content::expire_urls(&conn).unwrap_or(0)
let user_ids: Vec<i64> = {
let conn = match db.users.lock() {
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 {
info!(expired_count = count, "Expired URLs marked");
let mut total_expired = 0;
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 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()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
loop {
// 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...");
let job_id = log_job_start(&db.system, "link_checker");
@@ -37,40 +48,50 @@ pub async fn perform_link_check(
db: &Db,
client: &Client,
) -> Result<(), Box<dyn std::error::Error>> {
let urls = {
let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)?
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
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 {
let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
for user_id in user_ids {
let conn = match super::open_user_content_conn(db, user_id) {
Ok(c) => c,
Err(_) => continue,
};
// Log to system.db.health_checks
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
for (id, dest) in urls {
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(())
}
+38
View File
@@ -1,3 +1,4 @@
use crate::db::Db;
use chrono::Utc;
use rusqlite::{params, Connection};
use std::sync::Mutex;
@@ -35,3 +36,40 @@ 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 = {
let users = db.users.lock().map_err(|_| {
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
})?;
crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
};
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 = {
let users = db.users.lock().map_err(|_| {
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
})?;
crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
};
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()
};
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
let users_conn = db.users.lock().unwrap();
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 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 {
Some(days) => days,
None => return,
@@ -12,21 +16,59 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
loop {
// 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...");
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 conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => {
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
let mut total_cleaned = 0;
let mut failed = false;
let mut err_msg = None;
for user_id in user_ids {
match super::open_user_analytics_conn(&db, user_id) {
Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => total_cleaned += count,
Err(e) => {
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);
}
}
}
+2
View File
@@ -1,10 +1,12 @@
pub mod analytics;
pub mod auth;
pub mod build_info;
pub mod charts;
pub mod cli;
pub mod config;
pub mod db;
pub mod error;
pub mod identity;
pub mod jobs;
pub mod models;
pub mod services;
+76 -2
View File
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init();
let cli = Cli::parse();
let config = Config::load();
let config = match Config::load_with_cli(cli.command.data_dir()) {
Ok(config) => config,
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(1);
}
};
match cli.command {
Commands::Serve {
@@ -38,12 +44,80 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Validate { data_dir } => {
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 } => {
bzod::cli::create_admin::run(username, data_dir, config).await?;
bzod::cli::create_admin::run(username, None, data_dir, config).await?;
}
Commands::InitAdmin { data_dir } => {
bzod::cli::init_admin::run(data_dir, config).await?;
}
Commands::Doctor { data_dir } => {
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(())
+4 -1
View File
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
pub use audit::AuditLog;
pub use page::LandingPage;
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};
+208
View File
@@ -15,3 +15,211 @@ pub struct Session {
pub expires_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>,
/// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3).
pub tenant_id: Option<crate::identity::TenantId>,
/// Frozen v0.8 immutable UUID.
pub uuid: Option<String>,
}
impl TenantUser {
pub fn require_tenant_id(&self) -> Result<crate::identity::TenantId, crate::error::AppError> {
self.tenant_id.ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id))
})
}
pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> {
self.uuid.as_deref().ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id))
})
}
}
#[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,
}
}
}
+5
View File
@@ -1,3 +1,4 @@
use crate::identity::TenantId;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -12,6 +13,10 @@ pub struct VisitRecord {
pub accept_language: String,
pub country: String,
pub status_code: u16,
#[serde(default)]
pub owner_tenant_id: Option<TenantId>,
#[serde(default)]
pub owner_user_id: Option<i64>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
+132
View File
@@ -0,0 +1,132 @@
//! 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;
use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY};
/// 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/`
/// - `{data_dir}/slugs/` is created empty if missing (v0.8 topology)
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let topology = Topology::new(data_dir);
let admin_dir = topology.admin_dir();
let users_1_dir = topology.legacy_admin_dir();
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
std::fs::create_dir_all(topology.slugs_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 {
let topology = Topology::new(data_dir);
Self {
admin: topology.admin_db(),
system: topology.system_db(),
users: topology.users_registry_db(),
content: topology
.content_db(LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
analytics: topology
.analytics_db(LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
}
}
}
#[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("slugs").is_dir());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! 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::identity::TenantId;
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(urls_conn: &Connection, slugs: &[String], owner_tenant_id: &TenantId) {
for slug in slugs {
let _ = crate::db::slugs::release_url_slug(urls_conn, slug, owner_tenant_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.
#[allow(clippy::too_many_arguments)]
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
reserved_db: &Mutex<Connection>,
global_urls_db: &Mutex<Connection>,
global_landing_pages_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
owner_tenant_id: TenantId,
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,
reserved_db,
global_urls_db,
global_landing_pages_db,
&owner_tenant_id,
item,
&mut reserved_slugs,
) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs in v0.8 global_urls.db
{
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls {
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id);
}
}
// 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<'_>,
reserved_db: &Mutex<Connection>,
global_urls_db: &Mutex<Connection>,
global_landing_pages_db: &Mutex<Connection>,
owner_tenant_id: &TenantId,
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 !crate::utils::validation::validate_redirect_code(&code) {
return Err(BulkUrlError::BadRequest(format!(
"Short code or slug '{code}' is invalid (must be 6 hex characters or !custom-slug)"
)));
}
{
let reserved_conn = crate::utils::lock_db(reserved_db, "reserved_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available =
crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_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::slugs::reserve_url_slug(
&reserved_conn,
&urls_conn,
&pages_conn,
&code,
owner_tenant_id,
) {
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}")))
}
+258
View File
@@ -0,0 +1,258 @@
//! 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 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(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
let users = db
.users
.lock()
.map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
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, 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());
}
}
+7 -7
View File
@@ -1,23 +1,23 @@
use crate::db::Db;
use crate::error::AppError;
use crate::models::LandingPage;
pub fn create_landing_page(
db: &Db,
conn: &rusqlite::Connection,
code: &str,
slug: &str,
title: &str,
html_content: &str,
state: &str,
) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?;
Ok(page)
}
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
pub fn get_landing_page_by_code(
conn: &rusqlite::Connection,
code: &str,
) -> Result<Option<LandingPage>, AppError> {
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
Ok(page)
}
+6
View File
@@ -1,6 +1,12 @@
pub mod api_keys;
pub mod audit;
pub mod backup_layout;
pub mod bulk;
pub mod bulk_urls;
pub mod destination_audit;
pub mod landing_pages;
pub mod qr;
pub mod registry_validator;
pub mod shortener;
pub mod slug_transfer;
pub mod urls;
+365
View File
@@ -0,0 +1,365 @@
use crate::db::topology::Topology;
use crate::identity::TenantId;
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryIssueType {
MissingTenant,
MissingTarget,
CorruptDatabase,
AccessFailure,
TrueOrphan,
Conflict,
StaleReservation,
InvalidStatus,
InvalidTargetType,
}
#[derive(Debug, Clone)]
pub struct RegistryIssue {
pub slug: String,
pub target_type: String,
pub owner_tenant_id: String,
pub database_path: PathBuf,
pub target_id: String,
pub issue_type: RegistryIssueType,
pub description: String,
}
pub struct RegistryValidator;
impl RegistryValidator {
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
/// and returns a list of detected issues categorized per safety policies.
pub fn scan(
_system_conn: &Connection,
users_conn: &Connection,
data_dir: &Path,
slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
let topology = Topology::new(data_dir);
let mut issues = Vec::new();
let urls_path = topology.global_urls_db();
let pages_path = topology.global_landing_pages_db();
let reserved_path = topology.reserved_db();
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_tenant_id: "".to_string(),
database_path: urls_path,
target_id: "".to_string(),
issue_type: RegistryIssueType::AccessFailure,
description: "One or more v0.8 slug databases are missing from disk".to_string(),
});
return Ok(issues);
}
let urls_conn = Connection::open(&urls_path)?;
let pages_conn = Connection::open(&pages_path)?;
let reserved_conn = Connection::open(&reserved_path)?;
// 1. Scan global_urls.db
Self::scan_table(
&urls_conn,
users_conn,
&reserved_conn,
&pages_conn,
&topology,
"url",
slug_filter,
&mut issues,
)?;
// 2. Scan global_landing_pages.db
Self::scan_table(
&pages_conn,
users_conn,
&reserved_conn,
&urls_conn,
&topology,
"page",
slug_filter,
&mut issues,
)?;
Ok(issues)
}
#[allow(clippy::too_many_arguments)]
fn scan_table(
conn: &Connection,
users_conn: &Connection,
reserved_conn: &Connection,
other_conn: &Connection,
topology: &Topology,
target_type: &str,
slug_filter: Option<&str>,
issues: &mut Vec<RegistryIssue>,
) -> Result<(), Box<dyn std::error::Error>> {
let (query, params_vec) = if let Some(slug) = slug_filter {
(
format!(
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
if target_type == "url" { "url" } else { "landing_page" }
),
vec![slug.to_string()],
)
} else {
(
format!(
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
if target_type == "url" {
"url"
} else {
"landing_page"
}
),
vec![],
)
};
let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_tenant_id_str: String = row.get(1)?;
let target_id: String = row.get(2)?;
let created_at_str: String = row.get(3)?;
let status: String = row.get(4)?;
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
let content_db_path = match tenant_id_res {
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
};
// 1. Conflict with reserved.db
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: topology.reserved_db(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::Conflict,
description: format!("Slug '{}' conflicts with a reserved system route", slug),
});
}
// 2. Conflict with the other slug database
let other_table = if target_type == "url" {
"global_landing_pages"
} else {
"global_urls"
};
let in_other: bool = other_conn
.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
if in_other {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::Conflict,
description: format!(
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
slug
),
});
}
// 3. Status check
if status != "active"
&& status != "disabled"
&& status != "reserving"
&& status != "retired"
{
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status),
});
}
// If retired, no active target check is needed
if status == "retired" {
continue;
}
// 4. Owner tenant check in users.db
let tid = match tenant_id_res {
Ok(t) => t,
Err(_) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path,
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTenant,
description: format!(
"Slug '{}' has invalid TenantId '{}'",
slug, owner_tenant_id_str
),
});
continue;
}
};
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
let owner_exists = match owner_opt {
Some(ref u) => u.status != "deleted",
None => false,
};
if !owner_exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTenant,
description: format!(
"Slug '{}' references missing or deleted owner tenant '{}'",
slug, owner_tenant_id_str
),
});
continue;
}
// 5. Stale reservation 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.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
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
),
});
}
}
}
// 6. Target record check in tenant content DB
if status == "active" || status == "disabled" {
if !content_db_path.exists() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::TrueOrphan,
description: format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
),
});
} else {
match Connection::open(&content_db_path) {
Ok(tenant_conn) => {
let exists = if target_type == "url" {
tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
};
if !exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
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 tenant content database",
slug, target_type, target_id
),
});
}
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
{
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::CorruptDatabase,
description: format!(
"Slug '{}' owner content database is corrupt at {:?}",
slug, content_db_path
),
});
}
Err(e) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::AccessFailure,
description: format!(
"Slug '{}' owner content database could not be accessed: {}",
slug, e
),
});
}
}
}
}
}
Ok(())
}
}
+9 -7
View File
@@ -1,22 +1,24 @@
use crate::db::Db;
use crate::error::AppError;
use crate::models::Url;
pub fn create_url(
db: &Db,
conn: &rusqlite::Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
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 url = crate::db::content::create_url(conn, code, destination, title, description, tags)?;
Ok(url)
}
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::get_url_by_code(&conn, code)?;
pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
let url = crate::db::content::get_url_by_code(conn, code)?;
Ok(url)
}
+292
View File
@@ -0,0 +1,292 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates v0.8 slug
//! databases ownership. Handlers own admin auth and HTTP mapping.
use crate::db::tenant::TenantOpenMode;
use crate::identity::TenantId;
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
#[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 old_owner_tenant_id: TenantId,
pub new_owner_tenant_id: TenantId,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in v0.8 slug databases (`global_urls.db` / `global_landing_pages.db`).
pub fn lookup_slug(
state: &AppState,
slug: &str,
) -> Result<crate::db::slugs::ResolvedSlugInfo, TransferError> {
match state.lookup_slug(slug) {
Ok(Some(info)) => Ok(info),
Ok(None) => Err(TransferError::NotFound("Slug not found")),
Err(e) => Err(TransferError::Internal(e.to_string())),
}
}
/// 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 + v0.8 slug registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let slug_info = lookup_slug(state, &req.slug)?;
let target_type = slug_info.target_type.as_str().to_string();
let old_owner_tenant_id = TenantId::parse(&slug_info.owner_tenant_id).map_err(|_| {
TransferError::Internal(format!(
"Invalid owner tenant ID '{}' on slug '{}'",
slug_info.owner_tenant_id, req.slug
))
})?;
// Look up old owner user row in users.db
let (old_owner_user_id, new_owner_tenant_id) = {
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let old_user = crate::db::users::get_user_by_tenant_id(&users_conn, old_owner_tenant_id)
.map_err(|e| TransferError::Internal(e.to_string()))?
.ok_or_else(|| {
TransferError::Internal(format!(
"Current owner user for tenant {old_owner_tenant_id} not found"
))
})?;
let new_user = crate::db::users::get_user_by_id(&users_conn, req.new_owner_user_id)
.map_err(|e| TransferError::Internal(e.to_string()))?
.ok_or_else(|| {
TransferError::BadRequest(format!(
"Target user ID {} not found",
req.new_owner_user_id
))
})?;
if new_user.account_type == "admin" {
return Err(TransferError::BadRequest(
"Target user cannot be an Admin account (must be a tenant account)".into(),
));
}
let new_tid = new_user.tenant_id.ok_or_else(|| {
TransferError::BadRequest("Target user has no TenantId allocated".into())
})?;
(old_user.id, new_tid)
};
if old_owner_tenant_id == new_owner_tenant_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.open_tenant(old_owner_tenant_id, TenantOpenMode::CoreJob)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.open_tenant(new_owner_tenant_id, TenantOpenMode::Provision)
.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,
)?;
// Update authoritative v0.8 slug databases
{
let urls_conn = lock_db(&state.db.global_urls, "global_urls")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let pages_conn = lock_db(&state.db.global_landing_pages, "global_landing_pages")
.map_err(|e| TransferError::Internal(e.to_string()))?;
crate::db::slugs::transfer_slug_owner(
&urls_conn,
&pages_conn,
&req.slug,
&new_owner_tenant_id,
&new_target_id,
)
.map_err(|e| TransferError::Internal(format!("Failed to update slug registry: {e}")))?;
}
// Write audit event and history
{
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(
"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 tenant {} (user {}) to tenant {} (user {})",
old_owner_tenant_id, old_owner_user_id, new_owner_tenant_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
old_owner_tenant_id,
new_owner_tenant_id,
target_type,
new_target_id,
})
}
Loaded 100 of 219 files, more files were not shown because too many files have changed in this diff. Show more