Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b03e0ea727 | ||
|
|
115f6e9a23 | ||
|
|
0295b4bd7c | ||
|
|
6a3c744667 | ||
|
|
19e48270ed | ||
|
|
133c707f27 | ||
|
|
496186e2af | ||
|
|
fe7e8efeef | ||
|
|
5193870c97 | ||
|
|
7fdc352547 | ||
|
|
49acf76cf6 | ||
|
|
c7e851000f | ||
|
|
c5f33e9713 | ||
|
|
7dfb8c0f1b | ||
|
|
743502b183 | ||
|
|
7ee6ab2feb | ||
|
|
621a1eccdc | ||
|
|
cefb84f643 | ||
|
|
9fae39dfa4 | ||
|
|
2212701b6b | ||
|
|
536d885a3d | ||
|
|
a4ffe9a509 | ||
|
|
ad05af95e9 | ||
|
|
17d8618443 | ||
|
|
74524cb7d2 | ||
|
|
e2140e6614 | ||
|
|
1a9bf096f3 | ||
|
|
900f72a299 | ||
|
|
d42f6da94a | ||
|
|
18d8b16a2c | ||
|
|
f6dcf58b79 | ||
|
|
84c48fa5c1 | ||
|
|
0e111d61ff | ||
|
|
81eb8950dd | ||
|
|
914563e883 | ||
|
|
8b521f1a71 | ||
|
|
f1d72c8cbe | ||
|
|
d9979ba04c | ||
|
|
6f42b43608 | ||
|
|
3e9dc47604 | ||
|
|
e2e61fc412 | ||
|
|
a0a73b918c | ||
|
|
d2174aa111 | ||
|
|
6a45474e97 | ||
|
|
c6486763e3 | ||
|
|
02a26cfd94 | ||
|
|
3c0a1bdd91 | ||
|
|
ee6d3135d5 | ||
|
|
671370571a | ||
|
|
42a2df33dd | ||
|
|
80038fb851 | ||
|
|
9c5e3e4d58 | ||
|
|
27c4ad6805 | ||
|
|
592154e961 | ||
|
|
bcbcc90d98 | ||
|
|
157aa81252 | ||
|
|
a55f40dc29 | ||
|
|
a516ecedb0 | ||
|
|
50718e57f0 |
No files matched your search
@@ -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
@@ -1,22 +1,85 @@
|
||||
name: Rust
|
||||
name: Rust CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
CARGO_INCREMENTAL: 0
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
jobs:
|
||||
test:
|
||||
name: Test & Quality Checks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
- name: Install Rust Toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Cache Cargo Dependencies
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Check Formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Run Clippy
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||
|
||||
- name: Build Release
|
||||
run: cargo build --release --verbose
|
||||
|
||||
- name: Run Tests
|
||||
run: cargo test --all-features --verbose
|
||||
|
||||
docker:
|
||||
name: Build Docker Image
|
||||
runs-on: ubuntu-latest
|
||||
needs: test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Build and Push Docker Image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: ${{ github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -5,3 +5,4 @@ data/
|
||||
*.db-shm
|
||||
.env
|
||||
|
||||
.idea/
|
||||
Generated
+237
-1
@@ -94,6 +94,15 @@ version = "1.0.102"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
|
||||
[[package]]
|
||||
name = "arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
|
||||
dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.5.3"
|
||||
@@ -193,6 +202,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
@@ -315,6 +325,18 @@ version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.11.1"
|
||||
@@ -323,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.1.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
@@ -333,7 +355,10 @@ dependencies = [
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"image",
|
||||
"qrcode",
|
||||
"rand 0.8.6",
|
||||
"reqwest",
|
||||
"rusqlite",
|
||||
@@ -347,6 +372,8 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -356,6 +383,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
@@ -442,6 +471,24 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -466,6 +513,12 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crossbeam-utils"
|
||||
version = "0.8.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.1.7"
|
||||
@@ -485,6 +538,17 @@ dependencies = [
|
||||
"powerfmt",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_arbitrary"
|
||||
version = "1.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.10.7"
|
||||
@@ -507,6 +571,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "document-features"
|
||||
version = "0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
|
||||
dependencies = [
|
||||
"litrs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dotenvy"
|
||||
version = "0.15.7"
|
||||
@@ -556,6 +629,15 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
@@ -612,6 +694,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
@@ -625,6 +718,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -972,6 +1066,19 @@ dependencies = [
|
||||
"icu_properties",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.0"
|
||||
@@ -1002,6 +1109,16 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.100"
|
||||
@@ -1060,6 +1177,12 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
|
||||
|
||||
[[package]]
|
||||
name = "litrs"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092"
|
||||
|
||||
[[package]]
|
||||
name = "lock_api"
|
||||
version = "0.4.14"
|
||||
@@ -1145,6 +1268,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -1260,6 +1393,19 @@ version = "0.3.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -1303,6 +1449,37 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "psl-types"
|
||||
version = "2.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac"
|
||||
|
||||
[[package]]
|
||||
name = "publicsuffix"
|
||||
version = "2.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf"
|
||||
dependencies = [
|
||||
"idna",
|
||||
"psl-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f"
|
||||
|
||||
[[package]]
|
||||
name = "qrcode"
|
||||
version = "0.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec"
|
||||
dependencies = [
|
||||
"image",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.9"
|
||||
@@ -1472,6 +1649,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -2763,8 +2942,65 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "2.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
"displaydoc",
|
||||
"flate2",
|
||||
"indexmap",
|
||||
"memchr",
|
||||
"thiserror",
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zopfli"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"crc32fast",
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd"
|
||||
version = "0.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a"
|
||||
dependencies = [
|
||||
"zstd-safe",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zstd-safe"
|
||||
version = "7.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d"
|
||||
dependencies = [
|
||||
"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
-3
@@ -1,11 +1,31 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
version = "0.1.0"
|
||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||
homepage = "https://bzo.in"
|
||||
documentation = "https://github.com/thakares/nx9-url-shortener"
|
||||
readme = "README.md"
|
||||
authors = ["Sunil P. Thakare"]
|
||||
|
||||
keywords = [
|
||||
"url-shortener",
|
||||
"landing-pages",
|
||||
"analytics",
|
||||
"qr-code",
|
||||
"self-hosted"
|
||||
]
|
||||
|
||||
categories = [
|
||||
"web-programming",
|
||||
"command-line-utilities"
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -19,12 +39,25 @@ askama = { version = "0.12" }
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
hex = "0.4"
|
||||
time = "0.3"
|
||||
toml = "0.8"
|
||||
qrcode = "0.14"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
zip = { version = "2.1", default-features = false, features = ["deflate"] }
|
||||
futures-util = "0.3"
|
||||
zstd = "0.13"
|
||||
|
||||
[lints.clippy]
|
||||
let_unit_value = "allow"
|
||||
useless_vec = "allow"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
+30
-24
@@ -1,7 +1,7 @@
|
||||
# ==========================================
|
||||
# Stage 1: Build
|
||||
# Stage 1: Builder (with optimized caching)
|
||||
# ==========================================
|
||||
FROM rust:1.82-slim-bookworm AS builder
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,33 +9,33 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy configuration files
|
||||
COPY Cargo.toml ./
|
||||
# Copy only Cargo files first (best caching)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Pre-build dependencies to cache them
|
||||
RUN mkdir src && echo "fn main() {}" > src/main.rs
|
||||
RUN cargo build --release
|
||||
RUN rm -rf src
|
||||
# Create dummy source for dependency caching
|
||||
RUN mkdir -p src && \
|
||||
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src target/release/deps/bzod*
|
||||
|
||||
# Copy source and templates
|
||||
# Copy real source code + assets
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY www ./www
|
||||
|
||||
# Trigger rebuilding with actual source
|
||||
RUN touch src/main.rs
|
||||
# Build the real application
|
||||
RUN cargo build --release
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Runner
|
||||
# Stage 2: Runtime (slim)
|
||||
# ==========================================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
# Runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
ca-certificates \
|
||||
@@ -45,23 +45,29 @@ RUN apt-get update && apt-get install -y \
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
|
||||
|
||||
# Create non-root user and data directory
|
||||
RUN groupadd -g 10001 bzod && \
|
||||
useradd -u 10001 -g bzod -m -s /bin/bash bzod
|
||||
# Copy assets
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/www ./www
|
||||
|
||||
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1000 bzod && \
|
||||
useradd -u 1000 -g bzod -m -s /bin/bash bzod
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R bzod:bzod /app
|
||||
|
||||
USER bzod
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV PORT=8080
|
||||
ENV HOST=0.0.0.0
|
||||
ENV COOKIE_SECURE=true
|
||||
ENV DATA_DIR=/app/data \
|
||||
PORT=8654 \
|
||||
HOST=0.0.0.0 \
|
||||
COOKIE_SECURE=true
|
||||
|
||||
EXPOSE 8080
|
||||
EXPOSE 8654
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1
|
||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
||||
|
||||
ENTRYPOINT ["bzod"]
|
||||
CMD ["serve"]
|
||||
File renamed without changes.
+21
@@ -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.
|
||||
@@ -1,11 +1,9 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# BZOD Deployment Script (Debian Native Deployment)
|
||||
# This script sets up a secure, production-ready systemd service for BZOD.
|
||||
# BZOD Production Deployment Script
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Configurations
|
||||
SERVICE_USER="bzod"
|
||||
INSTALL_PATH="/usr/local/bin/bzod"
|
||||
CONFIG_DIR="/etc/bzod"
|
||||
@@ -13,93 +11,134 @@ DATA_DIR="/var/lib/bzod/data"
|
||||
ENV_FILE="${CONFIG_DIR}/bzod.env"
|
||||
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
|
||||
|
||||
# Color outputs
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
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
|
||||
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
|
||||
fi
|
||||
|
||||
# 2. Install Package Dependencies
|
||||
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}"
|
||||
apt-get update
|
||||
# 1. Install Base Dependencies
|
||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
||||
apt-get update -qq
|
||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
||||
|
||||
# 3. Compile Production Build Locally
|
||||
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}"
|
||||
if ! command -v cargo &> /dev/null; then
|
||||
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}"
|
||||
# 2. Install Binary (safe atomic download)
|
||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${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
|
||||
fi
|
||||
|
||||
cargo build --release
|
||||
echo -e "${GREEN}Release build completed.${NC}"
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
|
||||
echo -e "${RED}Binary verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# 4. Install Binary
|
||||
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}"
|
||||
cp target/release/bzod "${INSTALL_PATH}"
|
||||
chmod 755 "${INSTALL_PATH}"
|
||||
chown root:root "${INSTALL_PATH}"
|
||||
echo -e "${GREEN}Binary installed successfully.${NC}"
|
||||
# Show installed version
|
||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||
|
||||
# 5. Create Dedicated locked-down System User
|
||||
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}"
|
||||
# 3. Create System User
|
||||
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
|
||||
if ! id -u "${SERVICE_USER}" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
|
||||
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
|
||||
else
|
||||
echo "User '${SERVICE_USER}' already exists."
|
||||
fi
|
||||
|
||||
# 6. Configure Directory Trees and Permissions
|
||||
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}"
|
||||
mkdir -p "${CONFIG_DIR}"
|
||||
mkdir -p "${DATA_DIR}"
|
||||
# 4. Setup Directories
|
||||
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
|
||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
|
||||
chmod 700 "${CONFIG_DIR}"
|
||||
|
||||
# Copy .env file if it exists, otherwise prompt/generate
|
||||
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "Copying local .env file to ${ENV_FILE}..."
|
||||
cp .env "${ENV_FILE}"
|
||||
elif [ ! -f "${ENV_FILE}" ]; then
|
||||
echo "Generating default configuration file at ${ENV_FILE}..."
|
||||
# 5. Configuration (preserve on upgrades)
|
||||
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo -e "${BLUE}Generating new secure configuration...${NC}"
|
||||
cat <<EOF > "${ENV_FILE}"
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
PORT=8654
|
||||
DATA_DIR=${DATA_DIR}
|
||||
COOKIE_SECURE=true
|
||||
RUST_LOG=info
|
||||
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
|
||||
chmod 600 "${ENV_FILE}"
|
||||
chown root:"${SERVICE_USER}" "${ENV_FILE}"
|
||||
else
|
||||
echo -e "${GREEN}Existing configuration preserved${NC}"
|
||||
fi
|
||||
|
||||
chmod 600 "${ENV_FILE}"
|
||||
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}"
|
||||
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
|
||||
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
|
||||
|
||||
# 7. Initialise DB as the service user (avoids file permission conflicts)
|
||||
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
|
||||
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
|
||||
echo -e "${GREEN}Databases initialised.${NC}"
|
||||
|
||||
# 8. Set Up Systemd Service
|
||||
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
|
||||
# 6. Systemd Service
|
||||
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
|
||||
cat <<EOF > "${SYSTEMD_UNIT}"
|
||||
[Unit]
|
||||
Description=BZOD - Personal URL Shortener & Landing Page Platform
|
||||
After=network.target
|
||||
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -107,11 +146,12 @@ User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=/var/lib/bzod
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR}
|
||||
ExecStart=${INSTALL_PATH} serve
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Hardening / Sandboxing options for security
|
||||
# Security Hardening
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
@@ -119,6 +159,10 @@ PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
NoNewPrivileges=yes
|
||||
ReadWritePaths=/var/lib/bzod
|
||||
|
||||
[Install]
|
||||
@@ -127,21 +171,56 @@ EOF
|
||||
|
||||
chmod 644 "${SYSTEMD_UNIT}"
|
||||
systemctl daemon-reload
|
||||
echo -e "${GREEN}Systemd service registered.${NC}"
|
||||
|
||||
# 9. Enable and Start the Service
|
||||
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}"
|
||||
systemctl enable bzod
|
||||
systemctl restart bzod
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
sleep 2
|
||||
if systemctl is-active --quiet bzod; then
|
||||
echo -e "${GREEN}BZOD service is running successfully!${NC}"
|
||||
echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}"
|
||||
echo -e "You can access BZOD at http://localhost:8080"
|
||||
echo -e "Admin Login Dashboard is at http://localhost:8080/admin"
|
||||
echo -e "System service logs: journalctl -u bzod -f"
|
||||
echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}"
|
||||
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
|
||||
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
|
||||
echo -e "${GREEN}✓ Databases initialized${NC}"
|
||||
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
|
||||
|
||||
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}"
|
||||
+54
-22
@@ -1,28 +1,60 @@
|
||||
name: app-bzod
|
||||
services:
|
||||
bzod:
|
||||
build:
|
||||
context: .
|
||||
context: /DATA/AppData/bzod
|
||||
dockerfile: Dockerfile
|
||||
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
container_name: bzod
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "8654:8654"
|
||||
|
||||
volumes:
|
||||
- /DATA/AppData/bzod/data:/app/data
|
||||
- /DATA/AppData/bzod/config:/app/config
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8654
|
||||
DATA_DIR: /app/data
|
||||
COOKIE_SECURE: "false"
|
||||
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8654/status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
- COOKIE_SECURE=false
|
||||
- DATA_DIR=/app/data
|
||||
- HOST=0.0.0.0
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.4.0
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
published: "8654"
|
||||
protocol: tcp
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/data
|
||||
target: /app/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/config
|
||||
target: /app/config
|
||||
bind:
|
||||
create_host_path: true
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/www
|
||||
target: /app/www
|
||||
devices: []
|
||||
cap_add: []
|
||||
networks:
|
||||
- default
|
||||
privileged: false
|
||||
networks:
|
||||
default:
|
||||
name: app_default
|
||||
x-casaos:
|
||||
author: self
|
||||
category: self
|
||||
hostname: ""
|
||||
icon: ""
|
||||
index: /
|
||||
is_uncontrolled: false
|
||||
port_map: "8654"
|
||||
scheme: http
|
||||
title:
|
||||
custom: nx9-url-shortener
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,273 @@
|
||||
# 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
|
||||
|
||||
## 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.
|
||||
@@ -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.
|
||||
+484
@@ -0,0 +1,484 @@
|
||||
# BZOD Testing & Validation Guide
|
||||
|
||||
## Overview
|
||||
|
||||
BZOD follows a defense-in-depth validation strategy.
|
||||
|
||||
A release is considered valid only when:
|
||||
|
||||
* Code quality checks pass
|
||||
* Automated tests pass
|
||||
* Upgrade validation passes
|
||||
* Backup/restore validation passes
|
||||
* Namespace integrity validation passes
|
||||
* Multi-user isolation validation passes
|
||||
* Disaster recovery validation passes
|
||||
|
||||
The objective is not simply to ensure the application starts, but to ensure that it can be safely upgraded, operated, backed up, restored, and recovered.
|
||||
|
||||
---
|
||||
|
||||
# Validation Philosophy
|
||||
|
||||
BZOD prioritizes:
|
||||
|
||||
1. Namespace Integrity
|
||||
2. Data Integrity
|
||||
3. Multi-Tenant Isolation
|
||||
4. Operational Simplicity
|
||||
5. Recovery Capability
|
||||
6. Security
|
||||
7. Functional Correctness
|
||||
|
||||
A successful release is not merely one that runs.
|
||||
|
||||
A successful release is one that can be recovered.
|
||||
|
||||
---
|
||||
|
||||
# Automated Test Coverage
|
||||
|
||||
Current validation suite includes:
|
||||
|
||||
* Unit Tests
|
||||
* Integration Tests
|
||||
* HTTP E2E Tests
|
||||
* Business Workflow Tests
|
||||
* Security Tests
|
||||
* Backup & Restore Tests
|
||||
* Disaster Recovery Tests
|
||||
* Migration Tests
|
||||
* Upgrade Validation Tests
|
||||
* Namespace Integrity Tests
|
||||
* Ownership Isolation Tests
|
||||
* Dashboard Parity Tests
|
||||
* QR Endpoint Tests
|
||||
* Concurrency Tests
|
||||
* WAL Recovery Tests
|
||||
|
||||
The platform currently executes approximately 100+ automated tests.
|
||||
|
||||
---
|
||||
|
||||
# 1. Build Validation
|
||||
|
||||
Verify successful compilation.
|
||||
|
||||
```bash
|
||||
cargo check
|
||||
cargo build
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* No compilation failures
|
||||
* Release binary generated
|
||||
|
||||
---
|
||||
|
||||
# 2. Formatting Validation
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* No formatting errors
|
||||
|
||||
---
|
||||
|
||||
# 3. Static Analysis
|
||||
|
||||
```bash
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* Zero warnings
|
||||
* Zero errors
|
||||
|
||||
---
|
||||
|
||||
# 4. Complete Automated Test Suite
|
||||
|
||||
```bash
|
||||
cargo test --all-targets -- --nocapture
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* All tests pass
|
||||
* No failures
|
||||
* No ignored critical tests
|
||||
|
||||
---
|
||||
|
||||
# 5. Database Initialization Validation
|
||||
|
||||
Create clean environment:
|
||||
|
||||
```bash
|
||||
rm -rf data
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
bzod stats
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* Database hierarchy created
|
||||
* Migrations applied
|
||||
* System healthy
|
||||
|
||||
Validate:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Overall status: HEALTHY
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 6. Namespace Integrity Validation
|
||||
|
||||
BZOD maintains a global slug namespace.
|
||||
|
||||
The following must never coexist:
|
||||
|
||||
```text
|
||||
Admin URL
|
||||
hello
|
||||
|
||||
User URL
|
||||
hello
|
||||
|
||||
Landing Page
|
||||
hello
|
||||
```
|
||||
|
||||
Validate:
|
||||
|
||||
```bash
|
||||
bzod doctor
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
No namespace conflicts detected
|
||||
```
|
||||
|
||||
Duplicate slugs must abort upgrade and restore operations.
|
||||
|
||||
---
|
||||
|
||||
# 7. Multi-User Isolation Validation
|
||||
|
||||
Verify:
|
||||
|
||||
* User A cannot access User B URLs
|
||||
* User A cannot access User B Pages
|
||||
* User A cannot access User B Analytics
|
||||
* User A cannot export User B analytics
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
403 Forbidden
|
||||
```
|
||||
|
||||
for all unauthorized access.
|
||||
|
||||
---
|
||||
|
||||
# 8. Dashboard Parity Validation
|
||||
|
||||
Verify:
|
||||
|
||||
## Administrator URLs
|
||||
|
||||
Contains:
|
||||
|
||||
* Analytics
|
||||
* QR Preview
|
||||
* PNG Download
|
||||
* SVG Download
|
||||
|
||||
## User URLs
|
||||
|
||||
Contains identical functionality.
|
||||
|
||||
Differences allowed:
|
||||
|
||||
* User Management
|
||||
* Moderation
|
||||
* Backups
|
||||
* Health
|
||||
* Audit
|
||||
* Quotas
|
||||
|
||||
Everything else must match.
|
||||
|
||||
---
|
||||
|
||||
# 9. Analytics Validation
|
||||
|
||||
Verify:
|
||||
|
||||
* URL Analytics
|
||||
* Landing Page Analytics
|
||||
* CSV Export
|
||||
* JSON Export
|
||||
* Date Filters
|
||||
* Charts
|
||||
* Referrer Breakdown
|
||||
* Country Breakdown
|
||||
* Browser Breakdown
|
||||
* Device Breakdown
|
||||
|
||||
Expected:
|
||||
|
||||
Administrator and owner views return identical analytics.
|
||||
|
||||
---
|
||||
|
||||
# 10. QR Validation
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
/api/qr/<slug>.png
|
||||
/api/qr/<slug>.svg
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```text
|
||||
Content-Type: image/png
|
||||
Content-Type: image/svg+xml
|
||||
```
|
||||
|
||||
Disabled resources:
|
||||
|
||||
```http
|
||||
410 Gone
|
||||
```
|
||||
|
||||
Missing resources:
|
||||
|
||||
```http
|
||||
404 Not Found
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 11. Routing Validation
|
||||
|
||||
URL resources:
|
||||
|
||||
```text
|
||||
/<slug>
|
||||
```
|
||||
|
||||
must redirect correctly.
|
||||
|
||||
Landing Pages:
|
||||
|
||||
```text
|
||||
/<slug>
|
||||
```
|
||||
|
||||
must redirect permanently to:
|
||||
|
||||
```text
|
||||
/p/<slug>
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
301 Moved Permanently
|
||||
```
|
||||
|
||||
and:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
```
|
||||
|
||||
for final landing page render.
|
||||
|
||||
---
|
||||
|
||||
# 12. Backup Validation
|
||||
|
||||
Create backup:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
Archive generated successfully.
|
||||
|
||||
Validate archive contents.
|
||||
|
||||
---
|
||||
|
||||
# 13. Restore Validation
|
||||
|
||||
Restore backup:
|
||||
|
||||
```bash
|
||||
bzod restore --file backup.tar.gz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
* Restore succeeds
|
||||
* All data preserved
|
||||
* Namespace integrity preserved
|
||||
|
||||
---
|
||||
|
||||
# 14. Collision Protection Validation
|
||||
|
||||
Attempt restore containing duplicate slugs.
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Restore aborted
|
||||
Slug conflict detected
|
||||
```
|
||||
|
||||
No partial restore.
|
||||
|
||||
---
|
||||
|
||||
# 15. Upgrade Validation
|
||||
|
||||
Verify upgrade from legacy deployments.
|
||||
|
||||
Expected:
|
||||
|
||||
* User databases migrated
|
||||
* Analytics preserved
|
||||
* Links preserved
|
||||
* Landing pages preserved
|
||||
* Authentication preserved
|
||||
|
||||
Duplicate slugs must abort upgrade.
|
||||
|
||||
---
|
||||
|
||||
# 16. Disaster Recovery Validation
|
||||
|
||||
Procedure:
|
||||
|
||||
1. Backup system
|
||||
2. Stop service
|
||||
3. Remove data directory
|
||||
4. Restore backup
|
||||
5. Start service
|
||||
|
||||
Expected:
|
||||
|
||||
* Full recovery
|
||||
* No manual repair
|
||||
* All URLs functional
|
||||
* All Landing Pages functional
|
||||
* Analytics preserved
|
||||
|
||||
---
|
||||
|
||||
# 17. Docker Validation
|
||||
|
||||
```bash
|
||||
docker compose build --no-cache
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
docker compose logs
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```text
|
||||
Server started successfully
|
||||
```
|
||||
|
||||
Container health:
|
||||
|
||||
```text
|
||||
healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 18. WAL Recovery Validation
|
||||
|
||||
Verify:
|
||||
|
||||
* SQLite WAL mode enabled
|
||||
* Recovery after backup succeeds
|
||||
* No corruption detected
|
||||
|
||||
---
|
||||
|
||||
# Release Validation Checklist
|
||||
|
||||
Before every release:
|
||||
|
||||
```bash
|
||||
cargo fmt --check
|
||||
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
|
||||
cargo test --all-targets -- --nocapture
|
||||
|
||||
cargo build --release
|
||||
|
||||
cargo audit
|
||||
```
|
||||
|
||||
Release is approved only if all steps succeed.
|
||||
|
||||
---
|
||||
|
||||
# Release Blockers
|
||||
|
||||
The following are release blockers:
|
||||
|
||||
* Namespace conflicts
|
||||
* Backup failure
|
||||
* Restore failure
|
||||
* Upgrade failure
|
||||
* Multi-user isolation failure
|
||||
* Ownership validation failure
|
||||
* Security test failure
|
||||
* Data corruption
|
||||
* Disaster recovery failure
|
||||
|
||||
A release that cannot be restored is not considered production ready.
|
||||
+795
@@ -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.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 116 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 150 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 111 KiB |
+63
-20
@@ -1,6 +1,6 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use crate::db::analytics::{clean_referrer, parse_ua};
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::HashMap;
|
||||
use crate::db::analytics::{parse_ua, clean_referrer};
|
||||
|
||||
// Run aggregation for a specific day
|
||||
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
|
||||
@@ -46,7 +46,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
for v in visits {
|
||||
let (browser, os, device) = parse_ua(&v.user_agent);
|
||||
let referrer = clean_referrer(&v.referer);
|
||||
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
|
||||
let country = if v.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
v.country.clone()
|
||||
};
|
||||
|
||||
let targets = vec![
|
||||
(v.target_type.clone(), v.target_id.clone()),
|
||||
@@ -55,22 +59,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
|
||||
for (t_type, t_id) in targets {
|
||||
// Clicks
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"clicks".to_string(),
|
||||
"".to_string(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Country
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"country".to_string(),
|
||||
country.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Browser
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"browser".to_string(),
|
||||
browser.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// OS
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Device
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"device".to_string(),
|
||||
device.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Referrer
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"referrer".to_string(),
|
||||
referrer.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +119,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
// Delete old aggregates for this day
|
||||
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
|
||||
tx.execute(
|
||||
"DELETE FROM daily_summaries WHERE date = ?1;",
|
||||
params![date],
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare(
|
||||
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
@@ -86,14 +130,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
)?;
|
||||
|
||||
for ((t_type, t_id, m_type, m_key), value) in aggregates {
|
||||
insert_stmt.execute(params![
|
||||
date,
|
||||
t_type,
|
||||
t_id,
|
||||
m_type,
|
||||
m_key,
|
||||
value
|
||||
])?;
|
||||
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -108,7 +145,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
|
||||
tx.execute(
|
||||
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
|
||||
params![year_month],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -125,7 +165,10 @@ pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> ru
|
||||
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
|
||||
tx.execute(
|
||||
"DELETE FROM yearly_summaries WHERE year = ?1;",
|
||||
params![year],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub enum AnalyticsEvent {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
pub mod aggregate;
|
||||
pub mod events;
|
||||
pub mod location;
|
||||
pub mod queue;
|
||||
pub mod worker;
|
||||
pub mod location;
|
||||
pub mod events;
|
||||
pub mod aggregate;
|
||||
|
||||
pub use queue::AnalyticsQueue;
|
||||
pub use location::get_client_country;
|
||||
pub use events::AnalyticsEvent;
|
||||
pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
|
||||
pub use events::AnalyticsEvent;
|
||||
pub use location::get_client_country;
|
||||
pub use queue::AnalyticsQueue;
|
||||
@@ -1,6 +1,6 @@
|
||||
use tokio::sync::mpsc;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnalyticsQueue {
|
||||
|
||||
+45
-9
@@ -1,11 +1,11 @@
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::{interval, MissedTickBehavior};
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::db::analytics::insert_visits_batch;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::db::analytics::insert_visits_batch;
|
||||
|
||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
let mut batch = Vec::new();
|
||||
@@ -46,11 +46,47 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Flushing {} visits to analytics database", batch.len());
|
||||
let mut conn_lock = db.analytics.lock().unwrap();
|
||||
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) {
|
||||
error!("Failed to write analytics batch to database: {:?}", e);
|
||||
} else {
|
||||
batch.clear();
|
||||
info!(
|
||||
"Flushing {} visits to user analytics databases",
|
||||
batch.len()
|
||||
);
|
||||
|
||||
// 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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Deterministic CSRF token derived from session token
|
||||
pub fn generate_csrf_token(session_id: &str) -> String {
|
||||
|
||||
+11
-9
@@ -1,13 +1,13 @@
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, FromRef},
|
||||
extract::{FromRef, FromRequestParts},
|
||||
http::{request::Parts, StatusCode},
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::models::User;
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
|
||||
// Extractor: Authenticate API requests using Bearer token
|
||||
pub struct ApiUser(pub User);
|
||||
pub struct ApiUser(pub ApiActor);
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for ApiUser
|
||||
@@ -19,14 +19,16 @@ where
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
let app_state = AppState::from_ref(state);
|
||||
let auth_header = parts.headers
|
||||
let auth_header = parts
|
||||
.headers
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
let conn = app_state.admin_db.lock().unwrap();
|
||||
match authenticate_api_key(&conn, auth_header) {
|
||||
Ok(Some(user)) => Ok(ApiUser(user)),
|
||||
let admin_conn = app_state.admin_db.lock().unwrap();
|
||||
let users_conn = app_state.users_db.lock().unwrap();
|
||||
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
Ok(Some(actor)) => Ok(ApiUser(actor)),
|
||||
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
|
||||
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
|
||||
}
|
||||
|
||||
+6
-4
@@ -1,9 +1,11 @@
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
pub mod csrf;
|
||||
pub mod middleware;
|
||||
pub mod password;
|
||||
pub mod session;
|
||||
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{generate_token, authenticate_session, authenticate_api_key};
|
||||
pub use csrf::{generate_csrf_token, verify_csrf};
|
||||
pub use middleware::ApiUser;
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{
|
||||
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
|
||||
};
|
||||
@@ -1,21 +1,25 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use argon2::{
|
||||
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Hashing password with Argon2id
|
||||
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
|
||||
let password_hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
Ok(password_hash)
|
||||
}
|
||||
|
||||
// Verifying Argon2id password hash
|
||||
pub fn verify_password(password: &str, hash: &str) -> bool {
|
||||
if let Ok(parsed_hash) = PasswordHash::new(hash) {
|
||||
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
+259
-19
@@ -1,10 +1,12 @@
|
||||
use sha2::{Sha256, Digest};
|
||||
use rand::{RngCore, thread_rng};
|
||||
use crate::db::admin::{
|
||||
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
|
||||
};
|
||||
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use rusqlite::Connection;
|
||||
use chrono::Utc;
|
||||
use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash};
|
||||
use crate::models::User;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
pub fn generate_token(bytes_len: usize) -> String {
|
||||
@@ -13,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
|
||||
hex::encode(key)
|
||||
}
|
||||
|
||||
// Authenticate session from cookies
|
||||
pub fn authenticate_session(
|
||||
// Authenticate administrator session from cookies
|
||||
pub fn authenticate_admin_session(
|
||||
conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(User, String)>, rusqlite::Error> {
|
||||
@@ -24,7 +26,33 @@ pub fn authenticate_session(
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
let session = match get_session(conn, session_id)? {
|
||||
let session_opt: Option<AdminSession> = conn
|
||||
.query_row(
|
||||
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
|
||||
[session_id],
|
||||
|row| {
|
||||
let id: String = row.get(0)?;
|
||||
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
|
||||
let user_id_str: String = match row.get::<_, String>(1) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let i: i64 = row.get(1)?;
|
||||
i.to_string()
|
||||
}
|
||||
};
|
||||
let expires_at: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(AdminSession {
|
||||
id,
|
||||
user_id: user_id_str,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let session = match session_opt {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -39,19 +67,171 @@ pub fn authenticate_session(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
|
||||
// Get user (status must be 'active' and account_type = 'admin')
|
||||
// If the session user_id looks numeric, bind as integer when querying users.db
|
||||
// Try the extended lookup but catch errors (e.g., missing columns in legacy admin DB)
|
||||
// Try the extended lookup; if it errors (legacy schema), perform a fallback lookup.
|
||||
let (user_opt, extended_failed) = match if let Ok(id_i64) = session.user_id.parse::<i64>() {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
|
||||
[id_i64],
|
||||
|row| {
|
||||
let id_str = row.get::<_, i64>(0)?.to_string();
|
||||
Ok(User {
|
||||
id: id_str,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at
|
||||
FROM users WHERE id = ?1 AND status = 'active' AND account_type = 'admin';",
|
||||
[session.user_id.as_str()],
|
||||
|row| {
|
||||
// admin DB stores UUID string ids, so read as String
|
||||
let id_str: String = row.get(0)?;
|
||||
Ok(User {
|
||||
id: id_str,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
} {
|
||||
Ok(opt) => (opt, false),
|
||||
Err(_) => (None, true),
|
||||
};
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some((user, session.id)));
|
||||
}
|
||||
|
||||
if extended_failed {
|
||||
// Fallback for legacy admin.db schemas which may not have `status`/`account_type` columns
|
||||
// Try a simpler lookup by id only.
|
||||
let fallback_user_opt = if let Ok(id_i64) = session.user_id.parse::<i64>() {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
|
||||
[id_i64],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get::<_, i64>(0)?.to_string(),
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None)
|
||||
} else {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;",
|
||||
[session.user_id.as_str()],
|
||||
|row| {
|
||||
Ok(User {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None)
|
||||
};
|
||||
|
||||
if let Some(user) = fallback_user_opt {
|
||||
Ok(Some((user, session.id)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate user session from cookies
|
||||
pub fn authenticate_user_session(
|
||||
users_conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(TenantUser, String)>, rusqlite::Error> {
|
||||
let cookie = match jar.get("bzod_user_session") {
|
||||
Some(c) => c,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
|
||||
// Get session from sessions table in users.db
|
||||
let mut stmt = users_conn
|
||||
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let session_opt: Option<UserSession> = stmt
|
||||
.query_row([session_id], |row| {
|
||||
Ok(UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
|
||||
let session = match session_opt {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Check expiration
|
||||
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
|
||||
if expires.with_timezone(&Utc) < Utc::now() {
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get tenant user (status must be 'active')
|
||||
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)))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Authenticate API key from Authorization header
|
||||
// Authenticate API key/token from Authorization header (unified)
|
||||
pub fn authenticate_api_key(
|
||||
conn: &Connection,
|
||||
admin_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
auth_header: &str,
|
||||
) -> Result<Option<User>, rusqlite::Error> {
|
||||
) -> Result<Option<ApiActor>, rusqlite::Error> {
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -66,13 +246,73 @@ pub fn authenticate_api_key(
|
||||
hasher.update(key.as_bytes());
|
||||
let hashed_key = hex::encode(hasher.finalize());
|
||||
|
||||
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
|
||||
// Update last used timestamp
|
||||
update_api_key_last_used(conn, &api_key_rec.id)?;
|
||||
// 1. Check user API tokens in users.db
|
||||
let mut stmt = users_conn.prepare("SELECT user_id FROM api_tokens WHERE token_hash = ?1;")?;
|
||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||
|
||||
// Get user
|
||||
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
|
||||
return Ok(Some(user));
|
||||
if let Some(user_id) = user_id_opt {
|
||||
let 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([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,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-3
@@ -37,7 +37,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
|
||||
|
||||
// Coordinates calculations
|
||||
let count = data.len();
|
||||
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w };
|
||||
let step_x = if count > 1 {
|
||||
chart_w / (count - 1) as f64
|
||||
} else {
|
||||
chart_w
|
||||
};
|
||||
|
||||
let mut points = Vec::new();
|
||||
for (i, &(_, val)) in data.iter().enumerate() {
|
||||
@@ -70,7 +74,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
|
||||
for (i, (label, _)) in data.iter().enumerate() {
|
||||
if i % label_step == 0 || i == count - 1 {
|
||||
let x = points[i].0;
|
||||
let short_label = if label.len() == 10 { &label[5..] } else { label };
|
||||
let short_label = if label.len() == 10 {
|
||||
&label[5..]
|
||||
} else {
|
||||
label
|
||||
};
|
||||
x_labels.push_str(&format!(
|
||||
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
|
||||
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
|
||||
@@ -78,7 +86,11 @@ pub fn generate_line_chart(data: &[(String, i64)]) -> String {
|
||||
|
||||
x_labels.push_str(&format!(
|
||||
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
|
||||
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR
|
||||
x,
|
||||
pad_top + chart_h,
|
||||
x,
|
||||
pad_top + chart_h + 5.0,
|
||||
DEFAULT_GRID_COLOR
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
pub mod svg;
|
||||
pub mod line;
|
||||
pub mod bar;
|
||||
pub mod line;
|
||||
pub mod pie;
|
||||
pub mod svg;
|
||||
pub mod timeseries;
|
||||
|
||||
pub use line::generate_line_chart;
|
||||
pub use bar::generate_bar_chart;
|
||||
pub use line::generate_line_chart;
|
||||
pub use pie::generate_pie_chart;
|
||||
pub use timeseries::generate_timeseries_chart;
|
||||
+8
-4
@@ -1,16 +1,20 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::jobs::backup::perform_backup;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
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); }
|
||||
if let Some(o) = out { config.backup_dir = PathBuf::from(o); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
if let Some(o) = out {
|
||||
config.backup_dir = PathBuf::from(o);
|
||||
}
|
||||
|
||||
// Init DB connections to ensure databases exist and migrate if needed
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+14
-8
@@ -1,16 +1,18 @@
|
||||
use std::path::PathBuf;
|
||||
use std::io::{self, Write};
|
||||
use tracing::{info, error};
|
||||
use crate::auth::hash_password;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::auth::hash_password;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
username: 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); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
let final_username = match username {
|
||||
@@ -30,9 +32,13 @@ pub async fn run(
|
||||
}
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let conn = db.admin.lock().unwrap();
|
||||
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
|
||||
info!("Successfully created admin user: {} (ID: {})", u.username, u.id);
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!(
|
||||
"Successfully created admin user: {} (ID: {})",
|
||||
u.username, u.id
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
+97
-23
@@ -1,8 +1,8 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::sqlite;
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
/// Run comprehensive database diagnostics.
|
||||
///
|
||||
@@ -12,7 +12,9 @@ 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); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
info!("Running BZOD database diagnostics...");
|
||||
println!("BZOD Database Doctor");
|
||||
@@ -20,11 +22,23 @@ pub async fn run(
|
||||
println!("Data directory: {:?}", config.data_dir);
|
||||
println!();
|
||||
|
||||
let databases = ["admin", "content", "analytics", "system"];
|
||||
let mut all_healthy = true;
|
||||
|
||||
for db_name in &databases {
|
||||
let db_path = config.data_dir.join(format!("{}.db", db_name));
|
||||
// Define target databases in the new layout
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let dbs = vec![
|
||||
("admin", admin_dir.join("admin.db")),
|
||||
("system", admin_dir.join("system.db")),
|
||||
("users", admin_dir.join("users.db")),
|
||||
("legacy content", config.data_dir.join("content.db")),
|
||||
("legacy analytics", config.data_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() {
|
||||
println!("Database: {}", db_name);
|
||||
@@ -35,27 +49,35 @@ pub async fn run(
|
||||
}
|
||||
|
||||
match Connection::open(&db_path) {
|
||||
Ok(conn) => {
|
||||
match sqlite::collect_health_report(&conn, db_name) {
|
||||
Ok(report) => {
|
||||
println!("Database: {}", report.database);
|
||||
println!(" Path: {:?}", db_path);
|
||||
println!(" Schema version: {}", report.schema_version);
|
||||
println!(" Journal mode: {}", report.journal_mode);
|
||||
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" });
|
||||
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" });
|
||||
|
||||
if !report.integrity_ok || !report.foreign_keys_enabled {
|
||||
all_healthy = false;
|
||||
Ok(conn) => match sqlite::collect_health_report(&conn, db_name) {
|
||||
Ok(report) => {
|
||||
println!("Database: {}", report.database);
|
||||
println!(" Path: {:?}", db_path);
|
||||
println!(" Schema version: {}", report.schema_version);
|
||||
println!(" Journal mode: {}", report.journal_mode);
|
||||
println!(
|
||||
" Foreign keys: {}",
|
||||
if report.foreign_keys_enabled {
|
||||
"enabled"
|
||||
} else {
|
||||
"DISABLED"
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: ERROR collecting health report: {}", e);
|
||||
);
|
||||
println!(
|
||||
" Integrity: {}",
|
||||
if report.integrity_ok { "ok" } else { "FAILED" }
|
||||
);
|
||||
|
||||
if !report.integrity_ok || !report.foreign_keys_enabled {
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: ERROR collecting health report: {}", e);
|
||||
all_healthy = false;
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
println!("Database: {}", db_name);
|
||||
println!(" Status: FAILED to open: {}", e);
|
||||
@@ -65,6 +87,58 @@ pub async fn run(
|
||||
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!("--------------------");
|
||||
if all_healthy {
|
||||
println!("Overall status: HEALTHY");
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
+5
-3
@@ -1,14 +1,16 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
dry_run: bool,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
||||
|
||||
+98
-5
@@ -1,13 +1,24 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod serve;
|
||||
pub mod backup;
|
||||
pub mod restore;
|
||||
pub mod migrate;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
pub mod create_admin;
|
||||
pub mod doctor;
|
||||
pub mod expand;
|
||||
pub mod migrate;
|
||||
pub mod restore;
|
||||
pub mod serve;
|
||||
pub mod shorten;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
|
||||
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)]
|
||||
#[command(name = "bzod")]
|
||||
@@ -72,4 +83,86 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Shorten a URL (Feature 3)
|
||||
Shorten {
|
||||
/// The destination URL to shorten
|
||||
target_url: String,
|
||||
/// Custom slug (starting with ! followed by a-z, 0-9, -, _)
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Expand a shortened code or custom slug to its destination URL (Feature 4)
|
||||
Expand {
|
||||
/// The short code or custom slug to expand
|
||||
code: String,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Create a new standard user in the database
|
||||
CreateUser {
|
||||
#[arg(long)]
|
||||
username: Option<String>,
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Delete a standard user and all their databases/slugs
|
||||
DeleteUser {
|
||||
/// User ID to delete
|
||||
user_id: i64,
|
||||
/// Force deletion of system account/legacy_admin
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Disable a standard user
|
||||
DisableUser {
|
||||
/// User ID to disable
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Enable a standard user
|
||||
EnableUser {
|
||||
/// User ID to enable
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Reset standard user's password
|
||||
ResetPassword {
|
||||
/// User ID to reset
|
||||
user_id: i64,
|
||||
#[arg(long)]
|
||||
password: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// List all standard/system users
|
||||
ListUsers {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Backup a standard user's databases to a .tar.zst package
|
||||
BackupUser {
|
||||
/// Username to backup
|
||||
username: String,
|
||||
/// Output .tar.zst filepath
|
||||
#[arg(long)]
|
||||
out: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Restore a standard user's databases from a .tar.zst package
|
||||
RestoreUser {
|
||||
/// Input .tar.zst package path
|
||||
#[arg(long, required = true)]
|
||||
file: String,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
+113
-10
@@ -1,17 +1,120 @@
|
||||
use std::path::PathBuf;
|
||||
use crate::config::Config;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use tracing::{info, error};
|
||||
use flate2::read::GzDecoder;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use crate::config::Config;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub fn perform_restore(
|
||||
file_path: &std::path::Path,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Open the archive
|
||||
let f = File::open(file_path)?;
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
|
||||
// 2. 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(
|
||||
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); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let file_path = PathBuf::from(file);
|
||||
|
||||
if !file_path.exists() {
|
||||
@@ -19,7 +122,10 @@ pub async fn run(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir);
|
||||
info!(
|
||||
"WARNING: Restoring will overwrite existing databases in {:?}",
|
||||
config.data_dir
|
||||
);
|
||||
print!("Are you sure you want to restore? (y/N): ");
|
||||
let _ = io::stdout().flush();
|
||||
let mut confirm = String::new();
|
||||
@@ -35,10 +141,7 @@ pub async fn run(
|
||||
}
|
||||
|
||||
info!("Restoring backup from: {:?}", file_path);
|
||||
let f = File::open(&file_path)?;
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
archive.unpack(&config.data_dir)?;
|
||||
perform_restore(&file_path, &config.data_dir)?;
|
||||
info!("Database files successfully restored.");
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
+37
-8
@@ -1,11 +1,11 @@
|
||||
use crate::analytics::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::state::AppState;
|
||||
use crate::web::create_router;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::analytics::AnalyticsQueue;
|
||||
use crate::state::AppState;
|
||||
use crate::web::create_router;
|
||||
|
||||
pub async fn run(
|
||||
host: Option<String>,
|
||||
@@ -13,9 +13,15 @@ pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(h) = host { config.host = h; }
|
||||
if let Some(p) = port { config.port = p; }
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(h) = host {
|
||||
config.host = h;
|
||||
}
|
||||
if let Some(p) = port {
|
||||
config.port = p;
|
||||
}
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
info!("Starting BZOD server on {}:{}", config.host, config.port);
|
||||
info!("Database directory: {:?}", config.data_dir);
|
||||
@@ -52,11 +58,34 @@ pub async fn run(
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
|
||||
});
|
||||
|
||||
let expiry_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
||||
});
|
||||
|
||||
let 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 {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
db: db.clone(),
|
||||
config: config.clone(),
|
||||
analytics_queue: queue,
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -1,12 +1,14 @@
|
||||
use std::path::PathBuf;
|
||||
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); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
println!("=== BZOD Database Stats ===");
|
||||
@@ -17,7 +19,12 @@ pub async fn run(
|
||||
let p = config.data_dir.join(f);
|
||||
if p.exists() {
|
||||
let sz = std::fs::metadata(&p)?.len();
|
||||
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0);
|
||||
println!(
|
||||
" File: {} - Size: {} bytes ({:.2} MB)",
|
||||
f,
|
||||
sz,
|
||||
sz as f64 / 1_048_576.0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +38,10 @@ pub async fn run(
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn)?
|
||||
};
|
||||
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead);
|
||||
println!(
|
||||
"Shortened URLs: {} total ({} active / {} dead)",
|
||||
urls_total, urls_active, urls_dead
|
||||
);
|
||||
|
||||
let pages_count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
|
||||
+7
-5
@@ -1,15 +1,17 @@
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use reqwest::Client;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
|
||||
+86
-27
@@ -1,7 +1,7 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::Deserialize;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
@@ -18,6 +18,7 @@ pub struct Config {
|
||||
pub backup_enabled: bool,
|
||||
pub backup_interval_mins: u64,
|
||||
pub backup_dir: PathBuf,
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -33,6 +34,7 @@ struct TomlConfig {
|
||||
link_check_interval_mins: Option<u64>,
|
||||
aggregation_interval_mins: Option<u64>,
|
||||
backup: Option<TomlBackupConfig>,
|
||||
base_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -50,7 +52,8 @@ impl Config {
|
||||
let mut data_dir = PathBuf::from("./data");
|
||||
let mut admin_username = "admin".to_string();
|
||||
let mut bootstrap_password_sha256 = "".to_string();
|
||||
let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
|
||||
let mut session_secret =
|
||||
"bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
|
||||
let mut cookie_secure = true;
|
||||
let mut data_retention_days = None;
|
||||
let mut link_check_interval_mins = 60u64;
|
||||
@@ -58,6 +61,7 @@ impl Config {
|
||||
let mut backup_enabled = false;
|
||||
let mut backup_interval_mins = 1440u64; // Default: once per day
|
||||
let mut backup_dir = PathBuf::from("./backups");
|
||||
let mut base_url = None;
|
||||
|
||||
// 2. Load bzod.toml if it exists
|
||||
let mut toml_path = "bzod.toml".to_string();
|
||||
@@ -66,13 +70,27 @@ impl Config {
|
||||
}
|
||||
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
|
||||
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
|
||||
if let Some(h) = toml_config.host { host = h; }
|
||||
if let Some(p) = toml_config.port { port = p; }
|
||||
if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); }
|
||||
if let Some(u) = toml_config.admin_username { admin_username = u; }
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; }
|
||||
if let Some(sec) = toml_config.session_secret { session_secret = sec; }
|
||||
if let Some(c) = toml_config.cookie_secure { cookie_secure = c; }
|
||||
if let Some(h) = toml_config.host {
|
||||
host = h;
|
||||
}
|
||||
if let Some(p) = toml_config.port {
|
||||
port = p;
|
||||
}
|
||||
if let Some(d) = toml_config.data_dir {
|
||||
data_dir = PathBuf::from(d);
|
||||
}
|
||||
if let Some(u) = toml_config.admin_username {
|
||||
admin_username = u;
|
||||
}
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Some(sec) = toml_config.session_secret {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Some(c) = toml_config.cookie_secure {
|
||||
cookie_secure = c;
|
||||
}
|
||||
if let Some(ret) = toml_config.data_retention_days {
|
||||
if ret.eq_ignore_ascii_case("unlimited") {
|
||||
data_retention_days = None;
|
||||
@@ -80,12 +98,25 @@ impl Config {
|
||||
data_retention_days = Some(parsed);
|
||||
}
|
||||
}
|
||||
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; }
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; }
|
||||
if let Some(lc) = toml_config.link_check_interval_mins {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
if let Some(b) = toml_config.backup {
|
||||
if let Some(be) = b.enabled { backup_enabled = be; }
|
||||
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; }
|
||||
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); }
|
||||
if let Some(be) = b.enabled {
|
||||
backup_enabled = be;
|
||||
}
|
||||
if let Some(bi) = b.interval_mins {
|
||||
backup_interval_mins = bi;
|
||||
}
|
||||
if let Some(bo) = b.out_dir {
|
||||
backup_dir = PathBuf::from(bo);
|
||||
}
|
||||
}
|
||||
if let Some(bu) = toml_config.base_url {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -97,16 +128,30 @@ impl Config {
|
||||
}
|
||||
|
||||
// 4. Load from Environment Variables (taking highest precedence)
|
||||
if let Ok(h) = env::var("HOST") { host = h; }
|
||||
if let Ok(h) = env::var("HOST") {
|
||||
host = h;
|
||||
}
|
||||
if let Ok(p_str) = env::var("PORT") {
|
||||
if let Ok(p) = p_str.parse::<u16>() { port = p; }
|
||||
if let Ok(p) = p_str.parse::<u16>() {
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
if let Ok(d_str) = env::var("DATA_DIR") {
|
||||
data_dir = PathBuf::from(d_str);
|
||||
}
|
||||
if let Ok(u) = env::var("ADMIN_USERNAME") {
|
||||
admin_username = u;
|
||||
}
|
||||
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Ok(sec) = env::var("SESSION_SECRET") {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); }
|
||||
if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; }
|
||||
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; }
|
||||
if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; }
|
||||
if let Ok(c_str) = env::var("COOKIE_SECURE") {
|
||||
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; }
|
||||
if let Ok(c) = c_str.parse::<bool>() {
|
||||
cookie_secure = c;
|
||||
}
|
||||
}
|
||||
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
|
||||
if ret_str.eq_ignore_ascii_case("unlimited") {
|
||||
@@ -116,18 +161,31 @@ impl Config {
|
||||
}
|
||||
}
|
||||
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
|
||||
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; }
|
||||
if let Ok(lc) = lc_str.parse::<u64>() {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
}
|
||||
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
|
||||
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; }
|
||||
if let Ok(ag) = ag_str.parse::<u64>() {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
}
|
||||
if let Ok(be_str) = env::var("BACKUP_ENABLED") {
|
||||
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; }
|
||||
if let Ok(be) = be_str.parse::<bool>() {
|
||||
backup_enabled = be;
|
||||
}
|
||||
}
|
||||
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
|
||||
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; }
|
||||
if let Ok(bi) = bi_str.parse::<u64>() {
|
||||
backup_interval_mins = bi;
|
||||
}
|
||||
}
|
||||
if let Ok(bo_str) = env::var("BACKUP_DIR") {
|
||||
backup_dir = PathBuf::from(bo_str);
|
||||
}
|
||||
if let Ok(bu) = env::var("BASE_URL") {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
|
||||
|
||||
Self {
|
||||
host,
|
||||
@@ -143,6 +201,7 @@ impl Config {
|
||||
backup_enabled,
|
||||
backup_interval_mins,
|
||||
backup_dir,
|
||||
base_url,
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
-14
@@ -1,9 +1,13 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use crate::models::{ApiKey, AuditLog, Session, User};
|
||||
use chrono::Utc;
|
||||
use crate::models::{User, Session, ApiKey, AuditLog};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
|
||||
pub fn create_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<User> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
@@ -21,7 +25,9 @@ pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> ru
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
|
||||
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![username])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
@@ -37,7 +43,8 @@ pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Resu
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
|
||||
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
@@ -64,10 +71,20 @@ pub fn create_session(
|
||||
) -> rusqlite::Result<Session> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
// Bind `user_id` as integer when it appears to be numeric so that numeric
|
||||
// user IDs inserted into `users.db` keep the integer affinity and avoid
|
||||
// InvalidColumnType errors when read as i64 elsewhere.
|
||||
if let Ok(id_i64) = user_id.parse::<i64>() {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, id_i64, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Session {
|
||||
id: session_id.to_string(),
|
||||
@@ -78,7 +95,8 @@ pub fn create_session(
|
||||
}
|
||||
|
||||
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
|
||||
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
|
||||
let mut rows = stmt.query(params![session_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
@@ -177,7 +195,10 @@ pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
|
||||
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
|
||||
conn.execute(
|
||||
"UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -211,10 +232,14 @@ pub fn write_audit_log(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
|
||||
pub fn list_audit_logs(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<AuditLog>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
|
||||
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
|
||||
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(AuditLog {
|
||||
|
||||
+385
-48
@@ -1,6 +1,6 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use std::collections::HashMap;
|
||||
use crate::models::VisitRecord;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::collections::HashMap;
|
||||
|
||||
// Custom User-Agent parser to avoid bloated dependencies
|
||||
pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
@@ -18,7 +18,8 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
"Android".to_string()
|
||||
} else if ua_lower.contains("linux") {
|
||||
"Linux".to_string()
|
||||
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
|
||||
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod")
|
||||
{
|
||||
"iOS".to_string()
|
||||
} else {
|
||||
"Other".to_string()
|
||||
@@ -38,7 +39,11 @@ pub fn parse_ua(ua: &str) -> (String, String, String) {
|
||||
"Other".to_string()
|
||||
};
|
||||
|
||||
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
|
||||
let device = if ua_lower.contains("mobile")
|
||||
|| ua_lower.contains("android")
|
||||
|| ua_lower.contains("iphone")
|
||||
|| ua_lower.contains("ipod")
|
||||
{
|
||||
"Mobile".to_string()
|
||||
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
|
||||
"Tablet".to_string()
|
||||
@@ -62,7 +67,9 @@ pub fn clean_referrer(referer: &str) -> String {
|
||||
}
|
||||
|
||||
// Fallback if not a valid URL
|
||||
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
|
||||
let cleaned = referer
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://");
|
||||
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
|
||||
if cleaned.is_empty() {
|
||||
"Direct".to_string()
|
||||
@@ -75,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
|
||||
)?;
|
||||
|
||||
for r in records {
|
||||
@@ -90,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
r.referer,
|
||||
r.accept_language,
|
||||
r.country,
|
||||
r.status_code
|
||||
r.status_code,
|
||||
r.owner_user_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -99,16 +107,25 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
}
|
||||
|
||||
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'page';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
// Get the date range of visits in the DB
|
||||
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let min_date: Option<String> = row.get(0)?;
|
||||
@@ -156,7 +173,6 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
|
||||
// 2. Compute metrics in-memory
|
||||
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
|
||||
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
|
||||
@@ -165,7 +181,11 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
for v in visits {
|
||||
let (browser, os, device) = parse_ua(&v.user_agent);
|
||||
let referrer = clean_referrer(&v.referer);
|
||||
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
|
||||
let country = if v.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
v.country.clone()
|
||||
};
|
||||
|
||||
let targets = vec![
|
||||
(v.target_type.clone(), v.target_id.clone()),
|
||||
@@ -174,22 +194,59 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
|
||||
for (t_type, t_id) in targets {
|
||||
// Clicks
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"clicks".to_string(),
|
||||
"".to_string(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Country
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"country".to_string(),
|
||||
country.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Browser
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"browser".to_string(),
|
||||
browser.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// OS
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone()))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Device
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"device".to_string(),
|
||||
device.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
|
||||
// Referrer
|
||||
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
|
||||
*aggregates
|
||||
.entry((
|
||||
t_type.clone(),
|
||||
t_id.clone(),
|
||||
"referrer".to_string(),
|
||||
referrer.clone(),
|
||||
))
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,7 +254,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
// Delete old aggregates for this day
|
||||
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
|
||||
tx.execute(
|
||||
"DELETE FROM daily_summaries WHERE date = ?1;",
|
||||
params![date],
|
||||
)?;
|
||||
|
||||
let mut insert_stmt = tx.prepare(
|
||||
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
@@ -205,14 +265,7 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
)?;
|
||||
|
||||
for ((t_type, t_id, m_type, m_key), value) in aggregates {
|
||||
insert_stmt.execute(params![
|
||||
date,
|
||||
t_type,
|
||||
t_id,
|
||||
m_type,
|
||||
m_key,
|
||||
value
|
||||
])?;
|
||||
insert_stmt.execute(params![date, t_type, t_id, m_type, m_key, value])?;
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -227,7 +280,10 @@ pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()>
|
||||
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
|
||||
tx.execute(
|
||||
"DELETE FROM monthly_summaries WHERE year_month = ?1;",
|
||||
params![year_month],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -244,7 +300,10 @@ fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqli
|
||||
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
|
||||
tx.execute(
|
||||
"DELETE FROM yearly_summaries WHERE year = ?1;",
|
||||
params![year],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
|
||||
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
|
||||
@@ -262,7 +321,10 @@ fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Res
|
||||
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
|
||||
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
|
||||
let limit_str = limit_date.to_rfc3339();
|
||||
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
|
||||
let count = conn.execute(
|
||||
"DELETE FROM visits WHERE timestamp < ?1;",
|
||||
params![limit_str],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
@@ -274,12 +336,14 @@ pub fn get_clicks_trend(
|
||||
target_id: &str,
|
||||
limit_days: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
|
||||
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT date, SUM(metric_value) FROM daily_summaries
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
|
||||
GROUP BY date ORDER BY date ASC;"
|
||||
GROUP BY date ORDER BY date ASC;",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
@@ -305,7 +369,7 @@ pub fn get_clicks_trend_raw(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT date(timestamp) as d, COUNT(*) FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
|
||||
GROUP BY d ORDER BY d ASC;"
|
||||
GROUP BY d ORDER BY d ASC;",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
@@ -331,7 +395,7 @@ pub fn get_hourly_trend_raw(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
|
||||
GROUP BY h ORDER BY h ASC;"
|
||||
GROUP BY h ORDER BY h ASC;",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
|
||||
@@ -355,7 +419,7 @@ pub fn get_metric_rankings(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
|
||||
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
|
||||
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;",
|
||||
)?;
|
||||
|
||||
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
|
||||
@@ -384,18 +448,20 @@ pub fn get_metric_rankings_raw(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT country, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY country ORDER BY c DESC LIMIT ?3;"
|
||||
GROUP BY country ORDER BY c DESC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for r in rows { res.push(r?); }
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
}
|
||||
"referrer" => {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT referer, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
|
||||
GROUP BY referer ORDER BY c DESC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
|
||||
let raw_ref: String = row.get(0)?;
|
||||
@@ -416,7 +482,7 @@ pub fn get_metric_rankings_raw(
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT user_agent, COUNT(*) as c FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY user_agent;"
|
||||
GROUP BY user_agent;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
@@ -443,30 +509,301 @@ pub fn get_metric_rankings_raw(
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// Returns the raw visit counts for a specific target ID.
|
||||
pub fn get_target_visit_count(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
|
||||
params![target_type, target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the distinct IP count (Unique Visitors) for a specific target ID.
|
||||
pub fn get_target_unique_visitors(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(DISTINCT ip_address) FROM visits WHERE target_type = ?1 AND target_id = ?2;",
|
||||
params![target_type, target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Fetches the monthly clicks trend for a specific target ID, falling back to a raw visits query if monthly_summaries are empty.
|
||||
pub fn get_monthly_clicks_trend(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
limit_months: i64,
|
||||
) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT year_month, SUM(metric_value) FROM monthly_summaries
|
||||
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks'
|
||||
GROUP BY year_month ORDER BY year_month ASC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
|
||||
if res.is_empty() {
|
||||
// Fallback to raw visits
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT strftime('%Y-%m', timestamp) as m, COUNT(*) FROM visits
|
||||
WHERE target_type = ?1 AND target_id = ?2
|
||||
GROUP BY m ORDER BY m ASC LIMIT ?3;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![target_type, target_id, limit_months], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
pub fn get_visits_schema_columns(
|
||||
conn: &Connection,
|
||||
) -> rusqlite::Result<std::collections::HashSet<String>> {
|
||||
let mut columns = std::collections::HashSet::new();
|
||||
let mut stmt = conn.prepare("PRAGMA table_info(visits);")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let name: String = row.get("name")?;
|
||||
columns.insert(name);
|
||||
}
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
pub fn get_target_visits_paginated(
|
||||
conn: &Connection,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<VisitRecord>> {
|
||||
let mut sql = "SELECT id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id FROM visits WHERE target_type = ?1 AND target_id = ?2".to_string();
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> = vec![
|
||||
Box::new(target_type.to_string()),
|
||||
Box::new(target_id.to_string()),
|
||||
];
|
||||
|
||||
if let Some(df) = date_from {
|
||||
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||
}
|
||||
|
||||
if let Some(dt) = date_to {
|
||||
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
let next_day = parsed_date + chrono::Duration::days(1);
|
||||
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!(
|
||||
"{}T00:00:00Z",
|
||||
next_day.format("%Y-%m-%d")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY timestamp DESC, id DESC LIMIT ?");
|
||||
sql.push_str(&(params.len() + 1).to_string());
|
||||
params.push(Box::new(limit));
|
||||
|
||||
sql.push_str(" OFFSET ?");
|
||||
sql.push_str(&(params.len() + 1).to_string());
|
||||
params.push(Box::new(offset));
|
||||
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(param_refs), |row| {
|
||||
Ok(VisitRecord {
|
||||
id: row.get("id")?,
|
||||
target_type: row.get("target_type")?,
|
||||
target_id: row.get("target_id")?,
|
||||
timestamp: row.get("timestamp")?,
|
||||
ip_address: row.get("ip_address")?,
|
||||
user_agent: row.get("user_agent")?,
|
||||
referer: row.get("referer")?,
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_ua_browsers() {
|
||||
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
|
||||
let firefox_linux =
|
||||
"Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
|
||||
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
|
||||
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
|
||||
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
|
||||
|
||||
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
|
||||
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
|
||||
assert_eq!(
|
||||
parse_ua(firefox_linux),
|
||||
(
|
||||
"Firefox".to_string(),
|
||||
"Linux".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(chrome_win),
|
||||
(
|
||||
"Chrome".to_string(),
|
||||
"Windows".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(safari_mac),
|
||||
(
|
||||
"Safari".to_string(),
|
||||
"macOS".to_string(),
|
||||
"Desktop".to_string()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
parse_ua(android_phone),
|
||||
(
|
||||
"Chrome".to_string(),
|
||||
"Android".to_string(),
|
||||
"Mobile".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_referrer() {
|
||||
assert_eq!(clean_referrer("direct"), "Direct");
|
||||
assert_eq!(clean_referrer(""), "Direct");
|
||||
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
|
||||
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
|
||||
assert_eq!(
|
||||
clean_referrer("https://github.com/rust-lang/rust"),
|
||||
"github.com"
|
||||
);
|
||||
assert_eq!(
|
||||
clean_referrer("http://www.google.com/search?q=rust"),
|
||||
"google.com"
|
||||
);
|
||||
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::models::AuditEvent;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Write an audit event to the system.db audit_events table.
|
||||
pub fn write_audit_event(
|
||||
conn: &Connection,
|
||||
actor: &str,
|
||||
action: &str,
|
||||
object_type: &str,
|
||||
object_id: &str,
|
||||
metadata: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO audit_events (id, actor, action, object_type, object_id, timestamp, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![id, actor, action, object_type, object_id, now, metadata],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List audit events with optional filtering by actor or action.
|
||||
pub fn list_audit_events(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
actor_filter: Option<&str>,
|
||||
action_filter: Option<&str>,
|
||||
) -> rusqlite::Result<Vec<AuditEvent>> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match (actor_filter, action_filter) {
|
||||
(Some(actor), Some(action)) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 AND action = ?2 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4;".to_string(),
|
||||
vec![Box::new(actor.to_string()), Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(Some(actor), None) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE actor = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
|
||||
vec![Box::new(actor.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(None, Some(action)) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events WHERE action = ?1 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3;".to_string(),
|
||||
vec![Box::new(action.to_string()), Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
(None, None) => (
|
||||
"SELECT id, actor, action, object_type, object_id, timestamp, metadata FROM audit_events ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;".to_string(),
|
||||
vec![Box::new(limit), Box::new(offset)],
|
||||
),
|
||||
};
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::types::ToSql> =
|
||||
params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params_refs.as_slice(), |row| {
|
||||
Ok(AuditEvent {
|
||||
id: row.get(0)?,
|
||||
actor: row.get(1)?,
|
||||
action: row.get(2)?,
|
||||
object_type: row.get(3)?,
|
||||
object_id: row.get(4)?,
|
||||
timestamp: row.get(5)?,
|
||||
metadata: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
for event in rows {
|
||||
events.push(event?);
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
+252
-85
@@ -1,7 +1,7 @@
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use crate::models::Url;
|
||||
use chrono::Utc;
|
||||
use crate::models::{Url, LandingPage};
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
// Helper: Associate tags with a URL
|
||||
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
|
||||
@@ -47,6 +47,44 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Get the total count of URLs associated with a specific tag name.
|
||||
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
|
||||
let tag_name = tag.trim().to_lowercase();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls u
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
WHERE t.name = ?1;",
|
||||
params![tag_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// The full column list used in all URL SELECT queries.
|
||||
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
|
||||
|
||||
/// Build a Url struct from a row containing URL_COLUMNS in order.
|
||||
fn url_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<Url> {
|
||||
Ok(Url {
|
||||
id: row.get(0)?,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
expires_at: row.get(8)?,
|
||||
expired: row.get::<_, i32>(9).unwrap_or(0) != 0,
|
||||
password_hash: row.get(10)?,
|
||||
last_status: row.get(11)?,
|
||||
last_latency_ms: row.get(12)?,
|
||||
max_access_count: row.get(13)?,
|
||||
access_count: row.get::<_, i64>(14).unwrap_or(0),
|
||||
tags: Vec::new(), // filled after query
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_url(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
@@ -77,54 +115,84 @@ pub fn create_url(
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
tags: tags.to_vec(),
|
||||
expires_at: None,
|
||||
expired: false,
|
||||
password_hash: None,
|
||||
last_status: None,
|
||||
last_latency_ms: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a URL with all extended options.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_url_extended(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
expires_at: Option<&str>,
|
||||
password_hash: Option<&str>,
|
||||
max_access_count: Option<i64>,
|
||||
) -> rusqlite::Result<Url> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let status = "healthy".to_string();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, password_hash, max_access_count)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);",
|
||||
params![id, code, destination, title, description, status, now, now, expires_at, password_hash, max_access_count],
|
||||
)?;
|
||||
|
||||
associate_tags(conn, &id, tags)?;
|
||||
|
||||
Ok(Url {
|
||||
id,
|
||||
code: code.to_string(),
|
||||
destination: destination.to_string(),
|
||||
title: title.map(|s| s.to_string()),
|
||||
description: description.map(|s| s.to_string()),
|
||||
status,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
tags: tags.to_vec(),
|
||||
expires_at: expires_at.map(|s| s.to_string()),
|
||||
expired: false,
|
||||
password_hash: password_hash.map(|s| s.to_string()),
|
||||
last_status: None,
|
||||
last_latency_ms: None,
|
||||
max_access_count,
|
||||
access_count: 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
|
||||
)?;
|
||||
let sql = format!("SELECT {} FROM urls WHERE id = ?1;", URL_COLUMNS);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let url_id: String = row.get(0)?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
Ok(Some(Url {
|
||||
id: url_id,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
tags,
|
||||
}))
|
||||
let mut url = url_from_row(row)?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
Ok(Some(url))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
|
||||
)?;
|
||||
let sql = format!("SELECT {} FROM urls WHERE code = ?1;", URL_COLUMNS);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let mut rows = stmt.query(params![code])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
let url_id: String = row.get(0)?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
Ok(Some(Url {
|
||||
id: url_id,
|
||||
code: row.get(1)?,
|
||||
destination: row.get(2)?,
|
||||
title: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
created_at: row.get(6)?,
|
||||
updated_at: row.get(7)?,
|
||||
tags,
|
||||
}))
|
||||
let mut url = url_from_row(row)?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
Ok(Some(url))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -170,58 +238,49 @@ pub fn list_urls(
|
||||
|
||||
if let Some(tag) = tag_filter {
|
||||
let tag_name = tag.trim().to_lowercase();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
|
||||
FROM urls u
|
||||
let sql = format!(
|
||||
"SELECT u.{} FROM urls u
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
WHERE t.name = ?1
|
||||
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
|
||||
let url_id: String = row.get(0)?;
|
||||
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
||||
})?;
|
||||
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;",
|
||||
URL_COLUMNS
|
||||
.replace("id,", "u.id,")
|
||||
.replace(", code", ", u.code")
|
||||
.replace(", destination", ", u.destination")
|
||||
.replace(", title", ", u.title")
|
||||
.replace(", description", ", u.description")
|
||||
.replace(", status", ", u.status")
|
||||
.replace(", created_at", ", u.created_at")
|
||||
.replace(", updated_at", ", u.updated_at")
|
||||
.replace(", expires_at", ", u.expires_at")
|
||||
.replace(", expired", ", u.expired")
|
||||
.replace(", password_hash", ", u.password_hash")
|
||||
.replace(", last_status", ", u.last_status")
|
||||
.replace(", last_latency_ms", ", u.last_latency_ms")
|
||||
.replace(", max_access_count", ", u.max_access_count")
|
||||
.replace(", access_count", ", u.access_count")
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![tag_name, limit, offset], url_from_row)?;
|
||||
|
||||
for r in rows {
|
||||
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
urls.push(Url {
|
||||
id: url_id,
|
||||
code,
|
||||
destination,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
tags,
|
||||
});
|
||||
let mut url = r?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
urls.push(url);
|
||||
}
|
||||
} else {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, destination, title, description, status, created_at, updated_at
|
||||
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
let url_id: String = row.get(0)?;
|
||||
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
|
||||
})?;
|
||||
let sql = format!(
|
||||
"SELECT {} FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
|
||||
URL_COLUMNS
|
||||
);
|
||||
let mut stmt = conn.prepare(&sql)?;
|
||||
let rows = stmt.query_map(params![limit, offset], url_from_row)?;
|
||||
|
||||
for r in rows {
|
||||
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
|
||||
let tags = get_tags_for_url(conn, &url_id)?;
|
||||
urls.push(Url {
|
||||
id: url_id,
|
||||
code,
|
||||
destination,
|
||||
title,
|
||||
description,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
tags,
|
||||
});
|
||||
let mut url = r?;
|
||||
url.tags = get_tags_for_url(conn, &url.id)?;
|
||||
urls.push(url);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +288,7 @@ pub fn list_urls(
|
||||
}
|
||||
|
||||
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
|
||||
let mut stmt = conn.prepare("SELECT id, destination FROM urls WHERE expired = 0;")?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
@@ -247,13 +306,111 @@ pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite:
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update URL health with extended status and latency information.
|
||||
pub fn update_url_health_extended(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
status: &str,
|
||||
last_status: &str,
|
||||
latency_ms: Option<i64>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE urls SET status = ?1, last_status = ?2, last_latency_ms = ?3, updated_at = ?4 WHERE id = ?5;",
|
||||
params![status, last_status, latency_ms, now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
|
||||
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
|
||||
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
|
||||
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
|
||||
let active: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect') AND expired = 0;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let dead: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls WHERE status = 'dead' OR expired = 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok((total, active, dead))
|
||||
}
|
||||
|
||||
/// Mark all URLs with expires_at < now as expired.
|
||||
pub fn expire_urls(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET expired = 1, updated_at = ?1 WHERE expires_at IS NOT NULL AND expires_at < ?1 AND expired = 0;",
|
||||
params![now],
|
||||
)?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Set a password hash on a URL.
|
||||
pub fn set_url_password(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET password_hash = ?1, updated_at = ?2 WHERE id = ?3;",
|
||||
params![password_hash, now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Remove the password from a URL.
|
||||
pub fn remove_url_password(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET password_hash = NULL, updated_at = ?1 WHERE id = ?2;",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Atomically increment the access count and return the new value.
|
||||
pub fn increment_access_count(conn: &Connection, id: &str) -> rusqlite::Result<i64> {
|
||||
conn.execute(
|
||||
"UPDATE urls SET access_count = access_count + 1 WHERE id = ?1;",
|
||||
params![id],
|
||||
)?;
|
||||
conn.query_row(
|
||||
"SELECT access_count FROM urls WHERE id = ?1;",
|
||||
params![id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Set expiry on a URL.
|
||||
pub fn set_url_expiry(conn: &Connection, id: &str, expires_at: &str) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute(
|
||||
"UPDATE urls SET expires_at = ?1, updated_at = ?2 WHERE id = ?3;",
|
||||
params![expires_at, now, id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// Get health status summary across all URLs.
|
||||
pub fn get_health_summary(conn: &Connection) -> rusqlite::Result<Vec<(String, i64)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(last_status, status) AS health, COUNT(*) FROM urls GROUP BY health ORDER BY COUNT(*) DESC;"
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
|
||||
let mut res = Vec::new();
|
||||
for r in rows {
|
||||
res.push(r?);
|
||||
}
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
// --- Landing Page Operations (unchanged) ---
|
||||
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
@@ -283,7 +440,10 @@ pub fn create_landing_page(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
|
||||
pub fn get_landing_page_by_id(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
) -> rusqlite::Result<Option<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
|
||||
)?;
|
||||
@@ -305,7 +465,10 @@ pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<O
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
|
||||
pub fn get_landing_page_by_code(
|
||||
conn: &Connection,
|
||||
code: &str,
|
||||
) -> rusqlite::Result<Option<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
|
||||
)?;
|
||||
@@ -354,10 +517,14 @@ pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
|
||||
pub fn list_landing_pages(
|
||||
conn: &Connection,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
) -> rusqlite::Result<Vec<LandingPage>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
|
||||
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![limit, offset], |row| {
|
||||
Ok(LandingPage {
|
||||
|
||||
+281
-4
@@ -27,7 +27,12 @@ pub fn run_migrations(
|
||||
|
||||
if current_version < target_version {
|
||||
for m in migrations.iter().filter(|m| m.version > current_version) {
|
||||
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
name = m.name,
|
||||
"Applying migration"
|
||||
);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
@@ -35,7 +40,12 @@ pub fn run_migrations(
|
||||
|
||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
||||
|
||||
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
name = m.name,
|
||||
"Migration completed"
|
||||
);
|
||||
|
||||
// Write audit record to system.db.migrations
|
||||
if let Some(sys_db_mutex) = system_db_opt {
|
||||
@@ -58,7 +68,11 @@ pub fn run_migrations(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
info!(database = db_name, version = current_version, "Database up to date");
|
||||
info!(
|
||||
database = db_name,
|
||||
version = current_version,
|
||||
"Database up to date"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -77,7 +91,10 @@ pub fn print_migration_plan(
|
||||
println!(" Current version: {current_version}");
|
||||
println!(" Target version: {target_version}");
|
||||
|
||||
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
|
||||
let pending: Vec<&Migration> = migrations
|
||||
.iter()
|
||||
.filter(|m| m.version > current_version)
|
||||
.collect();
|
||||
|
||||
if pending.is_empty() {
|
||||
println!(" Status: up to date");
|
||||
@@ -141,6 +158,24 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[
|
||||
);
|
||||
"#,
|
||||
},
|
||||
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] = &[
|
||||
@@ -187,6 +222,49 @@ pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "features_expansion",
|
||||
sql: r#"
|
||||
-- Expiring Links
|
||||
ALTER TABLE urls ADD COLUMN expires_at TEXT NULL;
|
||||
ALTER TABLE urls ADD COLUMN expired INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Password Protected Links
|
||||
ALTER TABLE urls ADD COLUMN password_hash TEXT NULL;
|
||||
|
||||
-- Link Health Dashboard (extended columns)
|
||||
ALTER TABLE urls ADD COLUMN last_status TEXT;
|
||||
ALTER TABLE urls ADD COLUMN last_latency_ms INTEGER;
|
||||
|
||||
-- One-Time Links
|
||||
ALTER TABLE urls ADD COLUMN max_access_count INTEGER NULL;
|
||||
ALTER TABLE urls ADD COLUMN access_count INTEGER NOT NULL DEFAULT 0;
|
||||
|
||||
-- Smart Landing Pages / Link Preview
|
||||
CREATE TABLE IF NOT EXISTS link_preview (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
logo_url TEXT,
|
||||
button_text TEXT DEFAULT 'Continue',
|
||||
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- QR Code style metadata
|
||||
CREATE TABLE IF NOT EXISTS qr_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL,
|
||||
style TEXT NOT NULL DEFAULT 'default',
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_expired ON urls(expired);
|
||||
CREATE INDEX IF NOT EXISTS idx_urls_expires_at ON urls(expires_at);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
@@ -241,6 +319,27 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "qr_access_log",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS qr_access_log (
|
||||
id TEXT PRIMARY KEY,
|
||||
url_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
ip TEXT,
|
||||
user_agent TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_url ON qr_access_log(url_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "add_owner_user_id",
|
||||
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
|
||||
},
|
||||
];
|
||||
|
||||
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
@@ -291,4 +390,182 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "audit_events",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS audit_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
actor TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
object_type TEXT NOT NULL,
|
||||
object_id TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_events(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_events(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||
"#,
|
||||
},
|
||||
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';
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+466
-45
@@ -1,15 +1,22 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::migrations::{
|
||||
run_migrations, ADMIN_MIGRATIONS, ANALYTICS_MIGRATIONS, CONTENT_MIGRATIONS, SYSTEM_MIGRATIONS,
|
||||
USERS_MIGRATIONS,
|
||||
};
|
||||
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use rusqlite::Connection;
|
||||
use crate::config::Config;
|
||||
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
|
||||
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
|
||||
pub mod migrations;
|
||||
pub mod sqlite;
|
||||
pub mod admin;
|
||||
pub mod content;
|
||||
pub mod analytics;
|
||||
pub mod audit_events;
|
||||
pub mod content;
|
||||
pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod sqlite;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
@@ -17,85 +24,499 @@ pub struct Db {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub system: Arc<Mutex<Connection>>,
|
||||
pub users: Arc<Mutex<Connection>>,
|
||||
pub data_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use tracing::info;
|
||||
|
||||
// Ensure data directory exists
|
||||
if !config.data_dir.exists() {
|
||||
fs::create_dir_all(&config.data_dir)?;
|
||||
}
|
||||
|
||||
let admin_path = config.data_dir.join("admin.db");
|
||||
let content_path = config.data_dir.join("content.db");
|
||||
let analytics_path = config.data_dir.join("analytics.db");
|
||||
let system_path = config.data_dir.join("system.db");
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let users_dir = config.data_dir.join("users");
|
||||
fs::create_dir_all(&admin_dir)?;
|
||||
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() {
|
||||
info!("Legacy admin.db found at root. Moving administrative databases to 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");
|
||||
let mut admin_conn = Connection::open(admin_path)?;
|
||||
info!("Opening content.db");
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
info!("Opening analytics.db");
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
info!("Opening system.db");
|
||||
let mut system_conn = Connection::open(system_path)?;
|
||||
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")?;
|
||||
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(&users_conn, "users")?;
|
||||
|
||||
// Enable foreign key support
|
||||
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
info!(database = "content", "Enabling foreign key enforcement on content.db");
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
info!(database = "system", "Enabling foreign key enforcement on system.db");
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
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");
|
||||
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
|
||||
|
||||
let system_arc = Arc::new(Mutex::new(system_conn));
|
||||
|
||||
// 2. Run migrations for other databases with system.db logging
|
||||
info!("Running admin migrations");
|
||||
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
|
||||
info!("Running content migrations");
|
||||
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
|
||||
info!("Running analytics migrations");
|
||||
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
|
||||
// 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,
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self {
|
||||
// Run migrations for admin.db and users.db
|
||||
info!("Running admin migrations");
|
||||
run_migrations(
|
||||
&mut admin_conn,
|
||||
"admin",
|
||||
ADMIN_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
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() {
|
||||
info!("Legacy content/analytics databases found at root. Moving to 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(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
ANALYTICS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
// 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)),
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
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> {
|
||||
let admin = self.admin.lock().unwrap();
|
||||
admin.execute("VACUUM;", [])?;
|
||||
let _ = admin.execute("VACUUM;", []);
|
||||
|
||||
let content = self.content.lock().unwrap();
|
||||
content.execute("VACUUM;", [])?;
|
||||
let _ = content.execute("VACUUM;", []);
|
||||
|
||||
let analytics = self.analytics.lock().unwrap();
|
||||
analytics.execute("VACUUM;", [])?;
|
||||
let _ = analytics.execute("VACUUM;", []);
|
||||
|
||||
let system = self.system.lock().unwrap();
|
||||
system.execute("VACUUM;", [])?;
|
||||
let _ = system.execute("VACUUM;", []);
|
||||
|
||||
let users = self.users.lock().unwrap();
|
||||
let _ = users.execute("VACUUM;", []);
|
||||
|
||||
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 = if user_id == 1 {
|
||||
config.data_dir.join("content.db") // legacy admin content db path
|
||||
} else {
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
use crate::models::LinkPreview;
|
||||
use rusqlite::{params, Connection};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Insert or update a link preview for a URL.
|
||||
pub fn upsert_preview(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
title: Option<&str>,
|
||||
description: Option<&str>,
|
||||
logo_url: Option<&str>,
|
||||
button_text: Option<&str>,
|
||||
) -> rusqlite::Result<LinkPreview> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let btn = button_text.unwrap_or("Continue");
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO link_preview (id, url_id, title, description, logo_url, button_text)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(url_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
description = excluded.description,
|
||||
logo_url = excluded.logo_url,
|
||||
button_text = excluded.button_text;",
|
||||
params![id, url_id, title, description, logo_url, btn],
|
||||
)?;
|
||||
|
||||
// Return the current state (may have been an update with a different id)
|
||||
get_preview(conn, url_id)?.ok_or(rusqlite::Error::QueryReturnedNoRows)
|
||||
}
|
||||
|
||||
/// Get the link preview for a URL.
|
||||
pub fn get_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<Option<LinkPreview>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, url_id, title, description, logo_url, button_text FROM link_preview WHERE url_id = ?1;"
|
||||
)?;
|
||||
let mut rows = stmt.query(params![url_id])?;
|
||||
|
||||
if let Some(row) = rows.next()? {
|
||||
Ok(Some(LinkPreview {
|
||||
id: row.get(0)?,
|
||||
url_id: row.get(1)?,
|
||||
title: row.get(2)?,
|
||||
description: row.get(3)?,
|
||||
logo_url: row.get(4)?,
|
||||
button_text: row
|
||||
.get::<_, Option<String>>(5)?
|
||||
.unwrap_or_else(|| "Continue".to_string()),
|
||||
}))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete the link preview for a URL.
|
||||
pub fn delete_preview(conn: &Connection, url_id: &str) -> rusqlite::Result<bool> {
|
||||
let count = conn.execute(
|
||||
"DELETE FROM link_preview WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
)?;
|
||||
Ok(count > 0)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Log a QR code access event to the analytics database.
|
||||
pub fn log_qr_access(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
ip: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> rusqlite::Result<()> {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO qr_access_log (id, url_id, timestamp, ip, user_agent)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
params![id, url_id, now, ip, user_agent],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get QR scan count for a URL.
|
||||
pub fn get_qr_scan_count(conn: &Connection, url_id: &str) -> rusqlite::Result<i64> {
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM qr_access_log WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Get QR scan count for a URL by its code (joins with content.db — must be called on analytics db after lookup).
|
||||
pub fn get_qr_stats_for_url(
|
||||
conn: &Connection,
|
||||
url_id: &str,
|
||||
) -> rusqlite::Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT timestamp, ip FROM qr_access_log WHERE url_id = ?1 ORDER BY timestamp DESC LIMIT 100;"
|
||||
)?;
|
||||
let rows = stmt.query_map(params![url_id], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, Option<String>>(1)?.unwrap_or_default(),
|
||||
))
|
||||
})?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Create or update a QR code style registration in the content database.
|
||||
pub fn upsert_qr_code(conn: &Connection, url_id: &str, style: &str) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
// Check if entry already exists
|
||||
let existing_id: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT id FROM qr_codes WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(id) = existing_id {
|
||||
conn.execute(
|
||||
"UPDATE qr_codes SET style = ?1 WHERE id = ?2;",
|
||||
params![style, id],
|
||||
)?;
|
||||
} else {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO qr_codes (id, url_id, style, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![id, url_id, style, now],
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the style configured for a QR code.
|
||||
pub fn get_qr_code_style(conn: &Connection, url_id: &str) -> rusqlite::Result<String> {
|
||||
let style: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT style FROM qr_codes WHERE url_id = ?1;",
|
||||
params![url_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(style.unwrap_or_else(|| "default".to_string()))
|
||||
}
|
||||
+9
-5
@@ -14,8 +14,9 @@ use tracing::info;
|
||||
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
|
||||
/// the actual mode. Returns an error if the database does not confirm WAL mode.
|
||||
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
|
||||
let actual_mode: String =
|
||||
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
|
||||
let actual_mode: String = conn.query_row("PRAGMA journal_mode=WAL;", [], |row| {
|
||||
row.get::<_, String>(0)
|
||||
})?;
|
||||
|
||||
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
|
||||
|
||||
@@ -36,7 +37,11 @@ pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusql
|
||||
let enabled: bool =
|
||||
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
|
||||
|
||||
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
|
||||
info!(
|
||||
database = db_name,
|
||||
foreign_keys = enabled,
|
||||
"Foreign key enforcement configured"
|
||||
);
|
||||
|
||||
if !enabled {
|
||||
return Err(rusqlite::Error::QueryReturnedNoRows);
|
||||
@@ -183,8 +188,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_collect_health_report() {
|
||||
let conn = memory_conn();
|
||||
let report =
|
||||
collect_health_report(&conn, "test").expect("Failed to collect health report");
|
||||
let report = collect_health_report(&conn, "test").expect("Failed to collect health report");
|
||||
assert_eq!(report.database, "test");
|
||||
assert!(report.integrity_ok);
|
||||
}
|
||||
|
||||
+1012
File diff suppressed because it is too large.
Load diff
+60
-13
@@ -1,6 +1,6 @@
|
||||
use axum::{
|
||||
response::{IntoResponse, Response},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
@@ -12,17 +12,36 @@ use std::path::PathBuf;
|
||||
#[derive(Debug)]
|
||||
pub enum DatabaseInitError {
|
||||
/// Data directory could not be created or accessed
|
||||
DataDirCreate { path: PathBuf, source: std::io::Error },
|
||||
DataDirCreate {
|
||||
path: PathBuf,
|
||||
source: std::io::Error,
|
||||
},
|
||||
/// SQLite connection could not be opened
|
||||
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error },
|
||||
ConnectionOpen {
|
||||
database: String,
|
||||
path: PathBuf,
|
||||
source: rusqlite::Error,
|
||||
},
|
||||
/// PRAGMA configuration failed (WAL, foreign_keys, etc.)
|
||||
PragmaConfig { database: String, pragma: String, source: rusqlite::Error },
|
||||
PragmaConfig {
|
||||
database: String,
|
||||
pragma: String,
|
||||
source: rusqlite::Error,
|
||||
},
|
||||
/// Migration execution failed
|
||||
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> },
|
||||
MigrationFailed {
|
||||
database: String,
|
||||
version: u32,
|
||||
name: String,
|
||||
source: Box<dyn std::error::Error + Send + Sync>,
|
||||
},
|
||||
/// Database integrity check failed
|
||||
IntegrityCheckFailed { database: String, message: String },
|
||||
/// WAL mode could not be enabled (returned unexpected mode)
|
||||
WalModeFailed { database: String, actual_mode: String },
|
||||
WalModeFailed {
|
||||
database: String,
|
||||
actual_mode: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for DatabaseInitError {
|
||||
@@ -31,20 +50,48 @@ impl fmt::Display for DatabaseInitError {
|
||||
Self::DataDirCreate { path, source } => {
|
||||
write!(f, "Failed to create data directory {:?}: {}", path, source)
|
||||
}
|
||||
Self::ConnectionOpen { database, path, source } => {
|
||||
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source)
|
||||
Self::ConnectionOpen {
|
||||
database,
|
||||
path,
|
||||
source,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Failed to open {}.db at {:?}: {}",
|
||||
database, path, source
|
||||
)
|
||||
}
|
||||
Self::PragmaConfig { database, pragma, source } => {
|
||||
Self::PragmaConfig {
|
||||
database,
|
||||
pragma,
|
||||
source,
|
||||
} => {
|
||||
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
|
||||
}
|
||||
Self::MigrationFailed { database, version, name, source } => {
|
||||
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source)
|
||||
Self::MigrationFailed {
|
||||
database,
|
||||
version,
|
||||
name,
|
||||
source,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"Migration v{} ({}) failed on {}.db: {}",
|
||||
version, name, database, source
|
||||
)
|
||||
}
|
||||
Self::IntegrityCheckFailed { database, message } => {
|
||||
write!(f, "Integrity check failed on {}.db: {}", database, message)
|
||||
}
|
||||
Self::WalModeFailed { database, actual_mode } => {
|
||||
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode)
|
||||
Self::WalModeFailed {
|
||||
database,
|
||||
actual_mode,
|
||||
} => {
|
||||
write!(
|
||||
f,
|
||||
"WAL mode not enabled on {}.db (got '{}')",
|
||||
database, actual_mode
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-18
@@ -1,32 +1,52 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use crate::db::Db;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::analytics::aggregate_day;
|
||||
use super::{log_job_start, log_job_end};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
info!("Running background analytics aggregator...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let job_id = log_job_start(&db.system, "analytics_aggregator");
|
||||
match perform_aggregation(&db).await {
|
||||
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
error!("Error performing aggregation: {}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
let mut failed = false;
|
||||
let mut err_msg = None;
|
||||
|
||||
for user_id in user_ids {
|
||||
if let Err(e) = perform_aggregation(&db, user_id).await {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if failed {
|
||||
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("Error performing aggregation: {}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
} else {
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let date_range = {
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
crate::db::analytics::get_visits_date_range(&conn)?
|
||||
};
|
||||
pub async fn perform_aggregation(db: &Db, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut conn = super::open_user_analytics_conn(db, user_id)?;
|
||||
let date_range = crate::db::analytics::get_visits_date_range(&conn)?;
|
||||
|
||||
if let Some((min_date, max_date)) = date_range {
|
||||
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
|
||||
@@ -35,10 +55,7 @@ pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Erro
|
||||
let mut curr = min;
|
||||
while curr <= max {
|
||||
let date_str = curr.format("%Y-%m-%d").to_string();
|
||||
{
|
||||
let mut conn = db.analytics.lock().unwrap();
|
||||
aggregate_day(&mut conn, &date_str)?;
|
||||
}
|
||||
aggregate_day(&mut conn, &date_str)?;
|
||||
if curr == max {
|
||||
break;
|
||||
}
|
||||
|
||||
+53
-15
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::config::Config;
|
||||
use super::{log_job_start, log_job_end};
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
if !config.backup_enabled {
|
||||
@@ -10,7 +10,10 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
return;
|
||||
}
|
||||
|
||||
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
|
||||
info!(
|
||||
"Starting background backup scheduler (interval: {} mins)...",
|
||||
config.backup_interval_mins
|
||||
);
|
||||
loop {
|
||||
// Run backup every configured interval
|
||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
||||
@@ -31,13 +34,16 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use std::fs::File;
|
||||
pub async fn perform_backup(
|
||||
db: &Db,
|
||||
config: &Config,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use tar::Builder;
|
||||
use chrono::Utc;
|
||||
use rusqlite::params;
|
||||
use std::fs::File;
|
||||
use tar::Builder;
|
||||
use uuid::Uuid;
|
||||
|
||||
let out_dir = config.backup_dir.clone();
|
||||
@@ -45,6 +51,36 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
|
||||
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 tar_name = format!("{}-bzod-backup.tar.gz", date_str);
|
||||
let tar_path = out_dir.join(tar_name);
|
||||
@@ -53,12 +89,14 @@ pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
let db_file = config.data_dir.join(f);
|
||||
if db_file.exists() {
|
||||
tar.append_path_with_name(&db_file, f)?;
|
||||
}
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
if admin_dir.exists() {
|
||||
tar.append_dir_all("admin", &admin_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = config.data_dir.join("users");
|
||||
if users_dir.exists() {
|
||||
tar.append_dir_all("users", &users_dir)?;
|
||||
}
|
||||
|
||||
tar.into_inner()?.finish()?;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
|
||||
/// Background job that marks expired URLs.
|
||||
///
|
||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||
/// gets flipped to `expired = 1`.
|
||||
pub async fn run_expiry_checker(db: Db) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
|
||||
let 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 mut total_expired = 0;
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
-42
@@ -1,17 +1,18 @@
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use uuid::Uuid;
|
||||
use chrono::Utc;
|
||||
use reqwest::Client;
|
||||
use rusqlite::params;
|
||||
use std::time::{Duration, Instant};
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_start, log_job_end};
|
||||
|
||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-link-checker/0.1")
|
||||
.redirect(reqwest::redirect::Policy::limited(10))
|
||||
.build()
|
||||
.unwrap_or_default();
|
||||
|
||||
@@ -32,63 +33,129 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let urls = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::list_urls_for_health_check(&conn)?
|
||||
pub async fn perform_link_check(
|
||||
db: &Db,
|
||||
client: &Client,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM users;")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (id, dest) in urls {
|
||||
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::update_url_health(&conn, &id, &status)?;
|
||||
}
|
||||
for user_id in user_ids {
|
||||
let conn = match super::open_user_content_conn(db, user_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for (id, dest) in urls {
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let sys_conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = sys_conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
|
||||
let res = client.get(url)
|
||||
.timeout(Duration::from_secs(5))
|
||||
.send()
|
||||
.await;
|
||||
/// Check URL health with detailed classification and latency measurement.
|
||||
///
|
||||
/// Returns: (general_status, detail_status, status_code, latency_ms, error_message)
|
||||
async fn check_url_health(
|
||||
client: &Client,
|
||||
url: &str,
|
||||
) -> (String, String, Option<u16>, i64, Option<String>) {
|
||||
let start = Instant::now();
|
||||
let res = client.get(url).timeout(Duration::from_secs(5)).send().await;
|
||||
let latency_ms = start.elapsed().as_millis() as i64;
|
||||
|
||||
match res {
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
let code = status.as_u16();
|
||||
if status.is_success() || status.is_redirection() {
|
||||
("healthy".to_string(), Some(code), None)
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
|
||||
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
|
||||
(
|
||||
"healthy".to_string(),
|
||||
"healthy".to_string(),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
None,
|
||||
)
|
||||
} else if status == reqwest::StatusCode::NOT_FOUND
|
||||
|| status == reqwest::StatusCode::GONE
|
||||
{
|
||||
(
|
||||
"dead".to_string(),
|
||||
"dead".to_string(),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
Some(format!("HTTP {}", code)),
|
||||
)
|
||||
} else {
|
||||
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
|
||||
(
|
||||
"suspect".to_string(),
|
||||
format!("http_{}", code),
|
||||
Some(code),
|
||||
latency_ms,
|
||||
Some(format!("HTTP {}", code)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
let err_str = err.to_string();
|
||||
if err.is_timeout() || err.is_connect() {
|
||||
("suspect".to_string(), None, Some(err_str))
|
||||
let detail = if err.is_timeout() {
|
||||
"timeout".to_string()
|
||||
} else if err.is_connect() {
|
||||
if err_str.contains("dns") || err_str.contains("resolve") {
|
||||
"dns_failure".to_string()
|
||||
} else if err_str.contains("tls")
|
||||
|| err_str.contains("ssl")
|
||||
|| err_str.contains("certificate")
|
||||
{
|
||||
"tls_error".to_string()
|
||||
} else {
|
||||
"connection_refused".to_string()
|
||||
}
|
||||
} else if err.is_redirect() {
|
||||
"redirect_loop".to_string()
|
||||
} else {
|
||||
("dead".to_string(), None, Some(err_str))
|
||||
}
|
||||
"unknown_error".to_string()
|
||||
};
|
||||
|
||||
let general = if err.is_timeout() || err.is_connect() {
|
||||
"suspect"
|
||||
} else {
|
||||
"dead"
|
||||
};
|
||||
|
||||
(general.to_string(), detail, None, latency_ms, Some(err_str))
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
-7
@@ -1,16 +1,19 @@
|
||||
use std::sync::Mutex;
|
||||
use rusqlite::{Connection, params};
|
||||
use uuid::Uuid;
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::sync::Mutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub mod healthcheck;
|
||||
pub mod retention;
|
||||
pub mod aggregate;
|
||||
pub mod backup;
|
||||
pub mod expiry;
|
||||
pub mod healthcheck;
|
||||
pub mod retention;
|
||||
|
||||
pub use healthcheck::{run_link_checker, perform_link_check};
|
||||
pub use aggregate::{perform_aggregation, run_aggregator};
|
||||
pub use expiry::run_expiry_checker;
|
||||
pub use healthcheck::{perform_link_check, run_link_checker};
|
||||
pub use retention::run_retention_cleaner;
|
||||
pub use aggregate::{run_aggregator, perform_aggregation};
|
||||
|
||||
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
@@ -33,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)
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
+44
-12
@@ -1,8 +1,8 @@
|
||||
use std::time::Duration;
|
||||
use tracing::{info, error};
|
||||
use tracing::{error, info};
|
||||
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
use super::{log_job_start, log_job_end};
|
||||
|
||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
let retention_days = match retention_days_opt {
|
||||
@@ -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;
|
||||
info!("Running background data retention cleanup...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
};
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let job_id = log_job_start(&db.system, "retention_cleaner");
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
|
||||
Ok(count) => {
|
||||
info!("Cleaned up {} expired visits from database", count);
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
Err(e) => {
|
||||
let err_str = e.to_string();
|
||||
error!("Error running retention cleaner: {:?}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
let mut total_cleaned = 0;
|
||||
let mut failed = false;
|
||||
let mut err_msg = None;
|
||||
|
||||
for user_id in user_ids {
|
||||
match super::open_user_analytics_conn(&db, user_id) {
|
||||
Ok(conn) => match crate::db::analytics::retention_cleanup(&conn, retention_days) {
|
||||
Ok(count) => total_cleaned += count,
|
||||
Err(e) => {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
failed = true;
|
||||
err_msg = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if failed {
|
||||
let err_str = err_msg.unwrap_or_else(|| "Unknown error".to_string());
|
||||
error!("Error running retention cleaner: {:?}", err_str);
|
||||
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
|
||||
} else {
|
||||
info!(
|
||||
"Cleaned up {} expired visits across all user databases",
|
||||
total_cleaned
|
||||
);
|
||||
log_job_end(&db.system, &job_id, "success", None);
|
||||
}
|
||||
}
|
||||
}
|
||||
+10
-10
@@ -1,14 +1,14 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod auth;
|
||||
pub mod analytics;
|
||||
pub mod jobs;
|
||||
pub mod services;
|
||||
pub mod utils;
|
||||
pub mod models;
|
||||
pub mod templates;
|
||||
pub mod charts;
|
||||
pub mod state;
|
||||
pub mod error;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
pub mod state;
|
||||
pub mod templates;
|
||||
pub mod utils;
|
||||
pub mod web;
|
||||
pub mod cli;
|
||||
+60
-4
@@ -1,20 +1,26 @@
|
||||
use bzod::cli::{Cli, Commands};
|
||||
use bzod::config::Config;
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use bzod::config::Config;
|
||||
use bzod::cli::{Cli, Commands};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Set up tracing subscriber
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
|
||||
.with_env_filter(
|
||||
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
||||
)
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let config = Config::load();
|
||||
|
||||
match cli.command {
|
||||
Commands::Serve { host, port, data_dir } => {
|
||||
Commands::Serve {
|
||||
host,
|
||||
port,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::serve::run(host, port, data_dir, config).await?;
|
||||
}
|
||||
Commands::Backup { out, data_dir } => {
|
||||
@@ -38,6 +44,56 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::Doctor { data_dir } => {
|
||||
bzod::cli::doctor::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::Shorten {
|
||||
target_url,
|
||||
slug,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::shorten::run(target_url, slug, data_dir, config).await?;
|
||||
}
|
||||
Commands::Expand { code, data_dir } => {
|
||||
bzod::cli::expand::run(code, data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateUser {
|
||||
username,
|
||||
password,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::create_user::run(username, password, data_dir, config).await?;
|
||||
}
|
||||
Commands::DeleteUser {
|
||||
user_id,
|
||||
force,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::delete_user::run(user_id, force, data_dir, config).await?;
|
||||
}
|
||||
Commands::DisableUser { user_id, data_dir } => {
|
||||
bzod::cli::disable_user::run(user_id, data_dir, config).await?;
|
||||
}
|
||||
Commands::EnableUser { user_id, data_dir } => {
|
||||
bzod::cli::enable_user::run(user_id, data_dir, config).await?;
|
||||
}
|
||||
Commands::ResetPassword {
|
||||
user_id,
|
||||
password,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::reset_password::run(user_id, password, data_dir, config).await?;
|
||||
}
|
||||
Commands::ListUsers { data_dir } => {
|
||||
bzod::cli::list_users::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::BackupUser {
|
||||
username,
|
||||
out,
|
||||
data_dir,
|
||||
} => {
|
||||
bzod::cli::backup_user::run(username, out, data_dir, config).await?;
|
||||
}
|
||||
Commands::RestoreUser { file, data_dir } => {
|
||||
bzod::cli::restore_user::run(file, data_dir, config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct ApiKey {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditLog {
|
||||
|
||||
+11
-8
@@ -1,13 +1,16 @@
|
||||
pub mod user;
|
||||
pub mod url;
|
||||
pub mod page;
|
||||
pub mod visit;
|
||||
pub mod api_key;
|
||||
pub mod audit;
|
||||
pub mod page;
|
||||
pub mod url;
|
||||
pub mod user;
|
||||
pub mod visit;
|
||||
|
||||
pub use user::{User, Session};
|
||||
pub use url::Url;
|
||||
pub use page::LandingPage;
|
||||
pub use visit::{VisitRecord, SummaryEntry};
|
||||
pub use api_key::ApiKey;
|
||||
pub use audit::AuditLog;
|
||||
pub use page::LandingPage;
|
||||
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
|
||||
pub use user::{
|
||||
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
|
||||
UserQuotas, UserSession, UsernameHistory,
|
||||
};
|
||||
pub use visit::{SummaryEntry, VisitRecord};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LandingPage {
|
||||
|
||||
+64
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct Url {
|
||||
@@ -11,4 +11,67 @@ pub struct Url {
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub tags: Vec<String>,
|
||||
// --- Feature Expansion Fields ---
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub expired: bool,
|
||||
#[serde(skip_serializing)]
|
||||
pub password_hash: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_status: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub last_latency_ms: Option<i64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_access_count: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub access_count: i64,
|
||||
}
|
||||
|
||||
impl Url {
|
||||
/// Returns true if this URL has a password set.
|
||||
pub fn is_password_protected(&self) -> bool {
|
||||
self.password_hash.is_some()
|
||||
}
|
||||
|
||||
/// Returns true if this URL has reached its access limit.
|
||||
pub fn is_access_exhausted(&self) -> bool {
|
||||
if let Some(max) = self.max_access_count {
|
||||
self.access_count >= max
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Link preview metadata for smart landing pages.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct LinkPreview {
|
||||
pub id: String,
|
||||
pub url_id: String,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub logo_url: Option<String>,
|
||||
pub button_text: String,
|
||||
}
|
||||
|
||||
/// QR code metadata record.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct QrCode {
|
||||
pub id: String,
|
||||
pub url_id: String,
|
||||
pub style: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
/// Audit event record for the enhanced audit trail.
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct AuditEvent {
|
||||
pub id: String,
|
||||
pub actor: String,
|
||||
pub action: String,
|
||||
pub object_type: String,
|
||||
pub object_id: String,
|
||||
pub timestamp: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
+191
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct User {
|
||||
@@ -15,3 +15,193 @@ pub struct Session {
|
||||
pub expires_at: String,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct TenantUser {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub password_hash: String,
|
||||
pub status: String, // 'active', 'disabled', 'suspended', 'pending', 'deleted'
|
||||
pub created_at: String,
|
||||
pub last_login: Option<String>,
|
||||
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
|
||||
pub organization_id: Option<i64>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
use serde::{Serialize, Deserialize};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct VisitRecord {
|
||||
@@ -12,6 +12,7 @@ pub struct VisitRecord {
|
||||
pub accept_language: String,
|
||||
pub country: String,
|
||||
pub status_code: u16,
|
||||
pub owner_user_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::ApiKey;
|
||||
use crate::error::AppError;
|
||||
use crate::models::ApiKey;
|
||||
|
||||
pub fn create_api_key(
|
||||
db: &Db,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::AuditLog;
|
||||
use crate::error::AppError;
|
||||
use crate::models::AuditLog;
|
||||
|
||||
pub fn log_action(
|
||||
db: &Db,
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
use crate::models::Url;
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use std::io::Write;
|
||||
use zip::write::FileOptions;
|
||||
use zip::ZipWriter;
|
||||
|
||||
/// Export QR codes for the given URLs as a ZIP file.
|
||||
/// `format` can be "png" or "svg".
|
||||
/// `base_url` is used to build the full short URL encoded in the QR.
|
||||
pub fn export_qr_zip(
|
||||
urls: &[Url],
|
||||
format: &str,
|
||||
base_url: &str,
|
||||
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
let mut zip = ZipWriter::new(std::io::Cursor::new(&mut buf));
|
||||
let options =
|
||||
FileOptions::<()>::default().compression_method(zip::CompressionMethod::Deflated);
|
||||
|
||||
let mut csv_content = String::from("code,destination,qr_filename\n");
|
||||
|
||||
for url in urls {
|
||||
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), url.code);
|
||||
let ext = if format == "svg" { "svg" } else { "png" };
|
||||
let filename = format!("{}.{}", url.code, ext);
|
||||
|
||||
let qr_data = if format == "svg" {
|
||||
generate_qr_svg(&full_url)?.into_bytes()
|
||||
} else {
|
||||
generate_qr_png(&full_url, 256)?
|
||||
};
|
||||
|
||||
zip.start_file(&filename, options)?;
|
||||
zip.write_all(&qr_data)?;
|
||||
|
||||
// Escape quotes in destination for CSV formatting
|
||||
let escaped_dest = url.destination.replace('"', "\"\"");
|
||||
csv_content.push_str(&format!("{},\"{}\",{}\n", url.code, escaped_dest, filename));
|
||||
}
|
||||
|
||||
zip.start_file("manifest.csv", options)?;
|
||||
zip.write_all(csv_content.as_bytes())?;
|
||||
|
||||
zip.finish()?;
|
||||
}
|
||||
|
||||
Ok(buf)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::LandingPage;
|
||||
use crate::error::AppError;
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
db: &Db,
|
||||
@@ -11,7 +11,8 @@ pub fn create_landing_page(
|
||||
state: &str,
|
||||
) -> Result<LandingPage, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
let page =
|
||||
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
|
||||
+4
-2
@@ -1,4 +1,6 @@
|
||||
pub mod shortener;
|
||||
pub mod landing_pages;
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
pub mod bulk;
|
||||
pub mod landing_pages;
|
||||
pub mod qr;
|
||||
pub mod shortener;
|
||||
@@ -0,0 +1,58 @@
|
||||
//! QR code generation service.
|
||||
//!
|
||||
//! Generates QR codes on-demand as PNG or SVG. No files are stored on disk.
|
||||
|
||||
use image::Luma;
|
||||
use qrcode::QrCode;
|
||||
use std::io::Cursor;
|
||||
|
||||
/// Generate a QR code as a PNG byte vector.
|
||||
///
|
||||
/// The `url` is encoded into the QR matrix. The `size` parameter controls
|
||||
/// the pixel dimensions of the output image (default: 256).
|
||||
pub fn generate_qr_png(url: &str, size: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
|
||||
let code = QrCode::new(url.as_bytes())?;
|
||||
let image = code.render::<Luma<u8>>().min_dimensions(size, size).build();
|
||||
|
||||
let mut buf = Vec::new();
|
||||
let mut cursor = Cursor::new(&mut buf);
|
||||
image.write_to(&mut cursor, image::ImageFormat::Png)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Generate a QR code as an SVG string.
|
||||
pub fn generate_qr_svg(url: &str) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let code = QrCode::new(url.as_bytes())?;
|
||||
let svg = code
|
||||
.render::<qrcode::render::svg::Color>()
|
||||
.min_dimensions(256, 256)
|
||||
.build();
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_qr_png_generation() {
|
||||
let png = generate_qr_png("https://bzo.in/abc123", 256).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
// PNG magic bytes
|
||||
assert_eq!(&png[..4], &[0x89, b'P', b'N', b'G']);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qr_svg_generation() {
|
||||
let svg = generate_qr_svg("https://bzo.in/abc123").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
assert!(svg.contains("</svg>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_qr_long_url() {
|
||||
let long_url = format!("https://example.com/{}", "a".repeat(500));
|
||||
let png = generate_qr_png(&long_url, 512).unwrap();
|
||||
assert!(!png.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::db::Db;
|
||||
use crate::models::Url;
|
||||
use crate::error::AppError;
|
||||
use crate::models::Url;
|
||||
|
||||
pub fn create_url(
|
||||
db: &Db,
|
||||
|
||||
+74
-4
@@ -1,9 +1,17 @@
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use rusqlite::Connection;
|
||||
use crate::config::Config;
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::db::Db;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
@@ -11,6 +19,8 @@ pub struct AppState {
|
||||
pub content_db: Arc<Mutex<Connection>>,
|
||||
pub analytics_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 config: Config,
|
||||
pub analytics_queue: AnalyticsQueue,
|
||||
@@ -18,11 +28,71 @@ pub struct 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> {
|
||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
response::{IntoResponse, Response, Html},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
@@ -23,7 +23,11 @@ impl IntoResponse for DashboardTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loaded 100 of 193 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user