51 Commits
Author SHA1 Message Date
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
148 changed files with 32119 additions and 1859 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Dependencies & Build artifacts
target/
**/target/
# Environment & secrets
.env
.env.*
*.key
*.pem
# Development & testing files
.git/
.gitignore
.github/
.gitattributes
# Documentation & notes
README*.md
docs/
CONTRIBUTING.md
CHANGELOG.md
LICENSE
# Logs & temporary files
*.log
*.tmp
data/
backups/
# Editor & IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Docker related
Dockerfile*
.dockerignore
docker-compose*.yml
.docker/
# Test files
tests/
__tests__/
*.test.*
*.spec.*
# Other unnecessary files
node_modules/
dist/
build/
+80 -17
View File
@@ -1,22 +1,85 @@
name: Rust name: Rust CI
on: on:
push: push:
branches: [ "main" ] branches: ["main"]
pull_request: pull_request:
branches: [ "main" ] branches: ["main"]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs: jobs:
build: test:
name: Test & Quality Checks
runs-on: ubuntu-latest
runs-on: ubuntu-latest steps:
- name: Checkout Repository
uses: actions/checkout@v4
steps: - name: Install Rust Toolchain
- uses: actions/checkout@v4 uses: dtolnay/rust-toolchain@stable
- name: Build with:
run: cargo build --verbose components: rustfmt, clippy
- name: Run tests
run: cargo test --verbose - name: Cache Cargo Dependencies
uses: Swatinem/rust-cache@v2
- name: Check Formatting
run: cargo fmt --check
- name: Run Clippy
run: cargo clippy --all-targets --all-features -- -D warnings
- name: Build Release
run: cargo build --release --verbose
- name: Run Tests
run: cargo test --all-features --verbose
docker:
name: Build Docker Image
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- name: Checkout Repository
uses: actions/checkout@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha
type=raw,value=latest,enable={{is_default_branch}}
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and Push Docker Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Generated
+78 -536
View File
@@ -29,24 +29,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "aligned"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685"
dependencies = [
"as-slice",
]
[[package]]
name = "aligned-vec"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b"
dependencies = [
"equator",
]
[[package]] [[package]]
name = "android_system_properties" name = "android_system_properties"
version = "0.1.5" version = "0.1.5"
@@ -121,17 +103,6 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "arg_enum_proc_macro"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "argon2" name = "argon2"
version = "0.5.3" version = "0.5.3"
@@ -144,21 +115,6 @@ dependencies = [
"password-hash", "password-hash",
] ]
[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
name = "as-slice"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516"
dependencies = [
"stable_deref_trait",
]
[[package]] [[package]]
name = "askama" name = "askama"
version = "0.12.1" version = "0.12.1"
@@ -200,7 +156,7 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0" checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [ dependencies = [
"nom 7.1.3", "nom",
] ]
[[package]] [[package]]
@@ -226,49 +182,6 @@ version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "av-scenechange"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394"
dependencies = [
"aligned",
"anyhow",
"arg_enum_proc_macro",
"arrayvec",
"log",
"num-rational",
"num-traits",
"pastey",
"rayon",
"thiserror",
"v_frame",
"y4m",
]
[[package]]
name = "av1-grain"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8"
dependencies = [
"anyhow",
"arrayvec",
"log",
"nom 8.0.0",
"num-rational",
"v_frame",
]
[[package]]
name = "avif-serialize"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38"
dependencies = [
"arrayvec",
]
[[package]] [[package]]
name = "axum" name = "axum"
version = "0.7.9" version = "0.7.9"
@@ -289,6 +202,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
@@ -381,27 +295,12 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]]
name = "bitstream-io"
version = "4.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f"
dependencies = [
"no_std_io2",
]
[[package]] [[package]]
name = "blake2" name = "blake2"
version = "0.10.6" version = "0.10.6"
@@ -420,12 +319,6 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "built"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9"
[[package]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.20.3" version = "3.20.3"
@@ -452,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.1.0" version = "0.5.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
@@ -462,6 +355,7 @@ dependencies = [
"clap", "clap",
"dotenvy", "dotenvy",
"flate2", "flate2",
"futures-util",
"hex", "hex",
"image", "image",
"qrcode", "qrcode",
@@ -479,6 +373,7 @@ dependencies = [
"tracing-subscriber", "tracing-subscriber",
"uuid", "uuid",
"zip", "zip",
"zstd",
] ]
[[package]] [[package]]
@@ -559,12 +454,6 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -582,6 +471,24 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "cookie_store"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
dependencies = [
"cookie",
"document-features",
"idna",
"log",
"publicsuffix",
"serde",
"serde_derive",
"serde_json",
"time",
"url",
]
[[package]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@@ -606,37 +513,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
dependencies = [
"crossbeam-epoch",
"crossbeam-utils",
]
[[package]]
name = "crossbeam-epoch"
version = "0.9.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@@ -689,18 +571,21 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "document-features"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
dependencies = [
"litrs",
]
[[package]] [[package]]
name = "dotenvy" name = "dotenvy"
version = "0.15.7" version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
[[package]]
name = "either"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
[[package]] [[package]]
name = "encoding_rs" name = "encoding_rs"
version = "0.8.35" version = "0.8.35"
@@ -710,26 +595,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "equator"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc"
dependencies = [
"equator-macro",
]
[[package]]
name = "equator-macro"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -746,21 +611,6 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "exr"
version = "1.74.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -779,12 +629,6 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fax"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
[[package]] [[package]]
name = "fdeflate" name = "fdeflate"
version = "0.3.7" version = "0.3.7"
@@ -850,6 +694,17 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-macro"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "futures-task" name = "futures-task"
version = "0.3.32" version = "0.3.32"
@@ -863,6 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [ dependencies = [
"futures-core", "futures-core",
"futures-macro",
"futures-task", "futures-task",
"pin-project-lite", "pin-project-lite",
"slab", "slab",
@@ -918,27 +774,6 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@@ -1239,38 +1074,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder-lite", "byteorder-lite",
"color_quant",
"exr",
"gif",
"image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png", "png",
"qoi",
"ravif",
"rayon",
"rgb",
"tiff",
"zune-core",
"zune-jpeg",
] ]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
name = "imgref"
version = "1.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7"
[[package]] [[package]]
name = "indexmap" name = "indexmap"
version = "2.14.0" version = "2.14.0"
@@ -1283,17 +1091,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "interpolate_name"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.0" version = "2.12.0"
@@ -1306,15 +1103,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
dependencies = [
"either",
]
[[package]] [[package]]
name = "itoa" name = "itoa"
version = "1.0.18" version = "1.0.18"
@@ -1354,28 +1142,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libfuzzer-sys"
version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2"
dependencies = [
"arbitrary",
"cc",
]
[[package]] [[package]]
name = "libm" name = "libm"
version = "0.2.16" version = "0.2.16"
@@ -1405,6 +1177,12 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "litrs"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
[[package]] [[package]]
name = "lock_api" name = "lock_api"
version = "0.4.14" version = "0.4.14"
@@ -1420,15 +1198,6 @@ version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]]
name = "loop9"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062"
dependencies = [
"imgref",
]
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
version = "0.1.2" version = "0.1.2"
@@ -1450,16 +1219,6 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "maybe-rayon"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519"
dependencies = [
"cfg-if",
"rayon",
]
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.1"
@@ -1536,21 +1295,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "no_std_io2"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"
@@ -1561,21 +1305,6 @@ dependencies = [
"minimal-lexical", "minimal-lexical",
] ]
[[package]]
name = "nom"
version = "8.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
dependencies = [
"memchr",
]
[[package]]
name = "noop_proc_macro"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
[[package]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1585,53 +1314,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.2.2" version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
]
[[package]] [[package]]
name = "num-traits" name = "num-traits"
version = "0.2.19" version = "0.2.19"
@@ -1687,18 +1375,6 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pastey"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -1774,22 +1450,19 @@ dependencies = [
] ]
[[package]] [[package]]
name = "profiling" name = "psl-types"
version = "1.0.18" version = "2.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
dependencies = [
"profiling-procmacros",
]
[[package]] [[package]]
name = "profiling-procmacros" name = "publicsuffix"
version = "1.0.18" version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
dependencies = [ dependencies = [
"quote", "idna",
"syn", "psl-types",
] ]
[[package]] [[package]]
@@ -1798,15 +1471,6 @@ version = "0.1.29"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]] [[package]]
name = "qrcode" name = "qrcode"
version = "0.14.1" version = "0.14.1"
@@ -1816,12 +1480,6 @@ dependencies = [
"image", "image",
] ]
[[package]]
name = "quick-error"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]] [[package]]
name = "quinn" name = "quinn"
version = "0.11.9" version = "0.11.9"
@@ -1957,76 +1615,6 @@ dependencies = [
"getrandom 0.3.4", "getrandom 0.3.4",
] ]
[[package]]
name = "rav1e"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b"
dependencies = [
"aligned-vec",
"arbitrary",
"arg_enum_proc_macro",
"arrayvec",
"av-scenechange",
"av1-grain",
"bitstream-io",
"built",
"cfg-if",
"interpolate_name",
"itertools",
"libc",
"libfuzzer-sys",
"log",
"maybe-rayon",
"new_debug_unreachable",
"noop_proc_macro",
"num-derive",
"num-traits",
"paste",
"profiling",
"rand 0.9.4",
"rand_chacha 0.9.0",
"simd_helpers",
"thiserror",
"v_frame",
"wasm-bindgen",
]
[[package]]
name = "ravif"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45"
dependencies = [
"avif-serialize",
"imgref",
"loop9",
"quick-error",
"rav1e",
"rayon",
"rgb",
]
[[package]]
name = "rayon"
version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d"
dependencies = [
"either",
"rayon-core",
]
[[package]]
name = "rayon-core"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
dependencies = [
"crossbeam-deque",
"crossbeam-utils",
]
[[package]] [[package]]
name = "redox_syscall" name = "redox_syscall"
version = "0.5.18" version = "0.5.18"
@@ -2061,6 +1649,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [ dependencies = [
"base64", "base64",
"bytes", "bytes",
"cookie",
"cookie_store",
"futures-core", "futures-core",
"http", "http",
"http-body", "http-body",
@@ -2091,12 +1681,6 @@ dependencies = [
"webpki-roots", "webpki-roots",
] ]
[[package]]
name = "rgb"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -2320,15 +1904,6 @@ version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
[[package]]
name = "simd_helpers"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6"
dependencies = [
"quote",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -2446,20 +2021,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "tiff"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
dependencies = [
"fax",
"flate2",
"half",
"quick-error",
"weezl",
"zune-jpeg",
]
[[package]] [[package]]
name = "time" name = "time"
version = "0.3.47" version = "0.3.47"
@@ -2775,17 +2336,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "v_frame"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2"
dependencies = [
"aligned-vec",
"num-traits",
"wasm-bindgen",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -2955,12 +2505,6 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -3295,12 +2839,6 @@ dependencies = [
"rustix", "rustix",
] ]
[[package]]
name = "y4m"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.3" version = "0.8.3"
@@ -3440,25 +2978,29 @@ dependencies = [
] ]
[[package]] [[package]]
name = "zune-core" name = "zstd"
version = "0.5.1" version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [ dependencies = [
"simd-adler32", "zstd-safe",
] ]
[[package]] [[package]]
name = "zune-jpeg" name = "zstd-safe"
version = "0.5.15" version = "7.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
dependencies = [ dependencies = [
"zune-core", "zstd-sys",
]
[[package]]
name = "zstd-sys"
version = "2.0.16+zstd.1.5.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748"
dependencies = [
"cc",
"pkg-config",
] ]
+36 -4
View File
@@ -1,11 +1,31 @@
[package] [package]
name = "bzod" name = "bzod"
version = "0.1.0" description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.5.2"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener"
homepage = "https://bzo.in"
documentation = "https://github.com/thakares/nx9-url-shortener"
readme = "README.md"
authors = ["Sunil P. Thakare"]
keywords = [
"url-shortener",
"landing-pages",
"analytics",
"qr-code",
"self-hosted"
]
categories = [
"web-programming",
"command-line-utilities"
]
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["cookie"] } axum-extra = { version = "0.9", features = ["cookie"] }
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
@@ -19,7 +39,7 @@ askama = { version = "0.12" }
argon2 = "0.5" argon2 = "0.5"
sha2 = "0.10" sha2 = "0.10"
rand = "0.8" rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
tar = "0.4" tar = "0.4"
flate2 = "1.0" flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -27,5 +47,17 @@ hex = "0.4"
time = "0.3" time = "0.3"
toml = "0.8" toml = "0.8"
qrcode = "0.14" qrcode = "0.14"
image = "0.25" image = { version = "0.25", default-features = false, features = ["png"] }
zip = { version = "2.1", default-features = false, features = ["deflate"] } zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3"
zstd = "0.13"
[lints.clippy]
let_unit_value = "allow"
useless_vec = "allow"
[profile.release]
lto = true
codegen-units = 1
strip = true
panic = "abort"
+20 -21
View File
@@ -1,5 +1,5 @@
# ========================================== # ==========================================
# Stage 1: Build # Stage 1: Builder (with optimized caching)
# ========================================== # ==========================================
FROM rust:1.89-bookworm AS builder FROM rust:1.89-bookworm AS builder
@@ -9,28 +9,27 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy Cargo metadata # Copy only Cargo files first (best caching)
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies for layer caching # Create dummy source for dependency caching
RUN mkdir src && echo "fn main() {}" > src/main.rs RUN mkdir -p src && \
RUN cargo build --release echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
RUN rm -rf src cargo build --release && \
rm -rf src target/release/deps/bzod*
# Copy application source # Copy real source code + assets
COPY src ./src COPY src ./src
COPY templates ./templates COPY templates ./templates
COPY www ./www COPY www ./www
# Build application # Build the real application
RUN touch src/main.rs
RUN cargo build --release RUN cargo build --release
# ========================================== # ==========================================
# Stage 2: Runtime # Stage 2: Runtime (slim)
# ========================================== # ==========================================
FROM debian:bookworm-slim FROM debian:bookworm-slim
@@ -43,32 +42,32 @@ RUN apt-get update && apt-get install -y \
curl \ curl \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Application binary # Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Runtime assets # Copy assets
COPY templates ./templates COPY --from=builder /app/templates ./templates
COPY www ./www COPY --from=builder /app/www ./www
# Create non-root user # Create non-root user
RUN groupadd -g 1000 bzod && \ RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod useradd -u 1000 -g bzod -m -s /bin/bash bzod
# Create writable data directory # Create data directory
RUN mkdir -p /app/data && \ RUN mkdir -p /app/data && \
chown -R bzod:bzod /app chown -R bzod:bzod /app
USER bzod USER bzod
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data \
ENV PORT=8654 PORT=8654 \
ENV HOST=0.0.0.0 HOST=0.0.0.0 \
ENV COOKIE_SECURE=true COOKIE_SECURE=true
EXPOSE 8654 EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8654}/status || exit 1 CMD curl -f http://localhost:${PORT}/status || exit 1
ENTRYPOINT ["bzod"] ENTRYPOINT ["bzod"]
CMD ["serve"] CMD ["serve"]
View File
File renamed without changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sunil Purushottam Thakare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+1003 -315
View File
File diff suppressed because it is too large. Load diff
+155 -76
View File
@@ -1,11 +1,9 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# BZOD Production Deployment Script
# BZOD Deployment Script (Debian Native Deployment) # curl -fsSL https://bzo.in/deploy.sh | sudo bash
# This script sets up a secure, production-ready systemd service for BZOD.
set -euo pipefail set -euo pipefail
# Configurations
SERVICE_USER="bzod" SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod" INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod" CONFIG_DIR="/etc/bzod"
@@ -13,93 +11,134 @@ DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env" ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service" SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
# Color outputs
RED='\033[0;31m' RED='\033[0;31m'
GREEN='\033[0;32m' GREEN='\033[0;32m'
BLUE='\033[0;34m' BLUE='\033[0;34m'
NC='\033[0m' # No Color NC='\033[0m'
echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}" # Temporary file cleanup
TMP_BINARY=""
cleanup() {
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
}
trap cleanup EXIT
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
echo -e "Production deployment started...\n"
# 1. Check Root Privileges
if [ "$EUID" -ne 0 ]; then if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}" echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
exit 1 exit 1
fi fi
# 2. Install Package Dependencies # 1. Install Base Dependencies
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}" echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
apt-get update apt-get update -qq
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
# 3. Compile Production Build Locally # 2. Install Binary (safe atomic download)
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}" echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
if ! command -v cargo &> /dev/null; then
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}" ARCH="$(uname -m)"
case $ARCH in
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
REPO="thakares/nx9-url-shortener"
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
TMP_BINARY=$(mktemp)
echo "Trying GitHub Releases..."
if curl --retry 5 --retry-delay 2 --retry-connrefused \
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
else
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
if command -v docker >/dev/null 2>&1; then
TMP_GHCR=$(mktemp)
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
mv "${TMP_GHCR}" "${TMP_BINARY}"
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
fi
fi
if [ ! -s "${TMP_BINARY}" ]; then
echo -e "${BLUE}Falling back to local build...${NC}"
if ! command -v cargo >/dev/null 2>&1; then
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
exit 1
fi
apt-get install -y pkg-config build-essential
cargo build --release
cp target/release/bzod "${TMP_BINARY}"
echo -e "${GREEN}✓ Built from source${NC}"
fi
fi
# Atomic replace with backup
if [ -f "${INSTALL_PATH}" ]; then
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
fi
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
# Verify
if [ ! -x "${INSTALL_PATH}" ]; then
echo -e "${RED}Binary installation failed${NC}"
exit 1 exit 1
fi fi
cargo build --release "${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
echo -e "${GREEN}Release build completed.${NC}" echo -e "${RED}Binary verification failed${NC}"
exit 1
}
# 4. Install Binary # Show installed version
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}" VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
cp target/release/bzod "${INSTALL_PATH}" echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
chmod 755 "${INSTALL_PATH}"
chown root:root "${INSTALL_PATH}"
echo -e "${GREEN}Binary installed successfully.${NC}"
# 5. Create Dedicated locked-down System User # 3. Create System User
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}" echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
if ! id -u "${SERVICE_USER}" &>/dev/null; then if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}" useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
else
echo "User '${SERVICE_USER}' already exists."
fi fi
# 6. Configure Directory Trees and Permissions # 4. Setup Directories
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}" echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
mkdir -p "${CONFIG_DIR}" mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
mkdir -p "${DATA_DIR}" chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
chmod 700 "${CONFIG_DIR}"
# Copy .env file if it exists, otherwise prompt/generate # 5. Configuration (preserve on upgrades)
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then echo -e "\n${BLUE}[5/8] Configuration...${NC}"
echo "Copying local .env file to ${ENV_FILE}..." if [ ! -f "${ENV_FILE}" ]; then
cp .env "${ENV_FILE}" echo -e "${BLUE}Generating new secure configuration...${NC}"
elif [ ! -f "${ENV_FILE}" ]; then
echo "Generating default configuration file at ${ENV_FILE}..."
cat <<EOF > "${ENV_FILE}" cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0 HOST=0.0.0.0
PORT=8080 PORT=8654
DATA_DIR=${DATA_DIR} DATA_DIR=${DATA_DIR}
COOKIE_SECURE=true COOKIE_SECURE=true
RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32) SESSION_SECRET=$(openssl rand -hex 32)
ADMIN_USERNAME=admin
# SHA-256 for bootstrap (Default: admin)
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
EOF EOF
chmod 600 "${ENV_FILE}"
chown root:"${SERVICE_USER}" "${ENV_FILE}"
else
echo -e "${GREEN}Existing configuration preserved${NC}"
fi fi
chmod 600 "${ENV_FILE}" # 6. Systemd Service
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}" echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
# 7. Initialise DB as the service user (avoids file permission conflicts)
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}Databases initialised.${NC}"
# 8. Set Up Systemd Service
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}" cat <<EOF > "${SYSTEMD_UNIT}"
[Unit] [Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
After=network.target After=network-online.target
Wants=network-online.target
[Service] [Service]
Type=simple Type=simple
@@ -107,11 +146,12 @@ User=${SERVICE_USER}
Group=${SERVICE_USER} Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE} EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR} ExecStart=${INSTALL_PATH} serve
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
# Hardening / Sandboxing options for security # Security Hardening
ProtectSystem=strict ProtectSystem=strict
ProtectHome=yes ProtectHome=yes
PrivateTmp=yes PrivateTmp=yes
@@ -119,6 +159,10 @@ PrivateDevices=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ProtectControlGroups=yes ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
LockPersonality=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/bzod ReadWritePaths=/var/lib/bzod
[Install] [Install]
@@ -127,21 +171,56 @@ EOF
chmod 644 "${SYSTEMD_UNIT}" chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload systemctl daemon-reload
echo -e "${GREEN}Systemd service registered.${NC}"
# 9. Enable and Start the Service # 7. Initialize & Start
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}" echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
systemctl enable bzod
systemctl restart bzod
sleep 2 if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
if systemctl is-active --quiet bzod; then runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}BZOD service is running successfully!${NC}" echo -e "${GREEN}✓ Databases initialized${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 else
echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}" echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
fi fi
systemctl enable --now bzod
# 8. Validation + Rollback
sleep 3
if ! systemctl is-active --quiet bzod; then
echo -e "${RED}Service failed to start! Rolling back...${NC}"
if [ -f "${INSTALL_PATH}.bak" ]; then
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
systemctl restart bzod || true
fi
journalctl -u bzod -n 50 --no-pager
exit 1
fi
# Clean up backup on success
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
# Soft health check
if command -v curl >/dev/null 2>&1; then
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
echo -e "${GREEN}✓ HTTP health check passed${NC}"
else
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
fi
fi
# Final Message
IP=$(hostname -I | awk '{print $1}' | head -n1)
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
echo -e "🌐 Web UI: http://${IP}:8654"
echo -e "🔑 Admin: http://${IP}:8654/admin"
echo -e "🖥 Architecture: ${ARCH}"
echo -e "📦 Version: ${VERSION}"
echo -e "\nNext step (first install):"
echo -e " sudo -u bzod bzod create-admin"
echo -e "\nCommands:"
echo -e " journalctl -u bzod -f"
echo -e " bzod doctor"
echo -e " systemctl status bzod"
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
+20 -27
View File
@@ -1,67 +1,60 @@
name: app-bzod name: app-bzod
services: services:
bzod: bzod:
image: nx9-url-shortener:v0.1.0
build: build:
context: . context: /DATA/AppData/bzod
dockerfile: Dockerfile dockerfile: Dockerfile
cpu_shares: 90
command: []
container_name: bzod container_name: bzod
deploy:
resources:
limits:
memory: 31940M
environment: environment:
- COOKIE_SECURE=false - COOKIE_SECURE=false
- DATA_DIR=/app/data - DATA_DIR=/app/data
- HOST=0.0.0.0 - HOST=0.0.0.0
- PORT=8654 - PORT=8654
- RUST_LOG=info - RUST_LOG=info
hostname: bzod
image: nx9-url-shortener:v0.4.0
ports: ports:
- mode: ingress - mode: ingress
target: 8654 target: 8654
published: "8654" published: "8654"
protocol: tcp protocol: tcp
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- type: bind - type: bind
source: /DATA/AppData/bzod/data source: /DATA/AppData/bzod/data
target: /app/data target: /app/data
bind: bind:
create_host_path: true create_host_path: true
- type: bind - type: bind
source: /DATA/AppData/bzod/config source: /DATA/AppData/bzod/config
target: /app/config target: /app/config
bind: bind:
create_host_path: true create_host_path: true
- type: bind
source: /DATA/AppData/bzod/www
target: /app/www
devices: []
cap_add: []
networks: networks:
- default - default
hostname: bzod
privileged: false privileged: false
cpu_shares: 90
deploy:
resources:
limits:
memory: 31940M
networks: networks:
default: default:
name: app_default name: app_default
x-casaos: x-casaos:
hostname: ""
scheme: http
index: /
port_map: "8654"
author: self author: self
category: self category: self
hostname: ""
icon: "" icon: ""
index: /
is_uncontrolled: false
port_map: "8654"
scheme: http
title: title:
custom: nx9-url-shortener custom: nx9-url-shortener
+888
View File
@@ -0,0 +1,888 @@
# BZOD Administrator Guide
Version: v0.5.1
---
# 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
```
+650
View File
@@ -0,0 +1,650 @@
# BZOD Architecture Guide
Version: v0.5.1
---
# 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.rs
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
↓
Record analytics
↓
302 Redirect
```
---
## 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
v0.5.0 includes more than 90 automated 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 v0.5.0 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.5.1
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.
+292
View File
@@ -0,0 +1,292 @@
# 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.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
+271
View File
@@ -0,0 +1,271 @@
# 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.5.1 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
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
---
# URL Management
## Create Short URL
```bash
bzod shorten https://example.com
```
## Expand Existing URL
```bash
bzod expand abc123
```
Returns the destination URL associated with the slug.
---
# Administrator Management
## Create Administrator
```bash
bzod create-admin admin
```
Creates a new administrator account.
---
# User Management
## List Users
```bash
bzod list-users
```
Displays all users in the platform.
## Create User
```bash
bzod create-user alice
```
Creates a new standard user.
## Disable User
```bash
bzod disable-user alice
```
Blocks login and invalidates sessions.
## Enable User
```bash
bzod enable-user alice
```
Re-enables a disabled user.
## Reset Password
```bash
bzod reset-password alice
```
Resets a user's password.
## Delete User
```bash
bzod delete-user alice
```
Deletes:
* User account
* User databases
* Sessions
* API tokens
* Slug ownership
---
# User Backup Operations
## Backup User
```bash
bzod backup-user alice
```
Creates a portable `.tar.zst` archive containing all user-owned data.
## Restore User
```bash
bzod restore-user alice.tar.zst
```
Restores a user from a previously generated archive.
---
# Recommended Maintenance
Daily:
```bash
bzod doctor
```
Weekly:
```bash
bzod backup
```
Before Upgrades:
```bash
bzod backup
bzod validate
```
After Upgrades:
```bash
bzod migrate
bzod doctor
```
---
# Related Documentation
* INSTALL.md
* MULTI_USER.md
* ADMIN_GUIDE.md
* BACKUP_RESTORE.md
* SECURITY.md
* API.md
* ARCHITECTURE.md
+354
View File
@@ -0,0 +1,354 @@
# BZOD v0.5.1 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.5.1 uses SQLite exclusively.
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
---
# Overview
BZOD stores data in the following structure:
```text
data/
├── admin/
│ ├── admin.db
│ ├── system.db
│ └── users.db
│
└── users/
├── 1/
│ ├── analytics.db
│ ├── content.db
│ └── profile.db
│
├── 2/
│ ├── analytics.db
│ ├── content.db
│ └── profile.db
│
└── N/
├── analytics.db
├── content.db
└── profile.db
```
Each user receives isolated databases.
No user content or analytics are stored in the central administrative databases.
---
# Administrative Databases
Administrative databases are located under:
```text
data/admin/
```
---
# users.db
Primary authentication and user management database.
Purpose:
* User accounts
* Password hashes
* Sessions
* Quotas
* API tokens
* User status tracking
Typical tables:
```text
users
sessions
quotas
api_tokens
```
Responsibilities:
* Authentication
* Authorization
* Session management
* Account status
* Quota enforcement
This is the primary identity database of the platform.
---
# system.db
Global platform database.
Purpose:
* Global slug namespace
* Moderation
* Auditing
* System configuration
Typical tables:
```text
global_slugs
slug_history
moderation_events
audit_events
reserved_slugs
settings
```
Responsibilities:
* Global slug uniqueness
* Slug ownership
* Moderation actions
* Audit logging
* System settings
Every redirect ultimately resolves through records stored in this database.
---
# admin.db
Administrative application database.
Purpose:
* Administrative metadata
* Administrative API key records
* Legacy compatibility structures
* Internal management data
Typical tables:
```text
api_keys
audit_events
```
This database is reserved for administrative functions and does not store tenant content.
---
# Tenant Databases
Tenant databases are located under:
```text
data/users/{user_id}/
```
Each user owns a completely isolated set of databases.
Example:
```text
data/users/2/
├── analytics.db
├── content.db
└── profile.db
```
---
# content.db
Stores user-owned content.
Purpose:
* Short URLs
* Landing pages
* QR metadata
* Preview metadata
Typical tables:
```text
urls
pages
qr_codes
previews
```
Responsibilities:
* URL management
* Landing page management
* Content ownership
This database contains the actual resources owned by a user.
---
# analytics.db
Stores traffic and visitor information.
Purpose:
* Visit recording
* Referrer tracking
* Browser tracking
* Country statistics
* Aggregated analytics
Typical tables:
```text
visits
referrers
browsers
countries
daily_stats
```
Responsibilities:
* Analytics collection
* Reporting
* Dashboard statistics
Analytics are fully isolated per user.
Administrators access aggregated analytics by querying each user's analytics database.
---
# profile.db
Stores user-specific profile information.
Purpose:
* User preferences
* Profile settings
* Future extensible metadata
Typical tables:
```text
profile
preferences
```
Responsibilities:
* User profile management
* Dashboard preferences
* Future personalization features
---
# Database Isolation Model
BZOD follows a strict tenant isolation model.
```text
User A
├── content.db
├── analytics.db
└── profile.db
User B
├── content.db
├── analytics.db
└── profile.db
```
User databases never share tables.
Cross-user content access is prevented by design.
Benefits:
* Security
* Easier backups
* Easier deletion
* Reduced corruption impact
---
# Global Slug Registry
The system maintains a single namespace.
Stored in:
```text
system.db
```
Table:
```text
global_slugs
```
Example:
```text
abc123 → User 2 URL
docs → User 5 Page
demo → User 1 URL
```
This guarantees:
* Global uniqueness
* Ownership tracking
* Moderation support
* Slug transfer support
---
# Write Flow
Creating a URL:
```text
1. Validate quota
2. Register slug in system.db
3. Create URL in content.db
4. Update quota counters
5. Write audit event
```
Creating a landing page:
```text
1. Validate quota
2. Register slug in system.db
3. Create page in content.db
4. Update quota counters
5. Write audit event
```
---
# Analytics Flow
Visitor request:
```text
GET /abc123
```
Process:
```text
global_slugs
↓
content.db lookup
↓
redirect
↓
analytics.db visit record
```
Analytics writes never modify content records.
---
# WAL Mode
All databases operate in SQLite WAL mode.
Verify:
```sql
PRAGMA journal_mode;
```
Expected:
```text
wal
```
Benefits:
* Improved concurrency
* Reduced write contention
* Crash recovery
Associated files:
```text
*.db
*.db-shm
*.db-wal
```
---
# WAL Checkpointing
Large WAL files are normal during heavy traffic.
Example:
```text
analytics.db-wal
content.db-wal
```
To manually checkpoint:
```sql
PRAGMA wal_checkpoint(TRUNCATE);
```
The healthcheck and backup jobs may trigger checkpoints automatically.
---
# Backups
Recommended:
```bash
bzod backup
```
This creates a consistent archive of:
```text
admin/
users/
```
Never manually copy live databases while the application is running.
---
# Integrity Verification
Run:
```bash
bzod doctor
```
Or:
```sql
PRAGMA integrity_check;
```
Expected:
```text
ok
```
---
# Migration System
BZOD maintains schema versions using:
```sql
PRAGMA user_version;
```
Startup automatically executes:
```text
Db::init()
```
which:
1. Creates missing databases
2. Applies migrations
3. Validates schemas
4. Repairs legacy installations when required
---
# Design Principles
BZOD database architecture prioritizes:
* SQLite-only deployment
* Multi-user isolation
* Operational simplicity
* Backup friendliness
* Easy disaster recovery
* Minimal dependencies
* Single-binary deployment
---
# Related Documentation
* ARCHITECTURE.md
* MULTI_USER.md
* BACKUP_RESTORE.md
* INSTALL.md
* UPGRADE.md
* SECURITY.md
+545
View File
@@ -0,0 +1,545 @@
# Docker Deployment Guide for BZOD
This guide covers deployment, upgrades, backup, restore, troubleshooting, and production best practices for **BZOD (nx9-url-shortener)** using Docker.
---
# Overview
BZOD is a lightweight self-hosted URL shortener and landing page platform written in Rust.
Features include:
* URL shortening
* Human-readable custom slugs (`!office`, `!home`, etc.)
* Landing pages
* QR code generation
* Analytics
* Audit logging
* API access
* Backup and restore
* SQLite-based storage
* Docker deployment
BZOD is designed to remain simple:
* No PostgreSQL
* No Redis
* No external dependencies
* No vendor lock-in
---
# Quick Start
## Clone Repository
```bash
git clone https://github.com/thakares/nx9-url-shortener.git
cd nx9-url-shortener
```
## Build and Start
```bash
docker compose up -d --build
```
## Create Administrator
```bash
docker exec -it bzod bzod create-admin
```
Open:
```text
http://SERVER-IP:8654
```
Admin panel:
```text
http://SERVER-IP:8654/admin
```
---
# Docker Compose
Example:
```yaml
services:
bzod:
container_name: bzod
build: .
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- ./data:/app/data
- ./config:/app/config
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "./bzod", "doctor"]
interval: 30s
timeout: 10s
retries: 3
```
Start:
```bash
docker compose up -d
```
Verify:
```bash
docker ps
docker logs -f bzod
```
---
# Directory Layout
Typical deployment:
```text
bzod/
├── docker-compose.yml
├── Dockerfile
├── config/
├── data/
│ ├── admin.db
│ ├── content.db
│ ├── analytics.db
│ └── system.db
└── backups/
```
---
# Root Landing Page
BZOD can serve a static landing page from:
```text
www/index.html
```
This page is available at:
```text
https://your-domain/
```
Examples:
```text
https://bzo.in/
https://short.example.com/
```
The root landing page is packaged automatically inside the Docker image.
---
# Reverse Proxy Configuration
BZOD is intended to run behind a reverse proxy.
Example Nginx configuration:
```nginx
server {
server_name bzo.in;
location / {
proxy_pass http://127.0.0.1:8654;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Example deployment:
```text
Internet
↓
Nginx Proxy Manager
↓
BZOD Docker Container
```
---
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
Production recommendation:
```text
COOKIE_SECURE=true
```
when HTTPS is enabled.
---
# Analytics Export
Analytics pages support:
* Raw visitor logs
* CSV export
* JSON export
* Date filtering
Exports can be generated from:
Admin → Analytics
Backup
---
## Web UI
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Click:
```text
Download Backup
```
A compressed archive containing all databases will be downloaded.
---
## CLI Backup
Create backup:
```bash
docker exec -it bzod bzod backup
```
Example output:
```text
backup-2026-06-14.tar.gz
```
---
# Restore
## Web UI Restore
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Upload:
```text
backup.tar.gz
```
Type:
```text
RESTORE
```
Confirm restore.
The system will:
1. Validate archive contents
2. Restore databases
3. Reinitialize database access
4. Redirect to login
---
## CLI Restore
Copy backup archive into container or mounted volume.
Run:
```bash
docker exec -it bzod bash
cd /app/data
bzod restore --file backup.tar.gz
```
---
# Disaster Recovery
Example recovery procedure:
```bash
docker compose down
# Restore backup archive
docker compose up -d
```
Verify:
```bash
docker exec -it bzod bzod doctor
docker exec -it bzod bzod validate
```
Check:
* URLs
* Landing pages
* Analytics
* Audit logs
* Settings
---
# Useful CLI Commands
Health:
```bash
docker exec -it bzod bzod doctor
```
Statistics:
```bash
docker exec -it bzod bzod stats
```
Validate databases:
```bash
docker exec -it bzod bzod validate
```
Create admin:
```bash
docker exec -it bzod bzod create-admin
```
Shorten URL:
```bash
docker exec -it bzod bzod shorten https://example.com
```
Custom slug:
```bash
docker exec -it bzod bzod shorten https://example.com --slug !office
```
Expand URL:
```bash
docker exec -it bzod bzod expand !office
```
---
# Upgrading
Pull latest source:
```bash
git pull
```
Rebuild:
```bash
docker compose build --no-cache
```
Restart:
```bash
docker compose up -d
```
Verify:
```bash
docker logs -f bzod
```
# Upgrading to v0.4.0
1. Backup databases
2. Pull latest source
3. Rebuild container
4. Restart service
```bash
git pull
docker compose build --no-cache
docker compose up -d
---
# Troubleshooting
## Read-Only SQLite Database
Symptoms:
```text
attempt to write a readonly database
```
Check ownership:
```bash
ls -lah data/
```
Fix permissions:
```bash
docker exec -u 0 -it bzod bash
chown -R bzod:bzod /app/data
```
docker exec -it bzod bzod doctor
Restart:
```bash
docker compose restart bzod
```
---
## Missing Root Landing Page
Symptoms:
```text
404 on /
```
Verify:
```bash
docker exec -it bzod ls -lah /app/www
```
Expected:
```text
/app/www/index.html
```
Rebuild image if necessary:
```bash
docker compose build --no-cache
docker compose up -d
```
---
## Health Check Failure
Inspect logs:
```bash
docker logs bzod
```
Run:
```bash
docker exec -it bzod bzod doctor
```
---
## Port Already In Use
Change host port mapping:
```yaml
ports:
- "8080:8654"
```
Access:
```text
http://SERVER-IP:8080
```
---
# Production Recommendations
* Use HTTPS
* Run behind Nginx Proxy Manager or Nginx
* Use strong administrator credentials
* Schedule regular backups
* Periodically test restore procedures
* Monitor disk space
* Keep Docker images updated
---
# Validation Checklist
After deployment verify:
* [ ] Admin login works
* [ ] URL shortening works
* [ ] Custom slugs work
* [ ] Landing pages work
* [ ] QR generation works
* [ ] Analytics recorded
* [ ] Backup download works
* [ ] Restore workflow works
* [ ] Root landing page loads
* [ ] `bzod doctor` reports healthy
A deployment should not be considered production-ready until backup and restore procedures have been successfully tested.
+604
View File
@@ -0,0 +1,604 @@
# BZOD Installation Guide
Version: v0.5.1
---
# 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.5.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.5.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.5.1
---
# 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.
+290
View File
@@ -0,0 +1,290 @@
# 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.
+662
View File
@@ -0,0 +1,662 @@
# BZOD Security Guide
Version: v0.5.1
---
# 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.5.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
```
---
# 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.5.0 includes tests covering:
* Authentication
* Authorization
* Session validation
* CSRF enforcement
* Slug ownership
* User isolation
* Upgrade migrations
* Backup integrity
* Disaster recovery
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.5.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.
+364 -284
View File
@@ -1,34 +1,68 @@
# TESTING.md # BZOD Testing & Validation Guide
# BZOD Test Procedures ## Overview
This document describes the official verification procedures for BZOD. BZOD follows a defense-in-depth validation strategy.
The objective is not merely to confirm that code compiles, but to ensure that the complete platform can be built, deployed, backed up, restored, migrated, and recovered successfully. 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.
--- ---
# Philosophy # Validation Philosophy
BZOD prioritizes: BZOD prioritizes:
1. Data Integrity 1. Namespace Integrity
2. Operational Simplicity 2. Data Integrity
3. Recovery Capability 3. Multi-Tenant Isolation
4. Deployment Reproducibility 4. Operational Simplicity
5. Functional Correctness 5. Recovery Capability
6. Security
7. Functional Correctness
A passing unit test suite alone is insufficient. A successful release is not merely one that runs.
A release is considered valid only if backup, restore, migration, and recovery procedures have been verified. A successful release is one that can be recovered.
--- ---
# Test Categories # Automated Test Coverage
## 1. Build Verification Current validation suite includes:
Verify the application compiles successfully. * 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 ```bash
cargo check cargo check
@@ -36,244 +70,79 @@ cargo build
cargo build --release cargo build --release
``` ```
Expected Result: Expected:
* No compiler errors * No compilation failures
* No panics during startup * Release binary generated
* Release binary generated successfully
--- ---
## 2. Static Analysis # 2. Formatting Validation
```bash ```bash
cargo fmt --check cargo fmt --check
cargo clippy --all-targets
``` ```
Expected Result: Expected:
* Formatting passes * No formatting errors
* No significant Clippy warnings
--- ---
## 3. Unit Tests # 3. Static Analysis
```bash ```bash
cargo test cargo clippy --all-targets -- -D warnings
``` ```
Expected Result: Expected:
* Zero warnings
* Zero errors
---
# 4. Complete Automated Test Suite
```bash
cargo test --all-targets -- --nocapture
```
Expected:
* All tests pass * All tests pass
* No failures
* No ignored critical tests * No ignored critical tests
--- ---
## 4. Database Initialization # 5. Database Initialization Validation
Create a clean environment. Create clean environment:
```bash
rm -rf data
./bzod stats
```
Expected Result:
* Databases are automatically created
* Migrations applied successfully
Verify:
```bash
./bzod doctor
```
Expected Result:
```text
Overall status: HEALTHY
```
---
## 5. Migration Verification
Run migrations repeatedly.
```bash
./bzod migrate
./bzod migrate
./bzod migrate
```
Expected Result:
* No duplicate migrations
* No errors
* Schema remains stable
---
## 6. Administrator Creation
Create an administrator account.
```bash
./bzod create-admin
```
Expected Result:
* User created successfully
* Authentication works
Attempt duplicate creation:
```bash
./bzod create-admin
```
Expected Result:
* Duplicate username rejected
---
## 7. Backup Verification
Create backup archive.
```bash
./bzod backup
```
Expected Result:
* Backup archive generated
* Archive contains all databases
Verify:
```bash
tar -tzf backup-*.tar.gz
```
Expected Result:
```text
admin.db
content.db
analytics.db
system.db
```
---
## 8. Restore Verification
Create sample data.
Generate:
* Administrator
* URL records
* Landing pages
* Analytics records
Create backup:
```bash
./bzod backup
```
Delete databases:
```bash ```bash
rm -rf data rm -rf data
``` ```
Restore:
```bash
./bzod restore --file backup.tar.gz
```
Expected Result:
* Restore completes successfully
* All records preserved
Verify:
```bash
./bzod doctor
./bzod stats
```
Expected Result:
```text
Overall status: HEALTHY
```
and original record counts preserved.
---
## 9. Disaster Recovery Test
This is the most important test.
Procedure:
1. Backup system.
2. Delete entire data directory.
3. Restore backup.
4. Start server.
5. Login to Admin UI.
Commands:
```bash
./bzod backup
rm -rf data
./bzod restore --file backup.tar.gz
./bzod serve
```
Expected Result:
* System fully operational
* No manual database repair required
---
## 10. Database Health Verification
Run: Run:
```bash ```bash
./bzod doctor bzod stats
``` ```
Expected Result: Expected:
For every database: * Database hierarchy created
* Migrations applied
* System healthy
```text Validate:
Integrity: ok
Foreign keys: enabled ```bash
Journal mode: wal bzod doctor
``` ```
Final result: Expected:
```text ```text
Overall status: HEALTHY Overall status: HEALTHY
@@ -281,124 +150,335 @@ Overall status: HEALTHY
--- ---
## 11. SQLite Integrity Checks # 6. Namespace Integrity Validation
Manual verification. BZOD maintains a global slug namespace.
```bash The following must never coexist:
sqlite3 data/admin.db "PRAGMA integrity_check;"
sqlite3 data/content.db "PRAGMA integrity_check;"
sqlite3 data/analytics.db "PRAGMA integrity_check;"
sqlite3 data/system.db "PRAGMA integrity_check;"
```
Expected Result:
```text ```text
ok Admin URL
hello
User URL
hello
Landing Page
hello
``` ```
for all databases. Validate:
```bash
bzod doctor
```
Expected:
```text
No namespace conflicts detected
```
Duplicate slugs must abort upgrade and restore operations.
--- ---
## 12. Web Interface Verification # 7. Multi-User Isolation Validation
Start server. Verify:
```bash * User A cannot access User B URLs
./bzod serve * 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: Verify:
* Homepage loads ```text
* Redirects function Content-Type: image/png
* Landing pages render Content-Type: image/svg+xml
* Admin login works ```
* Dashboard loads
* API endpoints respond Disabled resources:
```http
410 Gone
```
Missing resources:
```http
404 Not Found
```
--- ---
## 13. Docker Verification # 11. Routing Validation
Build image. 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.
---
# 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 ```bash
docker compose build --no-cache docker compose build --no-cache
```
Start service.
```bash
docker compose up -d docker compose up -d
``` ```
Verify: Verify:
```bash ```bash
docker compose logs -f docker compose logs
``` ```
Expected Result: Expected:
```text ```text
Listening for requests Server started successfully
``` ```
Container health:
```text
healthy
```
---
# 18. WAL Recovery Validation
Verify: Verify:
```bash * SQLite WAL mode enabled
./bzod doctor * Recovery after backup succeeds
``` * No corruption detected
inside container.
--- ---
## 14. Upgrade Verification # Release Validation Checklist
1. Create backup. Before every release:
2. Upgrade binary.
3. Run migration.
4. Start service.
```bash ```bash
./bzod backup cargo fmt --check
./bzod migrate cargo clippy --all-targets -- -D warnings
./bzod serve cargo test --all-targets -- --nocapture
cargo build --release
cargo audit
``` ```
Expected Result: Release is approved only if all steps succeed.
* Existing data preserved
* No migration failures
--- ---
# Release Acceptance Criteria # Release Blockers
A release is considered production-ready only if: The following are release blockers:
* Build verification passes * Namespace conflicts
* Static analysis passes * Backup failure
* Unit tests pass * Restore failure
* Backup verification passes * Upgrade failure
* Restore verification passes * Multi-user isolation failure
* Disaster recovery verification passes * Ownership validation failure
* Doctor reports HEALTHY * Security test failure
* Docker deployment succeeds * Data corruption
* Web UI functions correctly * Disaster recovery failure
Failure of backup, restore, or disaster recovery tests is considered a release blocker. A release that cannot be restored is not considered production ready.
---
# Guiding Principle
A successful release is not merely one that starts.
A successful release is one that can be recovered.
+795
View File
@@ -0,0 +1,795 @@
# Upgrade Guide
Version: v0.5.1
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
---
# 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.
+42 -6
View File
@@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
return; return;
} }
info!("Flushing {} visits to analytics database", batch.len()); info!(
let mut conn_lock = db.analytics.lock().unwrap(); "Flushing {} visits to user analytics databases",
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) { batch.len()
error!("Failed to write analytics batch to database: {:?}", e); );
} else {
batch.clear(); // Group visits by owner_user_id
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
std::collections::HashMap::new();
for record in batch.drain(..) {
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
groups.entry(user_id).or_default().push(record);
}
for (user_id, user_visits) in groups {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
match rusqlite::Connection::open(&db_path) {
Ok(mut conn) => {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
error!(
"Failed to write analytics batch to user {} database: {:?}",
user_id, e
);
}
}
Err(e) => {
error!(
"Failed to open analytics database for user {}: {:?}",
user_id, e
);
}
}
} }
} }
+37 -5
View File
@@ -1,13 +1,44 @@
use crate::auth::session::authenticate_api_key; use crate::auth::session::authenticate_api_key;
use crate::models::User; use crate::models::ApiActor;
use crate::state::AppState; use crate::state::AppState;
use axum::{ use axum::{
extract::{FromRef, FromRequestParts}, extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode}, http::{request::Parts, StatusCode},
Json,
}; };
// Extractor: Authenticate API requests using Bearer token // Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User); pub struct ApiUser(pub ApiActor);
impl ApiUser {
pub fn require_admin(
&self,
) -> Result<&crate::models::User, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::Admin(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Admin privileges required".to_string(),
}),
)),
}
}
pub fn require_tenant(
&self,
) -> Result<&crate::models::TenantUser, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::User(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Tenant privileges required".to_string(),
}),
)),
}
}
}
#[axum::async_trait] #[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser impl<S> FromRequestParts<S> for ApiUser
@@ -25,9 +56,10 @@ where
.and_then(|h| h.to_str().ok()) .and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?; .ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let conn = app_state.admin_db.lock().unwrap(); let admin_conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) { let users_conn = app_state.users_db.lock().unwrap();
Ok(Some(user)) => Ok(ApiUser(user)), match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
Ok(Some(actor)) => Ok(ApiUser(actor)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")), Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")), Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
} }
+3 -1
View File
@@ -6,4 +6,6 @@ pub mod session;
pub use csrf::{generate_csrf_token, verify_csrf}; pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser; pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256}; pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{authenticate_api_key, authenticate_session, generate_token}; pub use session::{
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
};
+255 -17
View File
@@ -1,11 +1,11 @@
use crate::db::admin::{ use crate::db::admin::{
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
}; };
use crate::models::User; use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rand::{thread_rng, RngCore}; use rand::{thread_rng, RngCore};
use rusqlite::Connection; use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded) // Generate a secure random token (hex-encoded)
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key) hex::encode(key)
} }
// Authenticate session from cookies // Authenticate administrator session from cookies
pub fn authenticate_session( pub fn authenticate_admin_session(
conn: &Connection, conn: &Connection,
jar: &CookieJar, jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> { ) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -26,7 +26,33 @@ pub fn authenticate_session(
}; };
let session_id = cookie.value(); let session_id = cookie.value();
let session = match get_session(conn, session_id)? { let session_opt: Option<AdminSession> = conn
.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
[session_id],
|row| {
let id: String = row.get(0)?;
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
let user_id_str: String = match row.get::<_, String>(1) {
Ok(s) => s,
Err(_) => {
let i: i64 = row.get(1)?;
i.to_string()
}
};
let expires_at: String = row.get(2)?;
let created_at: String = row.get(3)?;
Ok(AdminSession {
id,
user_id: user_id_str,
expires_at,
created_at,
})
},
)
.optional()?;
let session = match session_opt {
Some(s) => s, Some(s) => s,
None => return Ok(None), None => return Ok(None),
}; };
@@ -41,19 +67,171 @@ pub fn authenticate_session(
return Ok(None); return Ok(None);
} }
// Get user // Get user (status must be 'active' and account_type = 'admin')
if let Some(user) = get_user_by_id(conn, &session.user_id)? { // If the session user_id looks numeric, bind as integer when querying users.db
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[id_i64],
|row| {
let id_str = row.get::<_, i64>(0)?.to_string();
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
[session.user_id.as_str()],
|row| {
// admin DB stores UUID string ids, so read as String
let id_str: String = row.get(0)?;
Ok(User {
id: id_str,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
} {
Ok(opt) => (opt, false),
Err(_) => (None, true),
};
if let Some(user) = user_opt {
return Ok(Some((user, session.id)));
}
if extended_failed {
// Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns
// Try a simpler lookup by id only.
let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::<i64>() {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[id_i64],
|row| {
Ok(User {
id: row.get::<_, i64>(0)?.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
} else {
conn.query_row(
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
[session.user_id.as_str()],
|row| {
Ok(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
},
)
.optional()
.unwrap_or(None)
};
if let Some(user) = fallback_user_opt {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
} else {
Ok(None)
}
}
// Authenticate user session from cookies
pub fn authenticate_user_session(
users_conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(TenantUser, String)>, rusqlite::Error> {
let cookie = match jar.get("bzod_user_session") {
Some(c) => c,
None => return Ok(None),
};
let session_id = cookie.value();
// Get session from sessions table in users.db
let mut stmt = users_conn
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let session_opt: Option<UserSession> = stmt
.query_row([session_id], |row| {
Ok(UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
let session = match session_opt {
Some(s) => s,
None => return Ok(None),
};
// Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() {
return Ok(None);
}
} else {
return Ok(None);
}
// Get tenant user (status must be 'active')
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([session.user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
Ok(Some((user, session.id))) Ok(Some((user, session.id)))
} else { } else {
Ok(None) Ok(None)
} }
} }
// Authenticate API key from Authorization header // Authenticate API key/token from Authorization header (unified)
pub fn authenticate_api_key( pub fn authenticate_api_key(
conn: &Connection, admin_conn: &Connection,
users_conn: &Connection,
auth_header: &str, auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> { ) -> Result<Option<ApiActor>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") { if !auth_header.starts_with("Bearer ") {
return Ok(None); return Ok(None);
} }
@@ -68,13 +246,73 @@ pub fn authenticate_api_key(
hasher.update(key.as_bytes()); hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize()); let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? { // 1. Check user API tokens in users.db
// Update last used timestamp let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
update_api_key_last_used(conn, &api_key_rec.id)?; let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
// Get user if let Some(user_id) = user_id_opt {
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? { let mut stmt = users_conn.prepare(
return Ok(Some(user)); "SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([user_id], |row| {
Ok(TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user)));
}
}
// 2. Check admin system API keys in admin.db
if let Some(api_key_rec) = get_api_key_by_hash(admin_conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(admin_conn, &api_key_rec.id)?;
// Get admin user from users.db (users_conn)
// Try to interpret the api_key user_id as an integer referencing users.db
if let Ok(user_id_i64) = api_key_rec.user_id.parse::<i64>() {
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, created_at
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
)?;
let user_opt = stmt
.query_row([user_id_i64], |row| {
let id_i64: i64 = row.get(0)?;
Ok(User {
id: id_i64.to_string(),
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.optional()?;
if let Some(user) = user_opt {
return Ok(Some(ApiActor::Admin(user)));
}
}
// Fallback: admin DB may store users with string UUIDs. Try looking up directly in admin_conn.
if let Ok(Some(admin_user)) = get_admin_user_by_id(admin_conn, &api_key_rec.user_id) {
return Ok(Some(ApiActor::Admin(User {
id: admin_user.id,
username: admin_user.username,
password_hash: admin_user.password_hash,
created_at: admin_user.created_at,
})));
} }
} }
+152
View File
@@ -0,0 +1,152 @@
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
target_admin_id: i64,
data_dir: Option<String>,
dry_run: bool,
force: bool,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// 1. Verify target admin exists and is an admin
let target_user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, target_admin_id)?
};
let target_user = match target_user {
Some(u) => u,
None => {
error!("Target admin ID {} not found", target_admin_id);
return Ok(());
}
};
if target_user.account_type != "admin" {
error!(
"Target user '{}' (ID {}) is not an admin account.",
target_user.username, target_admin_id
);
return Ok(());
}
if target_admin_id == 1 {
error!("Target admin ID cannot be 1 (legacy admin).");
return Ok(());
}
// 2. Open databases
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
if !legacy_content_path.exists() {
info!(
"No legacy admin content database found at {:?}",
legacy_content_path
);
return Ok(());
}
db.init_user_databases(target_admin_id)?;
let target_content_path = config
.data_dir
.join("users")
.join(target_admin_id.to_string())
.join("content.db");
let mut legacy_conn = Connection::open(&legacy_content_path)?;
let mut target_conn = Connection::open(&target_content_path)?;
let mut system_conn = db.system.lock().unwrap();
println!("Scanning legacy admin content database...");
// 3. Count items
let urls = {
let mut stmt = legacy_conn.prepare("SELECT * FROM urls;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let url_count = urls.len();
let pages = {
let mut stmt = legacy_conn.prepare("SELECT * FROM landing_pages;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let page_count = pages.len();
println!(
"Found {} URLs and {} Landing Pages owned by legacy admin (ID 1).",
url_count, page_count
);
if dry_run {
println!("Dry run mode enabled. No changes will be made.");
return Ok(());
}
if !force {
println!("Migration requires the --force flag to execute. Aborting.");
return Ok(());
}
println!(
"Starting migration to Admin '{}' (ID {})...",
target_user.username, target_admin_id
);
// 4. Perform Migration (using ATTACH DATABASE for fast copy)
// We attach the legacy db to the target db to do INSERT INTO ... SELECT * FROM
target_conn.execute(
"ATTACH DATABASE ?1 AS legacy;",
rusqlite::params![legacy_content_path.to_string_lossy()],
)?;
let tx = target_conn.transaction()?;
tx.execute("INSERT OR IGNORE INTO urls SELECT * FROM legacy.urls;", [])?;
tx.execute(
"INSERT OR IGNORE INTO landing_pages SELECT * FROM legacy.landing_pages;",
[],
)?;
tx.commit()?;
target_conn.execute("DETACH DATABASE legacy;", [])?;
// 5. Update global registry
let sys_tx = system_conn.transaction()?;
let updated_slugs = sys_tx.execute(
"UPDATE global_slugs SET owner_user_id = ?1 WHERE owner_user_id = 1;",
rusqlite::params![target_admin_id],
)?;
sys_tx.commit()?;
// 6. Delete from legacy
let legacy_tx = legacy_conn.transaction()?;
legacy_tx.execute("DELETE FROM urls;", [])?;
legacy_tx.execute("DELETE FROM landing_pages;", [])?;
legacy_tx.commit()?;
println!("Migration Complete!");
println!("-------------------");
println!("Migrated {} URLs.", url_count);
println!("Migrated {} Landing Pages.", page_count);
println!("Updated {} slugs in global registry.", updated_slugs);
println!("Cleared legacy content database.");
Ok(())
}
+150
View File
@@ -0,0 +1,150 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::fs::File;
use std::path::{Path, PathBuf};
use tar::{Builder, Header};
use tracing::{error, info};
use zstd::Encoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
username: String,
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let username_clean = username.trim().to_lowercase();
// 1. Get user details from users.db
let user_details = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_username(&conn, &username_clean)?
};
let user = match user_details {
Some(u) => u,
None => {
error!("User '{}' not found", username_clean);
return Ok(());
}
};
let user_id = user.id;
// 2. Fetch user's quotas
let quotas = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;",
[user_id],
|row| {
Ok(UserBackupQuotas {
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
})
}
)?
};
// 3. Define output path
let tar_path = match out {
Some(p) => PathBuf::from(p),
None => {
if !config.backup_dir.exists() {
std::fs::create_dir_all(&config.backup_dir)?;
}
config.backup_dir.join(format!(
"{}-{}.tar.zst",
username_clean,
Utc::now().format("%Y%m%d")
))
}
};
info!(
"Backing up user {} (ID: {}) to {:?}",
username_clean, user_id, tar_path
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
// 5. Create tar.zst archive
let file = File::create(&tar_path)?;
let zst_enc = Encoder::new(file, 3)?;
let mut tar = Builder::new(zst_enc);
// Write metadata.json directly into tar
let metadata_obj = UserBackupMetadata {
id: user.id,
username: user.username,
password_hash: user.password_hash,
status: user.status,
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
let mut header = Header::new_gnu();
header.set_size(metadata_bytes.len() as u64);
header.set_path("metadata.json")?;
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, &metadata_bytes[..])?;
// Append database files
let mut append_file =
|name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box<dyn std::error::Error>> {
if path_on_disk.exists() {
let mut file = File::open(path_on_disk)?;
tar.append_file(name_in_archive, &mut file)?;
}
Ok(())
};
append_file("content.db", &user_dir.join("content.db"))?;
append_file("analytics.db", &user_dir.join("analytics.db"))?;
append_file("profile.db", &user_dir.join("profile.db"))?;
tar.into_inner()?.finish()?;
info!("User backup generated successfully at {:?}", tar_path);
Ok(())
}
+3 -2
View File
@@ -32,8 +32,9 @@ pub async fn run(
} }
let hash = hash_password(&password).map_err(|e| e.to_string())?; let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap(); let conn = db.users.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?; let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!( info!(
"Successfully created admin user: {} (ID: {})", "Successfully created admin user: {} (ID: {})",
u.username, u.id u.username, u.id
+86
View File
@@ -0,0 +1,86 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
Some(u) => u,
None => read_input("Enter username: "),
};
let username_clean = final_username.trim().to_lowercase();
if username_clean.is_empty() {
error!("Username cannot be empty");
return Ok(());
}
if username_clean.len() < 3 {
error!("Username must be at least 3 characters");
return Ok(());
}
if !username_clean
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
error!("Username must contain only alphanumeric characters, hyphens, or underscores");
return Ok(());
}
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
// Check if user already exists
{
let conn = db.users.lock().unwrap();
if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() {
error!("User already exists: {}", username_clean);
return Ok(());
}
}
// Create user in DB (this seeds default quotas too)
let new_user = {
let conn = db.users.lock().unwrap();
crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)?
};
// Initialize their user specific directory and DB files (content.db, analytics.db, profile.db)
db.init_user_databases(new_user.id)?;
info!(
"Successfully created standard user: {} (ID: {})",
new_user.username, new_user.id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+92
View File
@@ -0,0 +1,92 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
force: bool,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, user_id)? {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
let conn = db.users.lock().unwrap();
crate::db::users::delete_user(&conn, user_id)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DELETION",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
}
info!(
"Successfully deleted user {} (ID: {}) and all associated content",
user_details.username, user_id
);
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "disabled" {
info!("User {} is already disabled", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "disabled")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DISABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!("User {} (ID: {}) has been disabled", user.username, user_id);
Ok(())
}
+69 -3
View File
@@ -22,11 +22,25 @@ pub async fn run(
println!("Data directory: {:?}", config.data_dir); println!("Data directory: {:?}", config.data_dir);
println!(); println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true; let mut all_healthy = true;
for db_name in &databases { // Define target databases in the new layout
let db_path = config.data_dir.join(format!("{}.db", db_name)); let admin_dir = config.data_dir.join("admin");
let legacy_user_dir = config.data_dir.join("users").join("1");
let dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", legacy_user_dir.join("content.db")),
("legacy analytics", legacy_user_dir.join("analytics.db")),
];
for (db_name, db_path) in dbs {
// Skip legacy databases if they don't exist
if db_name.starts_with("legacy") && !db_path.exists() {
continue;
}
if !db_path.exists() { if !db_path.exists() {
println!("Database: {}", db_name); println!("Database: {}", db_name);
@@ -75,6 +89,58 @@ pub async fn run(
println!(); println!();
} }
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if system_db_path.exists() && users_db_path.exists() {
match (
Connection::open(&system_db_path),
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::db::users::verify_global_slug_registry_integrity(
&sys_conn,
&usr_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
}
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", e);
all_healthy = false;
}
}
}
_ => {
println!(" Status: ERROR opening system.db or users.db for integrity check");
all_healthy = false;
}
}
} else {
println!(" Status: SKIPPED (system.db/users.db not found)");
}
println!();
println!("--------------------"); println!("--------------------");
if all_healthy { if all_healthy {
println!("Overall status: HEALTHY"); println!("Overall status: HEALTHY");
+58
View File
@@ -0,0 +1,58 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "active" {
info!("User {} is already active", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "active")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_ENABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"User {} (ID: {}) has been enabled (active)",
user.username, user_id
);
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
code: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let normalized_code = code.trim().to_lowercase();
if !crate::utils::validation::validate_redirect_code(&normalized_code) {
return Err("Invalid short code or custom slug format".into());
}
let url_opt = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)?
};
match url_opt {
Some(url) => {
println!("{}", url.destination);
Ok(())
}
None => Err(format!("Short code not found: {}", normalized_code).into()),
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let users = {
let conn = db.users.lock().unwrap();
crate::db::users::list_users(&conn)?
};
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
"ID", "Username", "Status", "Type", "Created At"
);
println!(
"{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}",
"", "", "", "", ""
);
for u in users {
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
u.id, u.username, u.status, u.account_type, u.created_at
);
}
Ok(())
}
+107
View File
@@ -3,12 +3,24 @@ use clap::{Parser, Subcommand};
pub mod backup; pub mod backup;
pub mod create_admin; pub mod create_admin;
pub mod doctor; pub mod doctor;
pub mod expand;
pub mod migrate; pub mod migrate;
pub mod restore; pub mod restore;
pub mod serve; pub mod serve;
pub mod shorten;
pub mod stats; pub mod stats;
pub mod validate; pub mod validate;
pub mod admin_migrate;
pub mod backup_user;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod enable_user;
pub mod list_users;
pub mod reset_password;
pub mod restore_user;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bzod")] #[command(name = "bzod")]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
@@ -72,4 +84,99 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Shorten a URL (Feature 3)
Shorten {
/// The destination URL to shorten
target_url: String,
/// Custom slug (starting with ! followed by a-z, 0-9, -, _)
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Expand a shortened code or custom slug to its destination URL (Feature 4)
Expand {
/// The short code or custom slug to expand
code: String,
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new standard user in the database
CreateUser {
#[arg(long)]
username: Option<String>,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Delete a standard user and all their databases/slugs
DeleteUser {
/// User ID to delete
user_id: i64,
/// Force deletion of system account/legacy_admin
#[arg(long)]
force: bool,
#[arg(long)]
data_dir: Option<String>,
},
/// Disable a standard user
DisableUser {
/// User ID to disable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Enable a standard user
EnableUser {
/// User ID to enable
user_id: i64,
#[arg(long)]
data_dir: Option<String>,
},
/// Reset standard user's password
ResetPassword {
/// User ID to reset
user_id: i64,
#[arg(long)]
password: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// List all standard/system users
ListUsers {
#[arg(long)]
data_dir: Option<String>,
},
/// Backup a standard user's databases to a .tar.zst package
BackupUser {
/// Username to backup
username: String,
/// Output .tar.zst filepath
#[arg(long)]
out: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Restore a standard user's databases from a .tar.zst package
RestoreUser {
/// Input .tar.zst package path
#[arg(long, required = true)]
file: String,
#[arg(long)]
data_dir: Option<String>,
},
/// FUTURE: Migrate legacy admin content to a specific admin tenant database
AdminMigrate {
/// Target Admin ID
target_admin_id: i64,
#[arg(long)]
data_dir: Option<String>,
/// Preview what would be moved without making changes
#[arg(long)]
dry_run: bool,
/// Force the migration to execute
#[arg(long)]
force: bool,
},
} }
+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()
}
+102 -4
View File
@@ -6,6 +6,107 @@ use std::path::PathBuf;
use tar::Archive; use tar::Archive;
use tracing::{error, info}; use tracing::{error, info};
pub fn perform_restore(
file_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive
let f = File::open(file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
// 2. Unpack to temporary directory first
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());
}
// 3. Run validation on temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
// Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
Ok(report) => {
if !report.duplicates.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
);
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
}
}
// Registry integrity check
let system_db_path = if temp_dir.join("admin/system.db").exists() {
temp_dir.join("admin/system.db")
} else {
temp_dir.join("system.db")
};
let users_db_path = if temp_dir.join("admin/users.db").exists() {
temp_dir.join("admin/users.db")
} else {
temp_dir.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::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&temp_dir,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
}
}
}
// 4. If validation succeeds, copy temp_dir contents to data_dir
if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir);
}
std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to copy restored files: {}", e).into());
}
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(())
}
pub async fn run( pub async fn run(
file: String, file: String,
data_dir: Option<String>, data_dir: Option<String>,
@@ -40,10 +141,7 @@ pub async fn run(
} }
info!("Restoring backup from: {:?}", file_path); info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?; perform_restore(&file_path, &config.data_dir)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
info!("Database files successfully restored."); info!("Database files successfully restored.");
Ok(()) Ok(())
+182
View File
@@ -0,0 +1,182 @@
use crate::config::Config;
use crate::db::Db;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use tracing::{error, info};
use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let file_path = PathBuf::from(file);
if !file_path.exists() {
error!("Backup file not found: {:?}", file_path);
return Ok(());
}
let db = Db::init(&config)?;
// 1. Read metadata.json from the tar.zst archive
let f = File::open(&file_path)?;
let zst_dec = Decoder::new(f)?;
let mut archive = Archive::new(zst_dec);
let mut metadata_opt: Option<UserBackupMetadata> = None;
for entry_res in archive.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if file_name == "metadata.json" {
let meta: UserBackupMetadata = serde_json::from_reader(&mut entry)?;
metadata_opt = Some(meta);
break;
}
}
let metadata = match metadata_opt {
Some(m) => m,
None => {
error!("Archive is missing metadata.json");
return Ok(());
}
};
info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db
let target_user_id = {
let users_conn = db.users.lock().unwrap();
let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user {
Some(u) => {
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
)?;
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
u.id
}
None => {
let id_taken: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[metadata.id],
|row| row.get(0),
)
.unwrap_or(false);
let new_id = if !id_taken {
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
)?;
users_conn.last_insert_rowid()
};
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
)?;
new_id
}
}
};
// 3. Extract database files to /data/users/<target_user_id>/
let dest_dir = config
.data_dir
.join("users")
.join(target_user_id.to_string());
std::fs::create_dir_all(&dest_dir)?;
let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?;
let mut archive2 = Archive::new(zst_dec2);
for entry_res in archive2.entries()? {
let mut entry = entry_res?;
let path = entry.path()?;
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
match file_name {
"content.db" => {
let mut out_file = File::create(dest_dir.join("content.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"analytics.db" => {
let mut out_file = File::create(dest_dir.join("analytics.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
"profile.db" => {
let mut out_file = File::create(dest_dir.join("profile.db"))?;
std::io::copy(&mut entry, &mut out_file)?;
}
_ => {}
}
}
// 4. Register slugs in global_slugs using the shared helper
{
let system_conn = db.system.lock().unwrap();
crate::db::users::register_restored_user_slugs(
&system_conn,
target_user_id,
&dest_dir.join("content.db"),
)?;
}
// 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
info!(
"User '{}' (ID: {}) successfully restored from backup.",
metadata.username, target_user_id
);
Ok(())
}
+18
View File
@@ -63,11 +63,29 @@ pub async fn run(
crate::jobs::run_expiry_checker(expiry_db).await; crate::jobs::run_expiry_checker(expiry_db).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)
};
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
});
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),
analytics_db: db.analytics.clone(), analytics_db: db.analytics.clone(),
system_db: db.system.clone(), system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
db: db.clone(), db: db.clone(),
config: config.clone(), config: config.clone(),
analytics_queue: queue, analytics_queue: queue,
+95
View File
@@ -0,0 +1,95 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
target_url: String,
slug: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Basic URL validation
if reqwest::Url::parse(&target_url).is_err() {
return Err("Invalid destination URL format".into());
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// 2. Validate/normalize slug/code
let code = match slug {
Some(s) => {
let normalized = s.trim().to_lowercase();
if !crate::utils::validation::validate_custom_slug(&normalized) {
return Err(
"Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _"
.into(),
);
}
normalized
}
None => crate::utils::random::generate_token(3),
};
// 3. Register slug in system.db with status 'reserving' and check availability
{
let system_conn = db.system.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? {
return Err("Short code/slug already exists".into());
}
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
}
// 4. Persist URL
let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended(
&conn,
&code,
&target_url,
None,
None,
&[],
None,
None,
None,
);
match res {
Ok(url) => {
// Activate slug in system.db
{
let system_conn = db.system.lock().unwrap();
system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
}
// Increment quota for user ID 1
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
}
let proto = if config.cookie_secure {
"https"
} else {
"http"
};
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, config.port));
// Output only the shortened URL as requested
println!("{}/{}", base_url, code);
Ok(())
}
Err(e) => {
let system_conn = db.system.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Err(e.into())
}
}
}
+18 -6
View File
@@ -14,14 +14,26 @@ pub async fn run(
println!("=== BZOD Database Stats ==="); println!("=== BZOD Database Stats ===");
println!("Storage Directory: {:?}", config.data_dir); println!("Storage Directory: {:?}", config.data_dir);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; let files = vec![
for f in files { ("admin.db", config.data_dir.join("admin/admin.db")),
let p = config.data_dir.join(f); ("system.db", config.data_dir.join("admin/system.db")),
if p.exists() { ("users.db", config.data_dir.join("admin/users.db")),
let sz = std::fs::metadata(&p)?.len(); (
"legacy content.db",
config.data_dir.join("users/1/content.db"),
),
(
"legacy analytics.db",
config.data_dir.join("users/1/analytics.db"),
),
];
for (name, path) in files {
if path.exists() {
let sz = std::fs::metadata(&path)?.len();
println!( println!(
" File: {} - Size: {} bytes ({:.2} MB)", " File: {} - Size: {} bytes ({:.2} MB)",
f, name,
sz, sz,
sz as f64 / 1_048_576.0 sz as f64 / 1_048_576.0
); );
+14 -4
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> { ) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339(); let created_at = Utc::now().to_rfc3339();
conn.execute( // Bind `user_id` as integer when it appears to be numeric so that numeric
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);", // user IDs inserted into `users.db` keep the integer affinity and avoid
params![session_id, user_id, expires_at_rfc3339, created_at], // InvalidColumnType errors when read as i64 elsewhere.
)?; if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session { Ok(Session {
id: session_id.to_string(), id: session_id.to_string(),
+241 -3
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?; let tx = conn.transaction()?;
{ {
let mut stmt = tx.prepare( let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code) "INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);" VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?; )?;
for r in records { for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer, r.referer,
r.accept_language, r.accept_language,
r.country, r.country,
r.status_code r.status_code,
r.owner_user_id
])?; ])?;
} }
} }
@@ -508,6 +509,243 @@ pub fn get_metric_rankings_raw(
Ok(res) Ok(res)
} }
/// Returns the raw visit counts for a specific target ID.
pub fn get_target_visit_count(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Returns the distinct IP count (Unique Visitors) for a specific target ID.
pub fn get_target_unique_visitors(
conn: &Connection,
target_type: &str,
target_id: &str,
) -> rusqlite::Result<i64> {
conn.query_row(
"SELECT COUNT(DISTINCT ip_address) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
params![target_type, target_id],
|row| row.get(0),
)
}
/// Fetches the monthly clicks trend for a specific target ID, falling back to a raw visits query if monthly_summaries are empty.
pub fn get_monthly_clicks_trend(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_months: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT year_month, SUM(metric_value) FROM monthly_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks'
GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
if res.is_empty() {
// Fallback to raw visits
let mut stmt = conn.prepare(
"SELECT strftime('%Y-%m', timestamp) as m, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY m ORDER BY m ASC LIMIT ?3;",
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows {
res.push(r?);
}
}
Ok(res)
}
pub fn get_visits_schema_columns(
conn: &Connection,
) -> rusqlite::Result<std::collections::HashSet<String>> {
let mut columns = std::collections::HashSet::new();
let mut stmt = conn.prepare("PRAGMA table_info(visits);")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let name: String = row.get("name")?;
columns.insert(name);
}
Ok(columns)
}
pub fn get_target_visits_paginated(
conn: &Connection,
target_type: &str,
target_id: &str,
limit: i64,
offset: i64,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC LIMIT ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(limit));
sql.push_str(" OFFSET ?");
sql.push_str(&(params.len() + 1).to_string());
params.push(Box::new(offset));
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visits_all_in_memory(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<Vec<VisitRecord>> {
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = conn.prepare(&sql)?;
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
Ok(VisitRecord {
id: row.get("id")?,
target_type: row.get("target_type")?,
target_id: row.get("target_id")?,
timestamp: row.get("timestamp")?,
ip_address: row.get("ip_address")?,
user_agent: row.get("user_agent")?,
referer: row.get("referer")?,
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_user_id: row.get("owner_user_id")?,
})
})?;
let mut visits = Vec::new();
for r in rows {
visits.push(r?);
}
Ok(visits)
}
pub fn get_target_visit_total_filtered(
conn: &Connection,
target_type: &str,
target_id: &str,
date_from: Option<&str>,
date_to: Option<&str>,
) -> rusqlite::Result<i64> {
if date_from.is_none() && date_to.is_none() {
return get_target_visit_count(conn, target_type, target_id);
}
let mut sql =
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
Box::new(target_type.to_string()),
Box::new(target_id.to_string()),
];
if let Some(df) = date_from {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = date_to {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
conn.query_row(&sql, rusqlite::params_from_iter(param_refs), |row| {
row.get(0)
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+13
View File
@@ -47,6 +47,19 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags) Ok(tags)
} }
/// Get the total count of URLs associated with a specific tag name.
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
let tag_name = tag.trim().to_lowercase();
conn.query_row(
"SELECT COUNT(*) FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1;",
params![tag_name],
|row| row.get(0),
)
}
/// The full column list used in all URL SELECT queries. /// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count"; const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
+189 -5
View File
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
// Migration definitions // Migration definitions
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration { pub const ADMIN_MIGRATIONS: &[Migration] = &[
version: 1, Migration {
name: "initial_schema", version: 1,
sql: r#" name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE, username TEXT NOT NULL UNIQUE,
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
value TEXT NOT NULL value TEXT NOT NULL
); );
"#, "#,
}]; },
Migration {
version: 2,
name: "remove_api_keys_fk",
sql: r#"
CREATE TABLE api_keys_new (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
DROP TABLE api_keys;
ALTER TABLE api_keys_new RENAME TO api_keys;
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[ pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration { Migration {
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp); CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
"#, "#,
}, },
Migration {
version: 3,
name: "add_owner_user_id",
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
},
]; ];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action); CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
"#, "#,
}, },
Migration {
version: 3,
name: "global_slugs_and_moderation",
sql: r#"
CREATE TABLE IF NOT EXISTS global_slugs (
slug TEXT PRIMARY KEY,
owner_user_id INTEGER NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
CREATE TABLE IF NOT EXISTS moderation_events (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
admin_username TEXT NOT NULL,
target_user_id INTEGER NOT NULL,
target_username TEXT,
resource_type TEXT NOT NULL,
resource_identifier TEXT NOT NULL,
action TEXT NOT NULL,
severity TEXT NOT NULL,
reason TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS slug_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
old_owner_user_id INTEGER,
new_owner_user_id INTEGER,
action TEXT NOT NULL,
timestamp TEXT NOT NULL,
admin_username TEXT
);
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER PRIMARY KEY,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- Seed defaults
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
"#,
},
];
pub const USERS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
last_login TEXT,
account_type TEXT DEFAULT 'standard',
organization_id INTEGER NULL,
metadata TEXT
);
CREATE TABLE IF NOT EXISTS quotas (
user_id INTEGER PRIMARY KEY,
max_urls INTEGER DEFAULT 100,
max_landings INTEGER DEFAULT 10,
max_api_tokens INTEGER DEFAULT 5,
max_storage_mb INTEGER DEFAULT 100,
current_urls INTEGER DEFAULT 0,
current_landings INTEGER DEFAULT 0,
current_api_tokens INTEGER DEFAULT 0,
current_storage_mb INTEGER DEFAULT 0,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS api_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS username_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
old_username TEXT NOT NULL,
new_username TEXT NOT NULL,
changed_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
"#,
},
Migration {
version: 2,
name: "repair_admin_account_type",
sql: r#"
UPDATE users
SET account_type = 'admin'
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
]; ];
+431 -46
View File
@@ -1,6 +1,7 @@
use crate::config::Config; use crate::config::Config;
use crate::db::migrations::{ use crate::db::migrations::{
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS, run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
USERS_MIGRATIONS,
}; };
use crate::db::sqlite::{enable_foreign_keys, enable_wal}; use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection; use rusqlite::Connection;
@@ -15,6 +16,7 @@ pub mod migrations;
pub mod preview; pub mod preview;
pub mod qr; pub mod qr;
pub mod sqlite; pub mod sqlite;
pub mod users;
#[derive(Clone)] #[derive(Clone)]
pub struct Db { pub struct Db {
@@ -22,70 +24,108 @@ pub struct Db {
pub content: Arc<Mutex<Connection>>, pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>, pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>, pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
} }
impl Db { impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> { pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists // Ensure data directory exists
if !config.data_dir.exists() { if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?; fs::create_dir_all(&config.data_dir)?;
} }
let admin_path = config.data_dir.join("admin.db"); let admin_dir = config.data_dir.join("admin");
let content_path = config.data_dir.join("content.db"); let users_dir = config.data_dir.join("users");
let analytics_path = config.data_dir.join("analytics.db"); fs::create_dir_all(&admin_dir)?;
let system_path = config.data_dir.join("system.db"); fs::create_dir_all(&users_dir)?;
use tracing::info; // Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
tracing::warn!("LEGACY DETECTED: admin.db found at root. Moving administrative databases to multi-tenant admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Pre-migration safety net: audit slug namespace for duplicates / format errors
match crate::db::users::audit_slug_namespace(config) {
Ok(report) => {
if !report.duplicates.is_empty() {
tracing::error!(
"Namespace conflicts detected before database migration: {:?}",
report.duplicates
);
return Err(format!(
"Database upgrade aborted due to slug conflicts: {:?}",
report.duplicates
)
.into());
}
}
Err(e) => {
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
info!("Opening admin.db"); info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?; let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db"); info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?; let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?; enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?; enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
// Enable foreign key support
info!(
database = "admin",
"Enabling foreign key enforcement on admin.db"
);
enable_foreign_keys(&admin_conn, "admin")?; enable_foreign_keys(&admin_conn, "admin")?;
info!(
database = "content",
"Enabling foreign key enforcement on content.db"
);
enable_foreign_keys(&content_conn, "content")?;
info!(
database = "analytics",
"Enabling foreign key enforcement on analytics.db"
);
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(
database = "system",
"Enabling foreign key enforcement on system.db"
);
enable_foreign_keys(&system_conn, "system")?; enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
// 1. Run migrations for system.db first, as it receives secondary audit records // Run migrations for system.db first
info!("Running system migrations"); info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?; run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn)); let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging // Pre-migration detection of admin account repair
let repair_needed = {
let stmt = users_conn.prepare(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
);
match stmt {
Ok(mut s) => s
.query_row([], |row| row.get::<_, bool>(0))
.unwrap_or(false),
Err(_) => false,
}
};
// Run migrations for admin.db and users.db
info!("Running admin migrations"); info!("Running admin migrations");
run_migrations( run_migrations(
&mut admin_conn, &mut admin_conn,
@@ -93,14 +133,131 @@ impl Db {
ADMIN_MIGRATIONS, ADMIN_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
info!("Running content migrations"); info!("Running users migrations");
run_migrations(
&mut users_conn,
"users",
USERS_MIGRATIONS,
Some(&system_arc),
)?;
// Post-migration: audit log if repaired
if repair_needed {
let admin_is_now_admin: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
[],
|row| row.get(0),
)
.unwrap_or(false);
if admin_is_now_admin {
let system_conn = system_arc.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"admin",
"migration_repair",
"users",
"admin",
Some("Repaired standard account type to admin"),
);
}
}
// Clean up expired sessions from users.db on startup
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations( run_migrations(
&mut content_conn, &mut content_conn,
"content", "content",
CONTENT_MIGRATIONS, CONTENT_MIGRATIONS,
Some(&system_arc), Some(&system_arc),
)?; )?;
info!("Running analytics migrations");
run_migrations( run_migrations(
&mut analytics_conn, &mut analytics_conn,
"analytics", "analytics",
@@ -108,26 +265,254 @@ impl Db {
Some(&system_arc), Some(&system_arc),
)?; )?;
Ok(Self { // If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)), admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)), content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)), analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc, system: system_arc,
}) users: Arc::new(Mutex::new(users_conn)),
data_dir: config.data_dir.clone(),
};
let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db)
} }
pub fn compact(&self) -> Result<(), rusqlite::Error> { pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap(); let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?; let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap(); let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?; let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap(); let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?; let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap(); let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?; let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_wal(&profile_conn, "profile")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
enable_foreign_keys(&profile_conn, "profile")?;
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
Some(&self.system),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
Some(&self.system),
)?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(())
}
pub fn reconcile_global_slugs(
&self,
config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
let system_conn = self.system.lock().unwrap();
let users_conn = self.users.lock().unwrap();
// Get all user IDs
let mut stmt = users_conn.prepare("SELECT id FROM users;")?;
let mut rows = stmt.query([])?;
let mut user_ids = vec![1i64]; // Start with legacy admin
while let Some(row) = rows.next()? {
user_ids.push(row.get(0)?);
}
drop(rows);
drop(stmt);
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = user_dir.join("content.db");
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
// Sync URLs
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "url", target_id, created_at, now, global_status],
);
}
// Sync Landing Pages
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![code, user_id, "page", target_id, created_at, now, global_status],
);
}
}
}
Ok(()) Ok(())
} }
+1079
View File
File diff suppressed because it is too large. Load diff
+32 -15
View File
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator..."); info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
let job_id = log_job_start(&db.system, "analytics_aggregator"); let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await { let mut failed = false;
Ok(_) => log_job_end(&db.system, &job_id, "success", None), let mut err_msg = None;
Err(e) => {
let err_str = e.to_string(); for user_id in user_ids {
error!("Error performing aggregation: {}", err_str); if let Err(e) = perform_aggregation(&db, user_id).await {
log_job_end(&db.system, &job_id, "failed", Some(&err_str)); failed = true;
err_msg = Some(e.to_string());
} }
} }
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
log_job_end(&db.system, &job_id, "success", None);
}
} }
} }
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> { pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let date_range = { let mut conn = super::open_user_analytics_conn(db, user_id)?;
let conn = db.analytics.lock().unwrap(); let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
crate::db::analytics::get_visits_date_range(&conn)?
};
if let Some((min_date, max_date)) = date_range { if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?; let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
@@ -35,10 +55,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
let mut curr = min; let mut curr = min;
while curr <= max { while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string(); let date_str = curr.format("%Y-%m-%d").to_string();
{ aggregate_day(&mut conn, &date_str)?;
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
if curr == max { if curr == max {
break; break;
} }
+38 -6
View File
@@ -51,6 +51,36 @@ pub async fn perform_backup(
std::fs::create_dir_all(&out_dir)?; std::fs::create_dir_all(&out_dir)?;
} }
// Force checkpoint on all databases to flush WAL contents to the main DB files
if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
for user_id in user_ids {
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
}
}
}
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str); let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name); let tar_path = out_dir.join(tar_name);
@@ -59,12 +89,14 @@ pub async fn perform_backup(
let enc = GzEncoder::new(file, Compression::default()); let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc); let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"]; let admin_dir = config.data_dir.join("admin");
for f in files { if admin_dir.exists() {
let db_file = config.data_dir.join(f); tar.append_dir_all("admin", &admin_dir)?;
if db_file.exists() { }
tar.append_path_with_name(&db_file, f)?;
} let users_dir = config.data_dir.join("users");
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
} }
tar.into_inner()?.finish()?; tar.into_inner()?.finish()?;
+24 -5
View File
@@ -10,13 +10,32 @@ pub async fn run_expiry_checker(db: Db) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::time::sleep(Duration::from_secs(60)).await;
let count = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = db.users.lock().unwrap();
crate::db::content::expire_urls(&conn).unwrap_or(0) 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()
}; };
if count > 0 { let mut total_expired = 0;
info!(expired_count = count, "Expired URLs marked"); for user_id in user_ids {
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
total_expired += count;
}
}
if total_expired > 0 {
info!(
expired_count = total_expired,
"Expired URLs marked across users"
);
} }
} }
} }
+40 -30
View File
@@ -37,40 +37,50 @@ pub async fn perform_link_check(
db: &Db, db: &Db,
client: &Client, client: &Client,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let urls = { let user_ids: Vec<i64> = {
let conn = db.content.lock().unwrap(); let conn = db.users.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)? let mut stmt = conn.prepare("SELECT id FROM users;")?;
let rows = stmt.query_map([], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
}; };
for (id, dest) in urls { for user_id in user_ids {
let (status, detail_status, status_code, latency_ms, err_msg) = let conn = match super::open_user_content_conn(db, user_id) {
check_url_health(client, &dest).await; Ok(c) => c,
{ Err(_) => continue,
let conn = db.content.lock().unwrap(); };
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks let urls = crate::db::content::list_urls_for_health_check(&conn)?;
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests for (id, dest) in urls {
tokio::time::sleep(Duration::from_millis(200)).await; let (status, detail_status, status_code, latency_ms, err_msg) =
check_url_health(client, &dest).await;
{
crate::db::content::update_url_health_extended(
&conn,
&id,
&status,
&detail_status,
Some(latency_ms),
)?;
}
// Log to system.db.health_checks
{
let sys_conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = sys_conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
}
} }
Ok(()) Ok(())
} }
+36
View File
@@ -1,3 +1,4 @@
use crate::db::Db;
use chrono::Utc; use chrono::Utc;
use rusqlite::{params, Connection}; use rusqlite::{params, Connection};
use std::sync::Mutex; use std::sync::Mutex;
@@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Op
); );
} }
} }
pub mod quota_reconcile;
pub use quota_reconcile::run_quota_reconciliation;
// --- Database Connection Helpers for User-specific Databases ---
pub fn open_user_content_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
Ok(conn)
}
pub fn open_user_analytics_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
Ok(conn)
}
+42
View File
@@ -0,0 +1,42 @@
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
loop {
// Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare select user IDs: {:?}", e);
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!("Failed to query user IDs: {:?}", e);
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
}
}
}
info!("Quota reconciliation finished.");
}
}
+42 -10
View File
@@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
tokio::time::sleep(Duration::from_secs(24 * 3600)).await; tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup..."); info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
};
rows.filter_map(|r| r.ok()).collect()
};
let job_id = log_job_start(&db.system, "retention_cleaner"); let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap(); let mut total_cleaned = 0;
match crate::db::analytics::retention_cleanup(&conn, retention_days) { let mut failed = false;
Ok(count) => { let mut err_msg = None;
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None); for user_id in user_ids {
} match super::open_user_analytics_conn(&db, user_id) {
Err(e) => { Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
let err_str = e.to_string(); Ok(count) => total_cleaned += count,
error!("Error running retention cleaner: {:?}", err_str); Err(e) => {
log_job_end(&db.system, &job_id, "failed", Some(&err_str)); failed = true;
err_msg = Some(e.to_string());
}
},
Err(e) => {
failed = true;
err_msg = Some(e.to_string());
}
} }
} }
if failed {
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
} else {
info!(
"Cleaned up {} expired visits across all user databases",
total_cleaned
);
log_job_end(&db.system, &job_id, "success", None);
}
} }
} }
+59
View File
@@ -44,6 +44,65 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Doctor { data_dir } => { Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?; bzod::cli::doctor::run(data_dir, config).await?;
} }
Commands::Shorten {
target_url,
slug,
data_dir,
} => {
bzod::cli::shorten::run(target_url, slug, data_dir, config).await?;
}
Commands::Expand { code, data_dir } => {
bzod::cli::expand::run(code, data_dir, config).await?;
}
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?;
}
} }
Ok(()) Ok(())
+4 -1
View File
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
pub use audit::AuditLog; pub use audit::AuditLog;
pub use page::LandingPage; pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url}; pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{Session, User}; pub use user::{
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
UserQuotas, UserSession, UsernameHistory,
};
pub use visit::{SummaryEntry, VisitRecord}; pub use visit::{SummaryEntry, VisitRecord};
+190
View File
@@ -15,3 +15,193 @@ pub struct Session {
pub expires_at: String, pub expires_at: String,
pub created_at: String, pub created_at: String,
} }
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct TenantUser {
pub id: i64,
pub username: String,
pub password_hash: String,
pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted'
pub created_at: String,
pub last_login: Option<String>,
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserQuotas {
pub user_id: i64,
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub current_urls: i64,
pub current_landings: i64,
pub current_api_tokens: i64,
pub current_storage_mb: i64,
}
impl UserQuotas {
pub fn urls_pct(&self) -> f64 {
if self.max_urls <= 0 {
0.0
} else {
(self.current_urls as f64 / self.max_urls as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn landings_pct(&self) -> f64 {
if self.max_landings <= 0 {
0.0
} else {
(self.current_landings as f64 / self.max_landings as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn api_tokens_pct(&self) -> f64 {
if self.max_api_tokens <= 0 {
0.0
} else {
(self.current_api_tokens as f64 / self.max_api_tokens as f64 * 100.0).clamp(0.0, 100.0)
}
}
pub fn storage_pct(&self) -> f64 {
if self.max_storage_mb <= 0 {
0.0
} else {
(self.current_storage_mb as f64 / self.max_storage_mb as f64 * 100.0).clamp(0.0, 100.0)
}
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserApiToken {
pub id: i64,
pub user_id: i64,
pub token_hash: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UserSession {
pub id: String,
pub user_id: i64,
pub expires_at: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct UsernameHistory {
pub id: i64,
pub user_id: i64,
pub old_username: String,
pub new_username: String,
pub changed_at: String,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum SlugStatus {
Active,
Flagged,
Disabled,
SoftDeleted,
}
impl SlugStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Active => "active",
Self::Flagged => "flagged",
Self::Disabled => "disabled",
Self::SoftDeleted => "soft_deleted",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"active" => Some(Self::Active),
"flagged" => Some(Self::Flagged),
"disabled" => Some(Self::Disabled),
"soft_deleted" => Some(Self::SoftDeleted),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum AccountType {
System,
Admin,
Standard,
Organization,
Service,
}
impl AccountType {
pub fn as_str(&self) -> &'static str {
match self {
Self::System => "system",
Self::Admin => "admin",
Self::Standard => "standard",
Self::Organization => "organization",
Self::Service => "service",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"system" => Some(Self::System),
"admin" => Some(Self::Admin),
"standard" => Some(Self::Standard),
"organization" => Some(Self::Organization),
"service" => Some(Self::Service),
_ => None,
}
}
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
pub enum ModerationSeverity {
Low,
Medium,
High,
Critical,
}
impl ModerationSeverity {
pub fn as_str(&self) -> &'static str {
match self {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::Critical => "critical",
}
}
#[allow(clippy::should_implement_trait)]
pub fn from_str(s: &str) -> Option<Self> {
match s {
"low" => Some(Self::Low),
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"critical" => Some(Self::Critical),
_ => None,
}
}
}
#[derive(Clone, Debug)]
pub enum ApiActor {
Admin(User),
User(TenantUser),
}
impl ApiActor {
pub fn username(&self) -> &str {
match self {
Self::Admin(u) => &u.username,
Self::User(u) => &u.username,
}
}
}
+1
View File
@@ -12,6 +12,7 @@ pub struct VisitRecord {
pub accept_language: String, pub accept_language: String,
pub country: String, pub country: String,
pub status_code: u16, pub status_code: u16,
pub owner_user_id: Option<i64>,
} }
#[derive(Serialize, Deserialize, Clone, Debug)] #[derive(Serialize, Deserialize, Clone, Debug)]
+70
View File
@@ -2,15 +2,25 @@ use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use rusqlite::Connection; use rusqlite::Connection;
use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::time::Instant; use std::time::Instant;
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
#[derive(Clone)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>, pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>, pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>, pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>, pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
pub db: Db, pub db: Db,
pub config: Config, pub config: Config,
pub analytics_queue: AnalyticsQueue, pub analytics_queue: AnalyticsQueue,
@@ -18,11 +28,71 @@ pub struct AppState {
} }
impl AppState { impl AppState {
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = self.user_dbs.lock().unwrap();
if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone());
}
// Open connection and run migrations
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
std::fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
// Run migrations
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(&self.system_db),
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
let dbs = UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
};
pool.insert(user_id, dbs.clone());
Ok(dbs)
}
pub fn db_compact(&self) -> Result<(), rusqlite::Error> { pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?; self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?; self.content_db.lock().unwrap().execute("VACUUM;", [])?;
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
self.system_db.lock().unwrap().execute("VACUUM;", [])?; self.system_db.lock().unwrap().execute("VACUUM;", [])?;
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
Ok(()) Ok(())
} }
} }
+108
View File
@@ -0,0 +1,108 @@
use crate::models::{LandingPage, Url};
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Clone, Debug)]
pub struct VisitorLogEntry {
pub sr: usize,
pub timestamp: String,
pub ip_address: String,
pub country: String,
pub referrer: String,
pub browser: String,
pub user_agent: String,
pub utm_source: String,
pub utm_campaign: String,
}
#[derive(Template)]
#[template(path = "url_analytics.html")]
pub struct UrlAnalyticsTemplate {
pub admin_username: String,
pub url: Url,
pub total_clicks: i64,
pub unique_visitors: i64,
pub qr_scans: i64,
pub direct_clicks: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
pub browsers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_admin: bool,
}
impl UrlAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UrlAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "page_analytics.html")]
pub struct PageAnalyticsTemplate {
pub admin_username: String,
pub page: LandingPage,
pub total_views: i64,
pub unique_visitors: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub is_admin: bool,
}
impl PageAnalyticsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for PageAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+301 -6
View File
@@ -1,26 +1,41 @@
pub mod analytics;
pub mod dashboard; pub mod dashboard;
pub mod pages; pub mod pages;
pub mod settings; pub mod settings;
pub mod stats; pub mod stats;
pub mod urls; pub mod urls;
pub mod user_dashboard;
pub mod user_pages;
pub mod user_settings;
pub mod user_urls;
pub mod users;
pub use dashboard::DashboardTemplate; pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate};
pub use urls::UrlsTemplate;
use askama::Template; use askama::Template;
use axum::{ use axum::{
http::StatusCode, http::StatusCode,
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
pub use dashboard::DashboardTemplate;
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate};
pub use urls::UrlsTemplate;
pub use user_dashboard::UserDashboardTemplate;
pub use user_pages::UserPagesTemplate;
pub use user_settings::UserSettingsTemplate;
pub use user_urls::UserUrlsTemplate;
pub use users::UsersTemplate;
#[derive(Template)] #[derive(Template)]
#[template(path = "login.html")] #[template(path = "login.html")]
pub struct LoginTemplate { pub struct LoginTemplate {
pub error: Option<String>, pub error: Option<String>,
pub csrf_token: String, pub csrf_token: String,
pub action: String,
pub title: String,
pub subtitle: String,
pub button_text: String,
} }
impl IntoResponse for LoginTemplate { impl IntoResponse for LoginTemplate {
@@ -79,3 +94,283 @@ impl IntoResponse for PreviewTemplate {
} }
} }
} }
#[derive(Template)]
#[template(path = "users_new.html")]
pub struct UsersNewTemplate {
pub admin_username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersNewTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_detail.html")]
pub struct UserDetailTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub stats: crate::web::admin::UserDetailStats,
pub sessions: Vec<crate::models::UserSession>,
pub tokens: Vec<crate::models::UserApiToken>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserDetailTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_edit.html")]
pub struct UserEditTemplate {
pub admin_username: String,
pub target_user: crate::models::TenantUser,
pub quotas: crate::models::UserQuotas,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserEditTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "moderation.html")]
pub struct ModerationTemplate {
pub admin_username: String,
pub flagged_items: Vec<crate::web::admin::GlobalSlugRow>,
pub logs: Vec<crate::web::admin::ModerationLogEntry>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ModerationTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "slugs.html")]
pub struct SlugsTemplate {
pub admin_username: String,
pub slugs: Vec<crate::web::admin::GlobalSlugRow>,
pub history: Vec<crate::web::admin::SlugHistoryRow>,
pub csrf_token: String,
pub search_filter: Option<String>,
pub owner_filter: Option<i64>,
pub status_filter: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SlugsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "sessions.html")]
pub struct SessionsTemplate {
pub admin_username: String,
pub sessions: Vec<crate::models::UserSession>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SessionsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "quotas.html")]
pub struct QuotasTemplate {
pub admin_username: String,
pub quotas: Vec<crate::models::UserQuotas>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for QuotasTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "health.html")]
pub struct HealthTemplate {
pub admin_username: String,
pub db_reports: Vec<crate::db::sqlite::DatabaseHealthReport>,
pub total_data_size: String,
pub system_db_size: String,
pub users_db_size: String,
pub admin_db_size: String,
pub tenants_db_size: String,
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub registry_errors: Vec<String>,
pub registry_warnings: Vec<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for HealthTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "backups.html")]
pub struct BackupsTemplate {
pub admin_username: String,
pub files: Vec<crate::web::admin::BackupFileRow>,
pub history: Vec<crate::web::admin::BackupHistoryRow>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for BackupsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "api_tokens.html")]
pub struct ApiTokensTemplate {
pub admin_username: String,
pub username: String,
pub tokens: Vec<crate::models::UserApiToken>,
pub new_token: Option<String>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for ApiTokensTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_analytics.html")]
pub struct UserAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub total_clicks: i64,
pub unique_visitors: i64,
pub direct_clicks: i64,
pub referred_clicks: i64,
pub referrers_chart: String,
pub browsers_chart: String,
pub visits: Vec<crate::models::VisitRecord>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+9
View File
@@ -12,6 +12,15 @@ pub struct PagesTemplate {
pub pages: Vec<LandingPage>, pub pages: Vec<LandingPage>,
pub csrf_token: String, pub csrf_token: String,
pub error: Option<String>, pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl PagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
} }
impl IntoResponse for PagesTemplate { impl IntoResponse for PagesTemplate {
+47
View File
@@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate {
} }
} }
} }
#[derive(Template)]
#[template(path = "user_status.html")]
pub struct UserStatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for UserStatusTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
impl IntoResponse for UserAuditTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+9
View File
@@ -14,6 +14,15 @@ pub struct UrlsTemplate {
pub error: Option<String>, pub error: Option<String>,
pub tag_filter: Option<String>, pub tag_filter: Option<String>,
pub base_url: String, pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
} }
impl IntoResponse for UrlsTemplate { impl IntoResponse for UrlsTemplate {
+33
View File
@@ -0,0 +1,33 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_dashboard.html")]
pub struct UserDashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for UserDashboardTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_pages.html")]
pub struct UserPagesTemplate {
pub admin_username: String,
pub username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserPagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserPagesTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_settings.html")]
pub struct UserSettingsTemplate {
pub admin_username: String,
pub username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserSettingsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::models::Url;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_urls.html")]
pub struct UserUrlsTemplate {
pub admin_username: String,
pub username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserUrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserUrlsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use crate::models::TenantUser;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate {
pub admin_username: String,
pub users: Vec<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod network;
pub mod random; pub mod random;
pub mod system; pub mod system;
pub mod time; pub mod time;
pub mod validation;
pub use hashing::sha256_hash; pub use hashing::sha256_hash;
pub use network::get_client_ip; pub use network::get_client_ip;
+5 -4
View File
@@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String {
pub fn get_db_file_info(data_dir: &Path) -> String { pub fn get_db_file_info(data_dir: &Path) -> String {
let mut stats = String::new(); let mut stats = String::new();
let files = vec![ let files = vec![
("admin.db", "Admin DB"), ("admin/admin.db", "Admin DB"),
("content.db", "Content DB"), ("admin/system.db", "System DB"),
("analytics.db", "Analytics DB"), ("admin/users.db", "Users DB"),
("system.db", "System DB"), ("users/1/content.db", "Legacy Content DB"),
("users/1/analytics.db", "Legacy Analytics DB"),
]; ];
for (f, name) in files { for (f, name) in files {
+19
View File
@@ -0,0 +1,19 @@
pub fn validate_custom_slug(slug: &str) -> bool {
if !slug.starts_with('!') {
return false;
}
let rest = &slug[1..];
if rest.is_empty() || rest.len() > 24 {
return false;
}
rest.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn validate_redirect_code(code: &str) -> bool {
(code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
pub fn validate_page_code(code: &str) -> bool {
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
+5848 -106
View File
File diff suppressed because it is too large. Load diff
+270 -65
View File
@@ -33,6 +33,9 @@ pub struct CreateUrlRequest {
pub expires_at: Option<String>, pub expires_at: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub max_access_count: Option<i64>, pub max_access_count: Option<i64>,
pub utm_source: Option<String>,
pub utm_medium: Option<String>,
pub utm_campaign: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -84,17 +87,47 @@ pub async fn api_create_url(
if code.is_empty() { if code.is_empty() {
code = generate_token(3); // 6 hex code = generate_token(3); // 6 hex
} else { } else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(&code) {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(ApiError { Json(ApiError {
error: "Short code must be 6 hex characters".to_string(), error: "Short code must be 6 hex characters or a custom slug starting with !"
.to_string(),
}), }),
) )
.into_response(); .into_response();
} }
} }
let mut dest = payload.destination.trim().to_string();
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(ref src) = payload.utm_source {
if !src.trim().is_empty() {
query.append_pair("utm_source", src.trim());
has_utm = true;
}
}
if let Some(ref med) = payload.utm_medium {
if !med.trim().is_empty() {
query.append_pair("utm_medium", med.trim());
has_utm = true;
}
}
if let Some(ref camp) = payload.utm_campaign {
if !camp.trim().is_empty() {
query.append_pair("utm_campaign", camp.trim());
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
let password_hash = if let Some(ref pwd) = payload.password { let password_hash = if let Some(ref pwd) = payload.password {
if pwd.is_empty() { if pwd.is_empty() {
None None
@@ -116,25 +149,110 @@ pub async fn api_create_url(
None None
}; };
// Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let tags = payload.tags.unwrap_or_default(); let tags = payload.tags.unwrap_or_default();
let conn = state.content_db.lock().unwrap(); let res = {
match crate::db::content::create_url_extended( let conn = content_db.lock().unwrap();
&conn, crate::db::content::create_url_extended(
&code, &conn,
&payload.destination, &code,
payload.title.as_deref(), &dest,
payload.description.as_deref(), payload.title.as_deref(),
&tags, payload.description.as_deref(),
payload.expires_at.as_deref(), &tags,
password_hash.as_deref(), payload.expires_at.as_deref(),
payload.max_access_count, password_hash.as_deref(),
) { payload.max_access_count,
)
};
match res {
Ok(url) => { Ok(url) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_CREATION", "URL_CREATION",
Some("url"), Some("url"),
Some(&url.id), Some(&url.id),
@@ -147,7 +265,7 @@ pub async fn api_create_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_CREATION", "URL_CREATION",
"url", "url",
&url.id, &url.id,
@@ -156,24 +274,17 @@ pub async fn api_create_url(
} }
(StatusCode::CREATED, Json(url)).into_response() (StatusCode::CREATED, Json(url)).into_response()
} }
Err(rusqlite::Error::SqliteFailure(err, _)) Err(e) => {
if err.code == rusqlite::ErrorCode::ConstraintViolation => let system_conn = state.system_db.lock().unwrap();
{ let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
( (
StatusCode::CONFLICT, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: e.to_string(),
}), }),
) )
.into_response() .into_response()
} }
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
} }
} }
@@ -281,7 +392,7 @@ pub async fn api_update_url(
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_UPDATE", "URL_UPDATE",
Some("url"), Some("url"),
Some(&uuid), Some(&uuid),
@@ -294,7 +405,7 @@ pub async fn api_update_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_UPDATE", "URL_UPDATE",
"url", "url",
&uuid, &uuid,
@@ -336,7 +447,7 @@ pub async fn api_delete_url(
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"URL_DELETION", "URL_DELETION",
Some("url"), Some("url"),
Some(&uuid), Some(&uuid),
@@ -349,7 +460,7 @@ pub async fn api_delete_url(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"URL_DELETION", "URL_DELETION",
"url", "url",
&uuid, &uuid,
@@ -390,32 +501,125 @@ pub async fn api_create_page(
if code.is_empty() { if code.is_empty() {
code = generate_token(2); // 4 hex code = generate_token(2); // 4 hex
} else { } else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_page_code(&code) {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(ApiError { Json(ApiError {
error: "Short code must be 4 hex characters".to_string(), error: "Short code must be 4 hex characters or start with ! followed by 1-24 characters of a-z, 0-9, -, _".to_string(),
}), }),
) )
.into_response(); .into_response();
} }
} }
let conn = state.content_db.lock().unwrap(); // Dynamically resolve target user ID and content DB
match create_landing_page( let (target_user_id, content_db) = match user.0 {
&conn, crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
&code, crate::models::ApiActor::User(ref u) => {
&payload.slug, let user_dbs = match state.get_user_dbs(u.id) {
&payload.title, Ok(dbs) => dbs,
&payload.html_content, Err(_) => {
&payload.state, return (
) { StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"page",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let res = {
let conn = content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&payload.slug,
&payload.title,
&payload.html_content,
&payload.state,
)
};
match res {
Ok(page) => { Ok(page) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if payload.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
target_user_id,
"landings",
);
}
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_CREATION", "PAGE_CREATION",
Some("page"), Some("page"),
Some(&page.id), Some(&page.id),
@@ -424,24 +628,17 @@ pub async fn api_create_page(
); );
(StatusCode::CREATED, Json(page)).into_response() (StatusCode::CREATED, Json(page)).into_response()
} }
Err(rusqlite::Error::SqliteFailure(err, _)) Err(e) => {
if err.code == rusqlite::ErrorCode::ConstraintViolation => let system_conn = state.system_db.lock().unwrap();
{ let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
( (
StatusCode::CONFLICT, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: e.to_string(),
}), }),
) )
.into_response() .into_response()
} }
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
} }
} }
@@ -516,7 +713,7 @@ pub async fn api_update_page(
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_UPDATE", "PAGE_UPDATE",
Some("page"), Some("page"),
Some(&uuid), Some(&uuid),
@@ -557,7 +754,7 @@ pub async fn api_delete_page(
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
&state.admin_db.lock().unwrap(), &state.admin_db.lock().unwrap(),
&user.0.username, user.0.username(),
"PAGE_DELETION", "PAGE_DELETION",
Some("page"), Some("page"),
Some(&uuid), Some(&uuid),
@@ -596,7 +793,11 @@ pub struct OverallStatsResponse {
} }
// GET /api/v1/stats // GET /api/v1/stats
pub async fn api_overall_stats(State(state): State<AppState>, _user: ApiUser) -> Response { pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) -> Response {
if let Err(err) = user.require_admin() {
return err.into_response();
}
let (total_urls, active_links, dead_links) = { let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap(); let conn = state.content_db.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0)) get_url_counts(&conn).unwrap_or((0, 0, 0))
@@ -785,9 +986,13 @@ pub struct AuditQuery {
// GET /api/v1/audit // GET /api/v1/audit
pub async fn api_list_audit( pub async fn api_list_audit(
State(state): State<AppState>, State(state): State<AppState>,
_user: ApiUser, user: ApiUser,
Query(query): Query<AuditQuery>, Query(query): Query<AuditQuery>,
) -> Response { ) -> Response {
if let Err(err) = user.require_admin() {
return err.into_response();
}
let limit = query.limit.unwrap_or(50); let limit = query.limit.unwrap_or(50);
let offset = query.offset.unwrap_or(0); let offset = query.offset.unwrap_or(0);
@@ -856,7 +1061,7 @@ pub async fn api_set_preview(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"SET_PREVIEW", "SET_PREVIEW",
"url", "url",
&uuid, &uuid,
@@ -943,7 +1148,7 @@ pub async fn api_delete_preview(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"DELETE_PREVIEW", "DELETE_PREVIEW",
"url", "url",
&uuid, &uuid,
@@ -1018,7 +1223,7 @@ pub async fn api_set_password(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"SET_PASSWORD", "SET_PASSWORD",
"url", "url",
&uuid, &uuid,
@@ -1076,7 +1281,7 @@ pub async fn api_remove_password(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"REMOVE_PASSWORD", "REMOVE_PASSWORD",
"url", "url",
&uuid, &uuid,
@@ -1144,7 +1349,7 @@ pub async fn api_create_qr(
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"CREATE_QR", "CREATE_QR",
"qr_code", "qr_code",
&payload.url_id, &payload.url_id,
+139 -15
View File
@@ -120,7 +120,7 @@ pub async fn api_bulk_qr(
let system_conn = state.db.system.lock().unwrap(); let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"BULK_QR_EXPORT", "BULK_QR_EXPORT",
"bulk", "bulk",
"qr", "qr",
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
.into_response(); .into_response();
} }
let mut conn = state.content_db.lock().unwrap(); // Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if let Some(quotas) =
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
{
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
} else {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "User quota not found".to_string(),
}),
)
.into_response();
}
}
let mut conn = content_db.lock().unwrap();
let tx = match conn.transaction() { let tx = match conn.transaction() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
}; };
let mut created_urls = Vec::new(); let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload { for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase(); let mut code = item.code.unwrap_or_default().trim().to_lowercase();
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
} else { } else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback(); let _ = tx.rollback();
// Release reserving slugs
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
} }
} }
// Reserve slug
{
let system_conn = state.system_db.lock().unwrap();
// Check availability in system.db and also check in our currently reserved slugs in this batch
let available = crate::db::users::is_slug_available(&system_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to reserve slug '{}': {}", code, e),
}),
)
.into_response();
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password { let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) { match hash_password(pwd) {
Ok(h) => Some(h), Ok(h) => Some(h),
Err(e) => { Err(e) => {
let _ = tx.rollback(); let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(
&system_conn,
slug,
target_user_id,
);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
item.max_access_count, item.max_access_count,
) { ) {
Ok(url) => created_urls.push(url), Ok(url) => created_urls.push(url),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let _ = tx.rollback();
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
Err(e) => { Err(e) => {
let _ = tx.rollback(); let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
} }
if let Err(e) = tx.commit() { if let Err(e) = tx.commit() {
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
.into_response(); .into_response();
} }
// Activate slugs
{
let system_conn = state.system_db.lock().unwrap();
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = state.users_db.lock().unwrap();
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
}
// Write Audit Log for the entire batch // Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
@@ -271,7 +395,7 @@ pub async fn api_bulk_url(
let system_conn = state.db.system.lock().unwrap(); let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
&user.0.username, user.0.username(),
"BULK_URL_CREATION", "BULK_URL_CREATION",
"bulk", "bulk",
"url", "url",
+1
View File
@@ -2,6 +2,7 @@ pub mod admin;
pub mod api; pub mod api;
pub mod bulk; pub mod bulk;
pub mod middleware; pub mod middleware;
pub mod multi_user;
pub mod pages; pub mod pages;
pub mod password_gate; pub mod password_gate;
pub mod qr; pub mod qr;
+991
View File
@@ -0,0 +1,991 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use chrono::Utc;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::ApiUser;
use crate::models::ApiActor;
use crate::state::AppState;
#[derive(Serialize, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub account_type: Option<String>,
pub metadata: Option<String>,
}
#[derive(Serialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
pub status: String,
pub account_type: String,
pub created_at: String,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserStatusRequest {
pub status: String,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserQuotasRequest {
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ResetPasswordRequest {
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct TransferSlugRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ModerateSlugRequest {
pub slug: String,
pub action: String, // 'flagged', 'disabled', 'active'
pub severity: String, // 'low', 'medium', 'high', 'critical'
pub reason: String,
}
#[derive(Serialize)]
pub struct ModerationEventResponse {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Serialize, Deserialize)]
pub struct ChangeOwnPasswordRequest {
pub old_password: String,
pub new_password: String,
}
#[derive(Serialize, Deserialize)]
pub struct CreateApiTokenRequest {
// No request body needed, token is generated securely
}
#[derive(Serialize)]
pub struct CreateApiTokenResponse {
pub id: i64,
pub token: String, // Cleartext token returned once
pub created_at: String,
}
// Helper: Ensure the request actor is an Admin
#[allow(clippy::result_large_err)]
fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> {
match &user.0 {
ApiActor::Admin(admin) => Ok(admin),
_ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()),
}
}
// --- Admin: User CRUD Endpoints ---
// GET /api/v1/admin/users
pub async fn admin_list_users(State(state): State<AppState>, user: ApiUser) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::list_users(&conn) {
Ok(users) => {
let resp: Vec<UserResponse> = users
.into_iter()
.map(|u| UserResponse {
id: u.id,
username: u.username,
status: u.status,
account_type: u.account_type,
created_at: u.created_at,
metadata: u.metadata,
})
.collect();
Json(resp).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users
pub async fn admin_create_user(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<CreateUserRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// Username validation: minimum 3 chars, alphanumeric, hyphen, underscore
let username = payload.username.trim().to_lowercase();
if username.len() < 3 {
return (
StatusCode::BAD_REQUEST,
"Username must be at least 3 characters",
)
.into_response();
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return (
StatusCode::BAD_REQUEST,
"Username must contain only alphanumeric characters, hyphens, or underscores",
)
.into_response();
}
// Hash password
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
let account_type = payload.account_type.as_deref().unwrap_or("standard");
match crate::db::users::create_user(
&conn,
&username,
&hash,
account_type,
payload.metadata.as_deref(),
) {
Ok(new_user) => {
// Write system audit event
{
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_CREATION",
"user",
&new_user.id.to_string(),
Some(&format!("Username: {}", new_user.username)),
);
}
Json(UserResponse {
id: new_user.id,
username: new_user.username,
status: new_user.status,
account_type: new_user.account_type,
created_at: new_user.created_at,
metadata: new_user.metadata,
})
.into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
(StatusCode::CONFLICT, "Username already exists").into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/status
pub async fn admin_update_user_status(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserStatusRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let status = payload.status.trim().to_lowercase();
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid user status").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_status(&conn, target_id, &status) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_STATUS_UPDATE",
"user",
&target_id.to_string(),
Some(&format!("New Status: {}", status)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/quotas
pub async fn admin_update_user_quotas(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserQuotasRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_quotas(
&conn,
target_id,
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb,
) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_QUOTA_UPDATE",
"user",
&target_id.to_string(),
Some(&format!(
"max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}",
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb
)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users/:id/password
pub async fn admin_reset_user_password(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<ResetPasswordRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&conn, target_id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_PASSWORD_RESET",
"user",
&target_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub fn delete_user_resources(
state: &AppState,
target_id: i64,
admin_username: &str,
force: bool,
) -> Result<(), String> {
if target_id == 1 && !force {
return Err("Deleting legacy_admin system account requires force flag".to_string());
}
let user_details = {
let conn = state.users_db.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, target_id) {
Ok(Some(u)) => u,
Ok(None) => return Err("User not found".to_string()),
Err(e) => return Err(e.to_string()),
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = state.system_db.lock().unwrap();
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
let slugs: Vec<String> = {
let mut stmt = tx
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([target_id], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, target_id, now, admin_username],
);
}
tx.commit()
.map_err(|e| format!("Failed to release slugs: {}", e))?;
}
let user_dir = state
.config
.data_dir
.join("users")
.join(target_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
let conn = state.users_db.lock().unwrap();
crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?;
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"USER_DELETION",
"user",
&target_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
Ok(())
}
// DELETE /api/v1/admin/users/:id
pub async fn admin_delete_user(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let force = params.get("force").map(|v| v == "true").unwrap_or(false);
match delete_user_resources(&state, target_id, &admin.username, force) {
Ok(_) => StatusCode::OK.into_response(),
Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(),
Err(err) if err == "Deleting legacy_admin system account requires force flag" => {
(StatusCode::BAD_REQUEST, err).into_response()
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
// --- Admin: Slug Transfer ---
// POST /api/v1/admin/transfers
pub async fn admin_transfer_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<TransferSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// 1. Check if the slug exists and get details
let (old_owner_user_id, target_type, _target_id) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let row_opt = stmt
.query_row([&payload.slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional();
match row_opt {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
};
if old_owner_user_id == payload.new_owner_user_id {
return (
StatusCode::BAD_REQUEST,
"New owner must be different from the current owner",
)
.into_response();
}
// 2. Fetch destination databases
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load current owner's database",
)
.into_response()
}
};
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load new owner's database",
)
.into_response()
}
};
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
let mut new_target_id = String::new();
let transfer_success = {
let old_conn = old_dbs.content.lock().unwrap();
let new_conn = new_dbs.content.lock().unwrap();
if target_type == "url" {
// Get URL record
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
Ok(u) => u,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(url) = url_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_urls >= quota.max_urls {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded URL quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = 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,
);
match ins_res {
Ok(new_url) => {
new_target_id = new_url.id;
// Delete from old owner database
let _ = crate::db::content::delete_url(&old_conn, &url.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy URL to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else if target_type == "page" {
// Get page record
let page_opt =
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
Ok(p) => p,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(page) = page_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_landings >= quota.max_landings {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded landing page quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
);
match ins_res {
Ok(new_page) => {
new_target_id = new_page.id;
// Delete from old owner database
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy Page to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else {
false
}
};
if !transfer_success {
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
}
// 4. Update system global_slugs, slug_history and adjust quotas
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
);
// Adjust quotas
let users_conn = state.users_db.lock().unwrap();
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,
payload.new_owner_user_id,
field,
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"SLUG_TRANSFER",
"slug",
&payload.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, payload.new_owner_user_id
)),
);
}
StatusCode::OK.into_response()
}
// --- Admin: Content Moderation ---
// POST /api/v1/admin/moderation
pub async fn admin_moderate_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ModerateSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let action = payload.action.trim().to_lowercase();
if !["flagged", "disabled", "active"].contains(&action.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
}
// 1. Verify slug and get owner user ID
let (owner_user_id, target_type) = {
let system_conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&payload.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt {
Some(r) => r,
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
}
};
// Resolve owner username for log snapshot
let owner_username = {
let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
.unwrap_or(None)
.map(|u| u.username)
};
// 2. Perform moderation update in global_slugs
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![action, now, payload.slug],
);
// Record moderation event
let event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
admin.username,
owner_user_id,
owner_username,
target_type,
payload.slug,
action,
payload.severity,
payload.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"CONTENT_MODERATION",
"slug",
&payload.slug,
Some(&format!("Action: {}, Reason: {}", action, payload.reason)),
);
}
StatusCode::OK.into_response()
}
// GET /api/v1/admin/moderation/events
pub async fn admin_list_moderation_events(
State(state): State<AppState>,
user: ApiUser,
) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason
FROM moderation_events ORDER BY timestamp DESC;"
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let rows = stmt.query_map([], |row| {
Ok(ModerationEventResponse {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
});
match rows {
Ok(mapped) => {
let events: Vec<ModerationEventResponse> = mapped.filter_map(|r| r.ok()).collect();
Json(events).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// --- User: Dashboard, profile settings, API tokens ---
// GET /api/v1/user/profile
pub async fn user_get_profile(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Profile endpoints are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
Json(serde_json::json!({
"id": tenant_user.id,
"username": tenant_user.username,
"status": tenant_user.status,
"account_type": tenant_user.account_type,
"created_at": tenant_user.created_at,
"metadata": tenant_user.metadata,
"quotas": quotas,
}))
.into_response()
}
// POST /api/v1/user/password
pub async fn user_change_password(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ChangeOwnPasswordRequest>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Change password is for tenant users only",
)
.into_response();
}
};
// Verify old password
if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) {
return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response();
}
// Hash new password
let hash = match crate::auth::password::hash_password(&payload.new_password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"PASSWORD_CHANGE",
"user",
&tenant_user.id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// GET /api/v1/user/api-tokens
pub async fn user_list_api_tokens(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) {
Ok(tokens) => Json(tokens).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/user/api-tokens
pub async fn user_create_api_token(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
// 1. Quota check
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
if let Some(quota) = quotas {
if quota.current_api_tokens >= quota.max_api_tokens {
return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response();
}
}
// 2. Generate secure token
let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars
// Hash token using SHA-256 for storing
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token_secret.as_bytes());
let token_hash = hex::encode(hasher.finalize());
match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) {
Ok(api_token) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_CREATION",
"api_token",
&api_token.id.to_string(),
None,
);
Json(CreateApiTokenResponse {
id: api_token.id,
token: token_secret, // Return cleartext once
created_at: api_token.created_at,
})
.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// DELETE /api/v1/user/api-tokens/:id
pub async fn user_delete_api_token(
State(state): State<AppState>,
user: ApiUser,
Path(token_id): Path<i64>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_DELETION",
"api_token",
&token_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
+65 -5
View File
@@ -4,12 +4,12 @@ use axum::{
response::{Html, IntoResponse, Response}, response::{Html, IntoResponse, Response},
}; };
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::services::landing_pages::get_landing_page_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -21,15 +21,62 @@ pub async fn resolve_page(
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_page_code(&code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let page_opt = match get_landing_page_by_code(&state.db, &code) { // 1. Query global slug namespace in system.db
Ok(page) => page, let slug_info = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
) {
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
stmt.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
};
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
Ok(Some(info)) => info,
Ok(None) => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "page".to_string(), "".to_string(), "active".to_string())
}
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return (
StatusCode::GONE,
"This content has been disabled or moderated",
)
.into_response();
}
// 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let page_opt = {
let conn = content_conn.lock().unwrap();
match crate::db::content::get_landing_page_by_code(&conn, &code) {
Ok(page) => page,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
match page_opt { match page_opt {
Some(page) => { Some(page) => {
// Check state // Check state
@@ -67,6 +114,7 @@ pub async fn resolve_page(
accept_language, accept_language,
country, country,
status_code: 200, status_code: 200,
owner_user_id: Some(owner_user_id),
}; };
state.analytics_queue.push(record); state.analytics_queue.push(record);
@@ -82,7 +130,7 @@ pub async fn resolve_page(
// Serve static root landing page from www/index.html // Serve static root landing page from www/index.html
pub async fn root_landing() -> Response { pub async fn root_landing() -> Response {
let mut target_path = std::path::PathBuf::from("www/index.html"); let mut target_path = std::path::PathBuf::from("www/index.html");
if !target_path.exists() { if !target_path.exists() {
// Search relative to executable // Search relative to executable
if let Ok(exe_path) = std::env::current_exe() { if let Ok(exe_path) = std::env::current_exe() {
@@ -121,3 +169,15 @@ pub async fn root_landing() -> Response {
} }
} }
pub async fn deploy_script() -> Response {
match tokio::fs::read("www/deploy.sh").await {
Ok(content) => (
StatusCode::OK,
[("content-type", "text/plain; charset=utf-8")],
content,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
}
}
+102 -39
View File
@@ -1,5 +1,4 @@
use crate::services::qr::{generate_qr_png, generate_qr_svg}; use crate::services::qr::{generate_qr_png, generate_qr_svg};
use crate::services::shortener::get_url_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
use axum::{ use axum::{
@@ -24,9 +23,10 @@ pub async fn qr_handler(
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok()); let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
let authenticated = if let Some(auth) = auth_header { let authenticated = if let Some(auth) = auth_header {
let conn = state.admin_db.lock().unwrap(); let admin_conn = state.admin_db.lock().unwrap();
let users_conn = state.users_db.lock().unwrap();
matches!( matches!(
crate::auth::session::authenticate_api_key(&conn, auth), crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth),
Ok(Some(_user)) Ok(Some(_user))
) )
} else { } else {
@@ -37,26 +37,57 @@ pub async fn qr_handler(
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
} }
let url_opt = match get_url_by_code(&state.db, &file) { // We need to look up owner_user_id, target_id, and status from global_slugs
Ok(u) => u, let (owner_user_id, target_id, slug_status) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
};
use rusqlite::OptionalExtension;
match stmt
.query_row(rusqlite::params![&file], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
{
Ok(Some((uid, tid, status))) => (uid, tid, status),
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
};
if slug_status == "disabled" {
return (StatusCode::GONE, "This content has been disabled").into_response();
} else if slug_status != "active" {
return (StatusCode::NOT_FOUND, "URL not found").into_response();
}
let user_dbs = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
};
let qr_scans = { let qr_scans = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0) crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
}; };
let direct_clicks = { let direct_clicks = {
let conn = state.analytics_db.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;", "SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
rusqlite::params![url.id], rusqlite::params![target_id],
|row| row.get(0), |row| row.get(0),
) )
.unwrap_or(0) .unwrap_or(0)
@@ -72,19 +103,44 @@ pub async fn qr_handler(
let code = parts[0]; let code = parts[0];
let ext = parts[1].to_lowercase(); let ext = parts[1].to_lowercase();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(code)
&& !crate::utils::validation::validate_page_code(code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let url_opt = match get_url_by_code(&state.db, code) { // We need to look up owner_user_id, target_type, target_id, and status from global_slugs
Ok(u) => u, let (owner_user_id, target_type, target_id, slug_status) = {
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn
.prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
{
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
use rusqlite::OptionalExtension;
match stmt
.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
{
Ok(Some(info)) => info,
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
let url = match url_opt { if slug_status == "disabled" {
Some(u) => u, return (StatusCode::GONE, "This content has been disabled").into_response();
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(), } else if slug_status != "active" {
}; return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
// Construct public base URL // Construct public base URL
let proto = if state.config.cookie_secure { let proto = if state.config.cookie_secure {
@@ -103,7 +159,11 @@ pub async fn qr_handler(
.clone() .clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header)); .unwrap_or_else(|| format!("{}://{}", proto, host_header));
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code); let full_url = if target_type == "page" {
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
} else {
format!("{}/{}", base_url.trim_end_matches('/'), code)
};
// Generate QR code based on format // Generate QR code based on format
let (body, content_type) = if ext == "svg" { let (body, content_type) = if ext == "svg" {
@@ -136,22 +196,25 @@ pub async fn qr_handler(
.into_response(); .into_response();
}; };
// Log the QR access event // Log the QR access event in a try-catch style
let ip = get_client_ip(&headers, connect_info); let _ = {
let user_agent = headers let ip = get_client_ip(&headers, connect_info);
.get("user-agent") let user_agent = headers
.and_then(|h| h.to_str().ok()) .get("user-agent")
.map(|s| s.to_string()); .and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
{ if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
let analytics_conn = state.db.analytics.lock().unwrap(); if let Ok(analytics_conn) = user_dbs.analytics.lock() {
let _ = crate::db::qr::log_qr_access( let _ = crate::db::qr::log_qr_access(
&analytics_conn, &analytics_conn,
&url.id, &target_id,
Some(ip.as_str()), Some(ip.as_str()),
user_agent.as_deref(), user_agent.as_deref(),
); );
} }
}
};
Response::builder() Response::builder()
.header("content-type", content_type) .header("content-type", content_type)
+77 -14
View File
@@ -5,12 +5,12 @@ use axum::{
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::models::VisitRecord; use crate::models::VisitRecord;
use crate::services::shortener::get_url_by_code;
use crate::state::AppState; use crate::state::AppState;
use crate::templates::PreviewTemplate; use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
@@ -24,22 +24,76 @@ pub async fn resolve_redirect(
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
// Basic validation of code (must be 6 hex characters) // Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(&code)
&& !crate::utils::validation::validate_page_code(&code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let url_opt = match get_url_by_code(&state.db, &code) { // 1. Query global slug namespace in system.db
Ok(url) => url, let slug_info = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
) {
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
stmt.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
};
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Ok(Some(info)) => info,
Ok(None) => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "url".to_string(), "".to_string(), "active".to_string())
}
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return (
StatusCode::GONE,
"This content has been disabled or moderated",
)
.into_response();
}
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return Redirect::permanent(&format!("/p/{}", code)).into_response();
}
// 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url_opt = {
let conn = content_conn.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &code) {
Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let url = match url_opt { let url = match url_opt {
Some(u) => u, Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(), None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
}; };
// 1. Expiration check // 3. Expiration check
if url.expired { if url.expired {
return (StatusCode::GONE, "This link has expired").into_response(); return (StatusCode::GONE, "This link has expired").into_response();
} }
@@ -49,7 +103,7 @@ pub async fn resolve_redirect(
if expires_at.with_timezone(&Utc) < Utc::now() { if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately // Mark as expired in DB asynchronously/immediately
{ {
let conn = state.db.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
let _ = conn.execute( let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;", "UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()], [url.id.clone()],
@@ -60,7 +114,7 @@ pub async fn resolve_redirect(
} }
} }
// 2. Access limit check // 4. Access limit check
if url.is_access_exhausted() { if url.is_access_exhausted() {
return ( return (
StatusCode::GONE, StatusCode::GONE,
@@ -69,7 +123,7 @@ pub async fn resolve_redirect(
.into_response(); .into_response();
} }
// 3. Password protection check // 5. Password protection check
if url.is_password_protected() { if url.is_password_protected() {
let cookie_name = format!("bzod_gate_{}", code); let cookie_name = format!("bzod_gate_{}", code);
let authorized = jar let authorized = jar
@@ -82,14 +136,14 @@ pub async fn resolve_redirect(
} }
} }
// 4. Increment access count & retrieve preview config // 6. Increment access count & retrieve preview config
let _new_access_count = { let _new_access_count = {
let conn = state.db.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1) crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
}; };
let preview_opt = { let preview_opt = {
let conn = state.db.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
}; };
@@ -123,12 +177,13 @@ pub async fn resolve_redirect(
accept_language, accept_language,
country, country,
status_code: if preview_opt.is_some() { 200 } else { 302 }, status_code: if preview_opt.is_some() { 200 } else { 302 },
owner_user_id: Some(owner_user_id),
}; };
// Push to memory queue (non-blocking) // Push to memory queue (non-blocking)
state.analytics_queue.push(record); state.analytics_queue.push(record);
// 5. Render Preview or Redirect // 7. Render Preview or Redirect
if let Some(preview) = preview_opt { if let Some(preview) = preview_opt {
PreviewTemplate { PreviewTemplate {
code, code,
@@ -140,6 +195,14 @@ pub async fn resolve_redirect(
} }
.into_response() .into_response()
} else { } else {
Redirect::temporary(&url.destination).into_response() {
use axum::http::{header, HeaderValue};
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&url.destination).unwrap(),
);
resp
}
} }
} }
+176 -2
View File
@@ -1,7 +1,7 @@
use crate::state::AppState; use crate::state::AppState;
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system}; use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
use axum::{ use axum::{
routing::{get, post}, routing::{delete, get, post, put},
Router, Router,
}; };
@@ -22,6 +22,62 @@ pub fn create_router(state: AppState) -> Router {
// --- System Health & Diagnostics --- // --- System Health & Diagnostics ---
.route("/status", get(system::status_endpoint)) .route("/status", get(system::status_endpoint))
.route("/metrics", get(system::metrics_endpoint)) .route("/metrics", get(system::metrics_endpoint))
// --- Public User Login ---
.route(
"/login",
get(admin::public_login_get).post(admin::public_login_post),
)
.route("/logout", get(admin::public_logout))
.route("/user/dashboard", get(admin::user_dashboard_get))
.route("/user/urls", get(admin::user_urls_get))
.route("/user/urls/create", post(admin::user_urls_create))
.route("/user/urls/delete/:id", post(admin::user_urls_delete))
.route("/user/audit", get(admin::user_audit_get))
.route("/user/status", get(admin::user_status_get))
.route("/user/pages", get(admin::user_pages_get))
.route("/user/pages/create", post(admin::user_pages_create))
.route("/user/pages/delete/:id", post(admin::user_pages_delete))
.route("/user/settings", get(admin::user_settings_get))
.route(
"/user/settings/password",
post(admin::user_change_password_post),
)
.route("/user/settings/backup", get(admin::user_download_backup))
.route(
"/user/settings/restore",
post(admin::user_restore_backup_post),
)
.route("/analytics", get(admin::user_analytics_get))
.route(
"/user/analytics/url/:id",
get(admin::user_url_analytics_get),
)
.route(
"/user/analytics/url/:id/export/csv",
get(admin::user_url_analytics_csv_export),
)
.route(
"/user/analytics/url/:id/export/json",
get(admin::user_url_analytics_json_export),
)
.route(
"/user/analytics/page/:id",
get(admin::user_page_analytics_get),
)
.route(
"/user/analytics/page/:id/export/csv",
get(admin::user_page_analytics_csv_export),
)
.route(
"/user/analytics/page/:id/export/json",
get(admin::user_page_analytics_json_export),
)
.route("/api-tokens", get(admin::api_tokens_get))
.route("/api-tokens/create", post(admin::api_tokens_create_post))
.route(
"/api-tokens/revoke/:id",
post(admin::api_tokens_revoke_post),
)
// --- Admin UI Login/Logout --- // --- Admin UI Login/Logout ---
.route("/admin", get(admin::admin_index)) .route("/admin", get(admin::admin_index))
.route( .route(
@@ -37,7 +93,44 @@ pub fn create_router(state: AppState) -> Router {
.route("/admin/pages", get(admin::pages_get)) .route("/admin/pages", get(admin::pages_get))
.route("/admin/pages/create", post(admin::pages_create)) .route("/admin/pages/create", post(admin::pages_create))
.route("/admin/pages/delete/:id", post(admin::pages_delete)) .route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
.route("/deploy.sh", get(pages::deploy_script))
.route(
"/admin/analytics/url/:id/export/csv",
get(admin::url_analytics_csv_export),
)
.route(
"/admin/analytics/url/:id/export/json",
get(admin::url_analytics_json_export),
)
.route("/admin/analytics/page/:id", get(admin::page_analytics_get))
.route(
"/admin/analytics/page/:id/export/csv",
get(admin::page_analytics_csv_export),
)
.route(
"/admin/analytics/page/:id/export/json",
get(admin::page_analytics_json_export),
)
.route("/admin/settings", get(admin::settings_get)) .route("/admin/settings", get(admin::settings_get))
.route("/admin/users", get(admin::users_get))
.route("/admin/users/new", get(admin::users_new_get))
.route("/admin/users/:id", get(admin::user_detail_get))
.route(
"/admin/users/:id/edit",
get(admin::user_edit_get).post(admin::user_edit_post),
)
.route("/admin/users/create", post(admin::users_create_post))
.route(
"/admin/users/status/:id",
post(admin::users_update_status_post),
)
.route("/admin/users/type/:id", post(admin::users_update_type_post))
.route(
"/admin/users/password/:id",
post(admin::users_reset_password_post),
)
.route("/admin/users/delete/:id", post(admin::users_delete_post))
.route( .route(
"/admin/settings/password", "/admin/settings/password",
post(admin::change_password_post), post(admin::change_password_post),
@@ -48,6 +141,7 @@ pub fn create_router(state: AppState) -> Router {
) )
.route("/admin/settings/compact", post(admin::compact_db_post)) .route("/admin/settings/compact", post(admin::compact_db_post))
.route("/admin/settings/backup", get(admin::download_backup)) .route("/admin/settings/backup", get(admin::download_backup))
.route("/admin/settings/restore", post(admin::restore_backup_post))
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post)) .route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
.route( .route(
"/admin/settings/api-keys/create", "/admin/settings/api-keys/create",
@@ -59,6 +153,39 @@ pub fn create_router(state: AppState) -> Router {
) )
.route("/admin/audit", get(admin::audit_get)) .route("/admin/audit", get(admin::audit_get))
.route("/admin/status", get(admin::status_get)) .route("/admin/status", get(admin::status_get))
.route(
"/admin/moderation",
get(admin::moderation_get).post(admin::moderation_post),
)
.route("/admin/slugs", get(admin::slugs_get))
.route("/admin/slugs/transfer", post(admin::slugs_transfer_post))
.route("/admin/slugs/status", post(admin::slugs_status_post))
.route("/admin/slugs/delete", post(admin::slugs_delete_post))
.route("/admin/sessions", get(admin::sessions_get))
.route(
"/admin/sessions/revoke/:id",
post(admin::sessions_revoke_post),
)
.route(
"/admin/sessions/revoke-all",
post(admin::sessions_revoke_all_post),
)
.route(
"/admin/quotas",
get(admin::quotas_get).post(admin::quotas_post),
)
.route("/admin/health", get(admin::health_get))
.route("/admin/backups", get(admin::backups_get))
.route("/admin/backups/create", post(admin::backups_create_post))
.route(
"/admin/backups/download/:filename",
get(admin::backups_download_get),
)
.route(
"/admin/backups/delete/:filename",
post(admin::backups_delete_post),
)
.route("/admin/backups/restore", post(admin::backups_restore_post))
// --- REST API v1 JSON Endpoints --- // --- REST API v1 JSON Endpoints ---
.route( .route(
"/api/v1/urls", "/api/v1/urls",
@@ -104,6 +231,53 @@ pub fn create_router(state: AppState) -> Router {
"/api/v1/urls/:uuid/password", "/api/v1/urls/:uuid/password",
post(api::api_set_password).delete(api::api_remove_password), post(api::api_set_password).delete(api::api_remove_password),
) )
// --- Multi-User REST API v1 Admin Endpoints ---
.route(
"/api/v1/admin/users",
get(multi_user::admin_list_users).post(multi_user::admin_create_user),
)
.route(
"/api/v1/admin/users/:id/status",
put(multi_user::admin_update_user_status),
)
.route(
"/api/v1/admin/users/:id/quotas",
put(multi_user::admin_update_user_quotas),
)
.route(
"/api/v1/admin/users/:id/password",
post(multi_user::admin_reset_user_password),
)
.route(
"/api/v1/admin/users/:id",
delete(multi_user::admin_delete_user),
)
.route(
"/api/v1/admin/transfers",
post(multi_user::admin_transfer_slug),
)
.route(
"/api/v1/admin/moderation",
post(multi_user::admin_moderate_slug),
)
.route(
"/api/v1/admin/moderation/events",
get(multi_user::admin_list_moderation_events),
)
// --- Multi-User REST API v1 Tenant User Endpoints ---
.route("/api/v1/user/profile", get(multi_user::user_get_profile))
.route(
"/api/v1/user/password",
post(multi_user::user_change_password),
)
.route(
"/api/v1/user/api-tokens",
get(multi_user::user_list_api_tokens).post(multi_user::user_create_api_token),
)
.route(
"/api/v1/user/api-tokens/:id",
delete(multi_user::user_delete_api_token),
)
// --- Static Asset Stub --- // --- Static Asset Stub ---
.route( .route(
"/static/style.css", "/static/style.css",
+6 -5
View File
@@ -6,7 +6,7 @@ use axum::{
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use serde::Serialize; use serde::Serialize;
use crate::auth::{authenticate_api_key, authenticate_session}; use crate::auth::{authenticate_admin_session, authenticate_api_key};
use crate::db::admin::get_user_count; use crate::db::admin::get_user_count;
use crate::state::AppState; use crate::state::AppState;
use crate::utils::{get_db_file_info, get_memory_usage}; use crate::utils::{get_db_file_info, get_memory_usage};
@@ -15,15 +15,16 @@ use crate::utils::{get_db_file_info, get_memory_usage};
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool { fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
// 1. Try Authorization header // 1. Try Authorization header
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) { if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
let conn = state.admin_db.lock().unwrap(); let admin_conn = state.admin_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) { let users_conn = state.users_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) {
return true; return true;
} }
} }
// 2. Try cookie session // 2. Try cookie session
let conn = state.admin_db.lock().unwrap(); let conn = state.users_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_session(&conn, jar) { if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) {
return true; return true;
} }
+82
View File
@@ -0,0 +1,82 @@
{% extends "user_layout.html" %}
{% block title %}My API Tokens - BZOD{% endblock %}
{% block active_tokens %}active{% endblock %}
{% block header_title %}API Tokens & Automation{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Token Card -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Generate API Token</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
API tokens allow you to automate link shortening and landing page creations. Generated tokens are hashed immediately; you will only be shown the raw token once.
</p>
{% if let Some(raw_token) = new_token %}
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); padding: 1rem; border-radius: 8px; margin-bottom: 1.25rem; word-break: break-all;">
<span style="display: block; font-size: 0.75rem; color: var(--success-color); font-weight: 700; margin-bottom: 0.25rem; text-transform: uppercase;">Raw Token (Copy now!)</span>
<code style="font-size: 1.1rem; color: var(--text-primary); font-family: monospace; font-weight: 600;">{{ raw_token }}</code>
</div>
{% endif %}
<form action="/api-tokens/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="width: 100%;">Generate New Token</button>
</form>
</div>
<!-- Active Tokens List -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Active API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active API tokens generated yet.
</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/api-tokens/revoke/{{ t.id }}" method="POST" onsubmit="return confirm('Revoke this API token? Any applications using it will be blocked.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends "layout.html" %}
{% block title %}Backup Management - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}Database Backups Console{% endblock %}
{% block header_actions %}
<form action="/admin/backups/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn">Create Backup Archive</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
<!-- List of Backup Files -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Available Backup Archives</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Archive File</th>
<th>File Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup archive files found in backups folder.
</td>
</tr>
{% else %}
{% for f in files %}
<tr>
<td>
<strong style="font-family: monospace;">{{ f.filename }}</strong>
</td>
<td>{{ f.size_str }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ f.created_str }}</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a href="/admin/backups/download/{{ f.filename }}" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Download</a>
<form action="/admin/backups/delete/{{ f.filename }}" method="POST" style="margin: 0;" onsubmit="return confirm('Delete backup file {{ f.filename }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Restore Database Panel -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem; color: var(--danger-color);">Restore Platform Databases</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
To restore the system databases, select a `.tar.gz` backup archive file. Warning: This will overwrite all active user accounts, quotas, links, and analytics data!
</p>
<form action="/admin/backups/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Upload Backup File (.tar.gz)</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to continue</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%;">Upload & Restore Now</button>
</form>
</div>
</div>
<!-- Backup History Log -->
<div class="card" style="padding: 0; overflow: hidden; margin-top: 1.5rem;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Backup Audit History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Backup File Path</th>
<th>Status</th>
<th>Size</th>
<th>Error Message</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup execution logs found.
</td>
</tr>
{% else %}
{% for h in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ h.created_at[0..19].replace("T", " ") }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ h.backup_path }}</td>
<td>
<span class="badge {% if h.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ h.status }}
</span>
</td>
<td>{{ h.size_bytes }} B</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ h.error_message.as_deref().unwrap_or("-") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+9
View File
@@ -0,0 +1,9 @@
<td style="text-align: center; vertical-align: middle;">
<a href="/api/qr/{{ code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
<a href="/api/qr/{{ code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</td>
+203
View File
@@ -0,0 +1,203 @@
{% extends "layout.html" %}
{% block title %}System Health & Diagnostics - BZOD{% endblock %}
{% block active_status %}active{% endblock %}
{% block header_title %}System Health Dashboard{% endblock %}
{% block content %}
{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %}
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem;">
{% if !registry_errors.is_empty() %}
<div class="card" style="border: 1px solid var(--dead-color); background-color: rgba(220, 53, 69, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: var(--dead-color); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Errors (Action Required)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for err in registry_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if !registry_warnings.is_empty() %}
<div class="card" style="border: 1px solid #ffc107; background-color: rgba(255, 193, 7, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: #ffc107; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Warnings (Attention Needed)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for warn in registry_warnings %}
<li>{{ warn }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- DB Health Report -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Database Structural Health
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Database File</th>
<th>Schema Ver.</th>
<th>Journal Mode</th>
<th>FK State</th>
<th>Integrity Check</th>
</tr>
</thead>
<tbody>
{% for db in db_reports %}
<tr>
<td><strong>{{ db.database }}.db</strong></td>
<td>v{{ db.schema_version }}</td>
<td><span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ db.journal_mode }}</span></td>
<td>{% if db.foreign_keys_enabled %}ON{% else %}OFF{% endif %}</td>
<td>
{% if db.integrity_ok %}
<span class="badge badge-healthy">Passed</span>
{% else %}
<span class="badge badge-dead">Corrupt/Failed</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Storage Utilization -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
Storage Utilization
</h3>
</div>
<div style="padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem;">
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Total Data Directory Size:</span>
<strong>{{ total_data_size }}</strong>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>System Database size (`system.db`):</span>
<span>{{ system_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Users/Quotas Database size (`users.db`):</span>
<span>{{ users_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
<span>Administration database (`admin.db`):</span>
<span>{{ admin_db_size }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span>Standard Tenants Databases (`/users/*`):</span>
<span>{{ tenants_db_size }}</span>
</div>
</div>
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Job Execution Status -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Maintenance & Reconcile Jobs
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Job Name</th>
<th>Last Run Status</th>
<th>Started At</th>
<th>Error Msg</th>
</tr>
</thead>
<tbody>
{% if job_history.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No job history logs.</td>
</tr>
{% else %}
{% for job in job_history %}
<tr>
<td><strong>{{ job.job_name }}</strong></td>
<td>
<span class="badge {% if job.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ job.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ job.started_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.8rem; color: var(--text-secondary); max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{ job.error_message.as_deref().unwrap_or("-") }}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Health Check Diagnostics -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Diagnostics Checks (URLs/Pages)
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Resource</th>
<th>Status</th>
<th>Code</th>
<th>Checked At</th>
</tr>
</thead>
<tbody>
{% if health_checks.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No resource diagnostics logs.</td>
</tr>
{% else %}
{% for check in health_checks %}
<tr>
<td><span style="font-family: monospace;">{{ check.object_type }}:{{ check.object_id }}</span></td>
<td>
<span class="badge {% if check.is_healthy == 1 %}badge-healthy{% else %}badge-dead{% endif %}">
{% if check.is_healthy == 1 %}healthy{% else %}unhealthy{% endif %}
</span>
</td>
<td>{{ check.status_code.unwrap_or(0) }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ check.checked_at[0..16].replace("T", " ") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+10
View File
@@ -378,6 +378,7 @@
</div> </div>
<ul class="nav-links"> <ul class="nav-links">
{% block sidebar_links %}
<li class="{% block active_dashboard %}{% endblock %}"> <li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard"> <a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
@@ -396,6 +397,12 @@
Landing Pages Landing Pages
</a> </a>
</li> </li>
<li class="{% block active_users %}{% endblock %}">
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li class="{% block active_settings %}{% endblock %}"> <li class="{% block active_settings %}{% endblock %}">
<a href="/admin/settings"> <a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
@@ -414,8 +421,10 @@
Status Status
</a> </a>
</li> </li>
{% endblock %}
</ul> </ul>
{% block sidebar_footer %}
<div class="sidebar-footer"> <div class="sidebar-footer">
<div class="admin-user-info"> <div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div> <div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
@@ -423,6 +432,7 @@
</div> </div>
<a href="/admin/logout" class="logout-btn">Log Out</a> <a href="/admin/logout" class="logout-btn">Log Out</a>
</div> </div>
{% endblock %}
</div> </div>
<!-- Main Workspace --> <!-- Main Workspace -->
+5 -5
View File
@@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - BZOD Platform</title> <title>{{ title }} - BZOD Platform</title>
<style> <style>
:root { :root {
--bg-base: #070a13; --bg-base: #070a13;
@@ -143,9 +143,9 @@
<div class="login-card"> <div class="login-card">
<div class="logo"> <div class="logo">
BZOD <span class="logo-dot"></span> {{ title }} <span class="logo-dot"></span>
</div> </div>
<p class="subtitle">Personal Redirects & Landing Pages</p> <p class="subtitle">{{ subtitle }}</p>
{% if let Some(err) = error %} {% if let Some(err) = error %}
<div class="alert-error"> <div class="alert-error">
@@ -153,7 +153,7 @@
</div> </div>
{% endif %} {% endif %}
<form action="/admin/login" method="POST"> <form action="{{ action }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group"> <div class="form-group">
@@ -166,7 +166,7 @@
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password"> <input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
</div> </div>
<button type="submit" class="btn">Sign In</button> <button type="submit" class="btn">{{ button_text }}</button>
</form> </form>
</div> </div>
+158
View File
@@ -0,0 +1,158 @@
{% extends "layout.html" %}
{% block title %}Content Moderation - BZOD{% endblock %}
{% block active_moderation %}active{% endblock %}
{% block header_title %}Content Moderation Panel{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Moderation Form -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Moderate a Resource</h3>
<form action="/admin/moderation" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="slug">Resource Slug</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. !hello or abcdef" required>
</div>
<div class="form-group">
<label for="action">Moderation Action</label>
<select id="action" name="action" class="form-input">
<option value="active">Activate (Publicly accessible)</option>
<option value="flagged">Flag (Flagged, visible to admins)</option>
<option value="disabled">Disable (Returns 410 Gone)</option>
<option value="deleted">Delete (Release slug completely)</option>
</select>
</div>
<div class="form-group">
<label for="severity">Severity Level</label>
<select id="severity" name="severity" class="form-input">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high" selected>High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label for="reason">Reason / Notes</label>
<textarea id="reason" name="reason" class="form-input" rows="3" placeholder="Violation detail, abuse report summary..." required></textarea>
</div>
<button type="submit" class="btn">Apply Action</button>
</form>
</div>
<!-- Active Flags & Disabled Resources -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Currently Flagged or Disabled Content</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner ID</th>
<th>Type</th>
<th>Status</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% if flagged_items.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No resources are currently flagged or disabled.
</td>
</tr>
{% else %}
{% for item in flagged_items %}
<tr>
<td>
<strong style="color: var(--accent-color);">/{{ item.slug }}</strong>
</td>
<td>{{ item.owner_user_id }}</td>
<td>{{ item.target_type }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ item.updated_at[0..10] }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Moderation Events Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Moderation Action Log</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Operator</th>
<th>Target User</th>
<th>Resource</th>
<th>Action</th>
<th>Severity</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No moderation actions recorded.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong>{{ log.admin_username }}</strong></td>
<td>ID: {{ log.target_user_id }}</td>
<td><span style="font-family: monospace;">{{ log.resource_type }}:{{ log.resource_identifier }}</span></td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ log.action }}</span>
</td>
<td>
<span class="badge" style="background-color: {% if log.severity == "critical" %}rgba(239,68,68,0.2){% else %}rgba(255,255,255,0.05){% endif %}; color: {% if log.severity == "critical" %}var(--danger-color){% else %}var(--text-secondary){% endif %}; font-weight: 700;">
{{ log.severity }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.reason }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+317
View File
@@ -0,0 +1,317 @@
{% extends "layout.html" %}
{% block title %}Landing Page Analytics - {{ page.slug }} - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block sidebar_links %}
{% if is_admin %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li>
<a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="active">
<a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li>
<a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1-1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/admin/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/admin/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% else %}
<li>
<a href="/user/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li>
<a href="/user/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="active">
<a href="/user/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/user/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/user/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/user/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% endif %}
{% endblock %}
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
</div>
{% endblock %}
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
{% block header_actions %}
<div style="display: flex; gap: 0.5rem;">
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/csv{% else %}/user/analytics/page/{{ page.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export CSV
</a>
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/json{% else %}/user/analytics/page/{{ page.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export JSON
</a>
<a href="{% if is_admin %}/admin/pages{% else %}/user/pages{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages
</a>
</div>
{% endblock %}
{% block content %}
<!-- Date Filter Form -->
<div class="card" style="margin-bottom: 2rem;">
<form method="GET" action="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div>
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_to" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date To</label>
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div>
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
</form>
</div>
<!-- Page Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Page Details
</h3>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Short Path</span>
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
/p/{{ page.code }}
</a>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">SEO Preview Path</span>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
/p/{{ page.code }}/{{ page.slug }}
</a>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Title</span>
<span style="font-weight: 500;">{{ page.title }}</span>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Publish State</span>
<span class="badge badge-{{ page.state }}">{{ page.state }}</span>
</div>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Created Date</span>
<span>{{ page.created_at[0..10] }} {{ page.created_at[11..16] }}</span>
</div>
</div>
</div>
<!-- Overview Stats Cards -->
<div class="grid-stats" style="margin-bottom: 2rem; grid-template-columns: repeat(2, 1fr);">
<div class="card stat-card">
<span class="stat-label">Total Views</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_views }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Unique Visitors</span>
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
</div>
</div>
<!-- Traffic Charts (Timeline) -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Daily Page Views (Last 30 Days)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ traffic_chart|safe }}
</div>
</div>
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Monthly Page Views
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ monthly_chart|safe }}
</div>
</div>
</div>
<!-- Geographic and Referrers Analysis -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Countries -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Geographic Analysis (Top Countries)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ countries_chart|safe }}
</div>
</div>
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="margin-top: 2rem; padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
<span class="badge badge-healthy" style="text-transform: none;">
{{ entry.country }}
</span>
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
<!-- Pagination Controls -->
<div style="padding: 1.25rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; gap: 0.75rem;">
<div style="font-size: 0.9rem; color: var(--text-secondary);">
Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
{% if current_page > 1 %}
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+43 -2
View File
@@ -36,12 +36,17 @@
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group"> <div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label> <label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters"> <input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div> </div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group"> <div class="form-group">
<label for="state">Publish State</label> <label for="state">Publish State</label>
<select id="state" name="state"> <select id="state" name="state">
@@ -78,6 +83,8 @@
<th>Short Path</th> <th>Short Path</th>
<th>SEO Preview Path</th> <th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th>
<th>QR Code</th>
<th>Created</th> <th>Created</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
@@ -85,7 +92,7 @@
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started! No landing pages registered. Create one to get started!
</td> </td>
</tr> </tr>
@@ -115,6 +122,13 @@
{{ page.state }} {{ page.state }}
</span> </span>
</td> </td>
<td>
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);"> <td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }} {{ page.created_at[0..10] }}
</td> </td>
@@ -132,6 +146,33 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Pagination Controls -->
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
{% if current_page > 1 %}
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "layout.html" %}
{% block title %}Quota Management - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Quota Management{% endblock %}
{% block header_actions %}
<form action="/admin/quotas" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile_all">
<button type="submit" class="btn">Sync & Reconcile All Quotas</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>URLs (Used/Max)</th>
<th>Pages (Used/Max)</th>
<th>API Tokens (Used/Max)</th>
<th>Storage (Used/Max MB)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if quotas.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No quotas defined.
</td>
</tr>
{% else %}
{% for q in quotas %}
<tr>
<td>
<a href="/admin/users/{{ q.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ q.user_id }}
</a>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_urls }} / {{ q.max_urls }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.urls_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_landings }} / {{ q.max_landings }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.landings_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_api_tokens }} / {{ q.max_api_tokens }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.api_tokens_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_storage_mb }} / {{ q.max_storage_mb }} MB</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.storage_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="/admin/users/{{ q.user_id }}/edit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Edit</a>
<form action="/admin/quotas" method="POST" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ q.user_id }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Reconcile</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "layout.html" %}
{% block title %}Active Sessions - BZOD{% endblock %}
{% block active_sessions %}active{% endblock %}
{% block header_title %}Session Administration{% endblock %}
{% block header_actions %}
<form action="/admin/sessions/revoke-all" method="POST" onsubmit="return confirm('Revoke ALL active user sessions? This will log out everyone including you.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Revoke All Sessions</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>Session ID (Masked)</th>
<th>Created At</th>
<th>Expires At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active sessions found in the system.
</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td>
<a href="/admin/users/{{ s.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ s.user_id }}
</a>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ s.id[0..6] }}...
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..19].replace("T", " ") }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+21
View File
@@ -105,6 +105,27 @@
Generates a tarball of admin.db, content.db, and analytics.db. Generates a tarball of admin.db, content.db, and analytics.db.
</p> </p>
</div> </div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: 0.5rem;">
<form action="/admin/settings/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="backup_file" style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.5rem;">Restore Database from Backup</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" style="padding: 0.35rem 0.5rem; margin-bottom: 0.75rem;" required accept=".tar.gz">
<p style="font-size: 0.8rem; color: #fca5a5; margin-bottom: 0.75rem; line-height: 1.4; font-weight: 500;">
Warning: This operation will overwrite all current data.
</p>
<div class="form-group" style="margin-bottom: 0.75rem;">
<label for="confirm_text" style="font-size: 0.75rem; color: var(--text-secondary);">Type RESTORE to continue:</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%; justify-content: center;">
Restore Backup
</button>
</form>
</div>
</div> </div>
</div> </div>
+181
View File
@@ -0,0 +1,181 @@
{% extends "layout.html" %}
{% block title %}Global Slug Namespace - BZOD{% endblock %}
{% block active_slugs %}active{% endblock %}
{% block header_title %}Global Slug Directory{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<!-- Search & Filtering -->
<div class="card">
<form action="/admin/slugs" method="GET" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="search">Search Slug</label>
<input type="text" id="search" name="search" class="form-input" placeholder="e.g. !hello" value="{% if let Some(s) = search_filter %}{{ s }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="owner">Owner User ID</label>
<input type="number" id="owner" name="owner" class="form-input" placeholder="e.g. 1" value="{% if let Some(o) = owner_filter %}{{ o }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="" selected>All Statuses</option>
<option value="active" {% if let Some(s) = status_filter %}{% if s == "active" %}selected{% endif %}{% endif %}>active</option>
<option value="flagged" {% if let Some(s) = status_filter %}{% if s == "flagged" %}selected{% endif %}{% endif %}>flagged</option>
<option value="disabled" {% if let Some(s) = status_filter %}{% if s == "disabled" %}selected{% endif %}{% endif %}>disabled</option>
</select>
</div>
<button type="submit" class="btn">Apply Filters</button>
</form>
</div>
<!-- Slugs Directory Table -->
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 1.5rem;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner (ID)</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if slugs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No registered slugs found matching filters.
</td>
</tr>
{% else %}
{% for item in slugs %}
<tr>
<td>
<strong style="color: var(--accent-color); font-family: monospace; font-size: 1.05rem;">/{{ item.slug }}</strong>
</td>
<td>
<a href="/admin/users/{{ item.owner_user_id }}" style="color: var(--text-primary); text-decoration: underline;">
User ID: {{ item.owner_user_id }}
</a>
</td>
<td>{{ item.target_type }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ item.target_id }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "active" %}rgba(16, 185, 129, 0.15){% else if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "active" %}var(--success-color){% else if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td>{{ item.created_at[0..10] }}</td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Transfer Form -->
<form action="/admin/slugs/transfer" method="POST" style="display: flex; gap: 0.25rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="number" name="new_owner_user_id" placeholder="New ID" required style="width: 80px; padding: 0.35rem 0.5rem; font-size: 0.85rem;">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Transfer</button>
</form>
<!-- Disable / Enable Form -->
{% if item.status == "active" %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Disable</button>
</form>
{% else %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.85rem; background: rgba(16,185,129,0.1); color: var(--success-color); border: 1px solid rgba(16,185,129,0.2);">Enable</button>
</form>
{% endif %}
<!-- Delete Form -->
<form action="/admin/slugs/delete" method="POST" onsubmit="return confirm('Release slug /{{ item.slug }}? this cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Slug History Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Slug Ownership History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>Old Owner</th>
<th>New Owner</th>
<th>Action</th>
<th>Admin</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No history records logged.
</td>
</tr>
{% else %}
{% for log in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="font-family: monospace;">/{{ log.slug }}</strong></td>
<td>{% if let Some(old_id) = log.old_owner_user_id %}User ID: {{ old_id }}{% else %}-{% endif %}</td>
<td>{% if let Some(new_id) = log.new_owner_user_id %}User ID: {{ new_id }}{% else %}-{% endif %}</td>
<td>
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ log.action }}</span>
</td>
<td>
{% if let Some(admin) = log.admin_username %}
{{ admin }}
{% else %}
System
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more