Compare commits
36
Commits
v0.2.1
...
v0.5.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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
|
||||
Generated
+95
-1
@@ -289,6 +289,7 @@ dependencies = [
|
||||
"matchit",
|
||||
"memchr",
|
||||
"mime",
|
||||
"multer",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustversion",
|
||||
@@ -452,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
@@ -462,6 +463,7 @@ dependencies = [
|
||||
"clap",
|
||||
"dotenvy",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"image",
|
||||
"qrcode",
|
||||
@@ -479,6 +481,7 @@ dependencies = [
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
"zip",
|
||||
"zstd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -582,6 +585,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"
|
||||
@@ -689,6 +710,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"
|
||||
@@ -850,6 +880,17 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
|
||||
|
||||
[[package]]
|
||||
name = "futures-macro"
|
||||
version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.32"
|
||||
@@ -863,6 +904,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-macro",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
@@ -1405,6 +1447,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"
|
||||
@@ -1792,6 +1840,22 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[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"
|
||||
@@ -2061,6 +2125,8 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
@@ -3439,6 +3505,34 @@ dependencies = [
|
||||
"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",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
|
||||
+10
-3
@@ -1,11 +1,12 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
version = "0.1.0"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
axum = { version = "0.7", features = ["macros"] }
|
||||
axum = { version = "0.7", features = ["macros", "multipart"] }
|
||||
axum-extra = { version = "0.9", features = ["cookie"] }
|
||||
rusqlite = { version = "0.31", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
@@ -19,7 +20,7 @@ askama = { version = "0.12" }
|
||||
argon2 = "0.5"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "cookies"] }
|
||||
tar = "0.4"
|
||||
flate2 = "1.0"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -29,3 +30,9 @@ toml = "0.8"
|
||||
qrcode = "0.14"
|
||||
image = "0.25"
|
||||
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"
|
||||
+20
-21
@@ -1,5 +1,5 @@
|
||||
# ==========================================
|
||||
# Stage 1: Build
|
||||
# Stage 1: Builder (with optimized caching)
|
||||
# ==========================================
|
||||
FROM rust:1.89-bookworm AS builder
|
||||
|
||||
@@ -9,28 +9,27 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
git \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Cargo metadata
|
||||
# Copy only Cargo files first (best caching)
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Pre-build dependencies for layer caching
|
||||
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 application source
|
||||
# Copy real source code + assets
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY www ./www
|
||||
|
||||
# Build application
|
||||
RUN touch src/main.rs
|
||||
# Build the real application
|
||||
RUN cargo build --release
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Runtime
|
||||
# Stage 2: Runtime (slim)
|
||||
# ==========================================
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -43,32 +42,32 @@ RUN apt-get update && apt-get install -y \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Application binary
|
||||
# Copy binary from builder
|
||||
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
|
||||
|
||||
# Runtime assets
|
||||
COPY templates ./templates
|
||||
COPY www ./www
|
||||
# Copy assets
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/www ./www
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1000 bzod && \
|
||||
useradd -u 1000 -g bzod -m -s /bin/bash bzod
|
||||
|
||||
# Create writable data directory
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R bzod:bzod /app
|
||||
|
||||
USER bzod
|
||||
|
||||
ENV DATA_DIR=/app/data
|
||||
ENV PORT=8654
|
||||
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 8654
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:$${PORT:-8654}/status || exit 1
|
||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
||||
|
||||
ENTRYPOINT ["bzod"]
|
||||
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,73 +1,235 @@
|
||||
# nx9-url-shortener
|
||||
# nx9-url-shortener (the BZOD daemon)
|
||||
|
||||
**A lightweight, self-hosted URL management platform written in Rust.**
|
||||
|
||||
nx9-url-shortener combines URL shortening, QR code generation, password-protected links, smart preview pages, analytics, audit logging, and lifecycle management into a single self-hosted application with zero external dependencies.
|
||||
BZOD combines URL shortening, landing pages, QR code generation, password-protected links, smart preview pages, analytics, audit logging, lifecycle management, backup/restore, and API automation into a single self-hosted application with zero external service dependencies.
|
||||
|
||||
Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for individuals, organizations, homelab operators, and businesses that want complete control over their links, analytics, and branding.
|
||||
Built with Rust, SQLite, Axum, and Askama, BZOD is designed for individuals, organizations, homelab operators, government agencies, and businesses that want complete ownership of their links, analytics, and branding.
|
||||
|
||||
---
|
||||
## License
|
||||
|
||||
Licensed under either of
|
||||
|
||||
- Apache License, Version 2.0
|
||||
([LICENSE](LICENSE-APACHE)) - Default
|
||||
- MIT License
|
||||
([LICENSE-MIT](LICENSE-MIT))
|
||||
|
||||
at your option.
|
||||
|
||||
### Contribution
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in this project shall be dual licensed under
|
||||
the terms above without any additional terms or conditions.
|
||||
|
||||
[](#license)
|
||||
---
|
||||
## Highlights
|
||||
|
||||
### v0.2.0
|
||||
### v0.4.0
|
||||
|
||||
* QR code generation (PNG and SVG)
|
||||
* QR scan analytics
|
||||
* Raw visitor activity logs
|
||||
* Analytics drill-down pages
|
||||
* Visitor pagination
|
||||
* Advanced sliding-window pagination
|
||||
* CSV analytics export
|
||||
* JSON analytics export
|
||||
* Date-filtered analytics
|
||||
* Landing page analytics
|
||||
* Human-readable custom slugs
|
||||
* Root landing page support
|
||||
* UTM campaign builder
|
||||
* Built-in backup and restore
|
||||
* CLI shorten command
|
||||
* CLI expand command
|
||||
* QR code generation
|
||||
* Password-protected links
|
||||
* Smart preview pages
|
||||
* Link expiration
|
||||
* Audit trail
|
||||
* Bulk operations
|
||||
* Expanded test coverage
|
||||
* Improved health monitoring
|
||||
* Analytics dashboard
|
||||
* Audit logging
|
||||
* Health monitoring
|
||||
* Human-readable custom slugs
|
||||
* Root landing page support
|
||||
* Landing page custom slugs
|
||||
* UTM campaign builder
|
||||
* Built-in backup and restore
|
||||
* CLI shorten command
|
||||
* CLI expand command
|
||||
* QR code generation (PNG/SVG)
|
||||
* Password-protected links
|
||||
* Smart preview pages
|
||||
* Analytics dashboard
|
||||
* Audit logging
|
||||
* Health monitoring
|
||||
|
||||
---
|
||||
|
||||
## Feature Matrix
|
||||
|
||||
| Feature | Status |
|
||||
| ------------------------- | ------ |
|
||||
|---------------------------| ------ |
|
||||
| URL Shortening | ✅ |
|
||||
| Custom Slugs | ✅ |
|
||||
| Landing Pages | ✅ |
|
||||
| Landing Page Custom Slugs | ✅ |
|
||||
| QR Code Generation | ✅ |
|
||||
| QR Analytics | ✅ |
|
||||
| Password-Protected Links | ✅ |
|
||||
| Smart Preview Pages | ✅ |
|
||||
| Link Expiration | ✅ |
|
||||
| One-Time Links | ✅ |
|
||||
| One/Multi-Time Links | ✅ |
|
||||
| Audit Trail | ✅ |
|
||||
| Bulk Operations | ✅ |
|
||||
| Analytics Dashboard | ✅ |
|
||||
| Health Monitoring | ✅ |
|
||||
| REST API | ✅ |
|
||||
| CSV Import/Export | 🚧 |
|
||||
| Backup & Restore | ✅ |
|
||||
| UTM Campaign Builder | ✅ |
|
||||
| CLI Automation | ✅ |
|
||||
| Raw Visitor Logs | ✅ |
|
||||
| Analytics Export (CSV) | ✅ |
|
||||
| Analytics Export (JSON) | ✅ |
|
||||
| Analytics Drill-Down | ✅ |
|
||||
| Date Range Analytics | ✅ |
|
||||
| Advanced Pagination | ✅ |
|
||||
| Geo Analytics | 🚧 |
|
||||
| Multi-User Administration | 🚧 |
|
||||
| SSO | 🚧 |
|
||||
|
||||
---
|
||||
## One-Command Installation (Recommended)
|
||||
|
||||
```bash
|
||||
curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
```
|
||||
---
|
||||
## Features
|
||||
|
||||
### URL Shortening
|
||||
|
||||
Create short links using compact hexadecimal identifiers.
|
||||
Create compact short URLs using automatically generated hexadecimal identifiers.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://your-short-domain/1bb170
|
||||
```
|
||||
|
||||
Redirects to:
|
||||
|
||||
```text
|
||||
https://very-long-domain-name.com
|
||||
https://your-domain/1bb170
|
||||
```
|
||||
|
||||
---
|
||||
## Design Principles
|
||||
|
||||
BZOD is intentionally designed around a few principles:
|
||||
|
||||
- Self-hosted first
|
||||
- SQLite-first architecture
|
||||
- Minimal operational complexity
|
||||
- Recovery over convenience
|
||||
- Human-readable administration
|
||||
- No external service dependencies
|
||||
- No vendor lock-in
|
||||
|
||||
Features are added only when they improve usability without increasing architectural complexity.
|
||||
### Custom Slugs
|
||||
|
||||
Create memorable human-readable links.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
https://your-domain/!office
|
||||
https://your-domain/!home
|
||||
https://your-domain/!site
|
||||
https://your-domain/!project-alpha
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
* Case-insensitive uniqueness
|
||||
* Lowercase normalization
|
||||
* Human-readable URLs
|
||||
* No database schema changes
|
||||
* Fully compatible with existing short codes
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
!office
|
||||
!home
|
||||
!warehouse
|
||||
!meeting-room
|
||||
!client_a
|
||||
```
|
||||
## Practical Examples
|
||||
|
||||
Generated URL
|
||||
```
|
||||
https://bzo.in/1b926e
|
||||
```
|
||||
Custom Slug
|
||||
```
|
||||
https://bzo.in/!myoffice
|
||||
```
|
||||
Landing Page
|
||||
```
|
||||
https://bzo.in/p/!company-profile
|
||||
```
|
||||
---
|
||||
## CLI
|
||||
```
|
||||
bzod shorten https://example.com
|
||||
bzod shorten https://example.com --slug !office
|
||||
bzod expand !office
|
||||
```
|
||||
---
|
||||
### Landing Pages
|
||||
|
||||
Create standalone landing pages hosted directly by BZOD.
|
||||
|
||||
Generated page:
|
||||
|
||||
```text
|
||||
https://your-domain/p/1a2b
|
||||
```
|
||||
|
||||
Custom slug page:
|
||||
|
||||
```text
|
||||
https://your-domain/p/!company-profile
|
||||
https://your-domain/p/!product-launch
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
* Raw HTML support
|
||||
* SEO slug support
|
||||
* Published / Draft states
|
||||
* Custom paths
|
||||
* Open Graph metadata
|
||||
|
||||
---
|
||||
### CLI Commands
|
||||
|
||||
Core commands
|
||||
```bash
|
||||
bzod serve
|
||||
bzod create-admin
|
||||
bzod doctor
|
||||
bzod stats
|
||||
```
|
||||
URL management
|
||||
```bash
|
||||
bzod shorten https://example.com
|
||||
bzod shorten https://example.com --slug !campaign
|
||||
bzod expand 1bb170
|
||||
bzod expand !campaign
|
||||
```
|
||||
Maintenance
|
||||
```bash
|
||||
bzod backup
|
||||
bzod restore --file backup_20250614.tar.gz
|
||||
bzod migrate
|
||||
```
|
||||
### QR Code Generation
|
||||
|
||||
Generate QR codes for every short URL.
|
||||
@@ -88,20 +250,20 @@ Features:
|
||||
|
||||
### Password-Protected Links
|
||||
|
||||
Protect sensitive links using Argon2id-hashed passwords.
|
||||
Protect sensitive links using Argon2id password hashing.
|
||||
|
||||
Features:
|
||||
|
||||
* Password gate
|
||||
* Secure session handling
|
||||
* Configurable protection
|
||||
* Access restrictions
|
||||
* Audit logging
|
||||
|
||||
---
|
||||
|
||||
### Smart Preview Pages
|
||||
|
||||
Display branded preview pages before redirecting.
|
||||
Display branded preview pages before redirecting visitors.
|
||||
|
||||
Features:
|
||||
|
||||
@@ -113,18 +275,6 @@ Features:
|
||||
|
||||
---
|
||||
|
||||
### Landing Pages
|
||||
|
||||
Create standalone landing pages using dedicated page identifiers.
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://your-short-domain/p/1a2b
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Link Lifecycle Management
|
||||
|
||||
Control link validity.
|
||||
@@ -132,63 +282,121 @@ Control link validity.
|
||||
Features:
|
||||
|
||||
* Expiration dates
|
||||
* Automatic expiry jobs
|
||||
* One-time links
|
||||
* Maximum access limits
|
||||
* Administrative disabling
|
||||
* Access limits
|
||||
* Administrative disable
|
||||
* Automated expiry jobs
|
||||
|
||||
---
|
||||
|
||||
### UTM Campaign Builder
|
||||
|
||||
Append campaign tracking parameters when creating links.
|
||||
|
||||
Supported parameters:
|
||||
|
||||
```text
|
||||
utm_source
|
||||
utm_medium
|
||||
utm_campaign
|
||||
```
|
||||
|
||||
Example output:
|
||||
|
||||
```text
|
||||
https://example.com/page?utm_source=email&utm_medium=newsletter&utm_campaign=launch
|
||||
```
|
||||
|
||||
No additional database schema changes are required.
|
||||
|
||||
---
|
||||
### Analytics
|
||||
|
||||
Track:
|
||||
|
||||
* Total visits
|
||||
* Unique visitors
|
||||
* QR scans
|
||||
* Country statistics
|
||||
* Countries
|
||||
* Referrers
|
||||
* User agents
|
||||
* Daily statistics
|
||||
* Monthly statistics
|
||||
* Yearly statistics
|
||||
|
||||
Analytics drill-down includes:
|
||||
|
||||
* Raw visitor activity logs
|
||||
* Visitor IP addresses
|
||||
* Country information
|
||||
* Referrer tracking
|
||||
* Browser detection
|
||||
* Raw User-Agent display
|
||||
* Date range filtering
|
||||
* CSV export
|
||||
* JSON export
|
||||
* Paginated visitor logs
|
||||
|
||||
Analytics Export
|
||||
|
||||
Export analytics data directly from the administration interface.
|
||||
|
||||
Supported formats:
|
||||
|
||||
* CSV
|
||||
* JSON
|
||||
|
||||
Features:
|
||||
|
||||
* Memory-safe export handling
|
||||
* Date-range filtering
|
||||
* Downloadable files
|
||||
* Compatible with spreadsheets and BI tools
|
||||
|
||||
---
|
||||
|
||||
### Audit Trail
|
||||
|
||||
Track administrative actions including:
|
||||
Track administrative activity.
|
||||
|
||||
Recorded events include:
|
||||
|
||||
* Login
|
||||
* Logout
|
||||
* URL creation
|
||||
* URL updates
|
||||
* URL deletion
|
||||
* QR operations
|
||||
* Backup creation
|
||||
* Restore operations
|
||||
* QR exports
|
||||
* Configuration changes
|
||||
|
||||
---
|
||||
|
||||
### Administrative Dashboard
|
||||
|
||||
Web-based administration interface featuring:
|
||||
Web-based management interface.
|
||||
|
||||
* URL management
|
||||
* QR code management
|
||||
* Landing page management
|
||||
* Preview page management
|
||||
Features:
|
||||
|
||||
* URL registry
|
||||
* Landing pages
|
||||
* QR management
|
||||
* Analytics
|
||||
* API token management
|
||||
* Audit logs
|
||||
* Link expiration controls
|
||||
* Backup utilities
|
||||
* Restore utilities
|
||||
* Health monitoring
|
||||
* Analytics dashboard
|
||||
* SVG charts
|
||||
* Bulk operations
|
||||
* Server diagnostics
|
||||
|
||||
---
|
||||
|
||||
### API Support
|
||||
### REST API
|
||||
|
||||
REST API endpoints for automation and integration.
|
||||
REST API support for automation and integrations.
|
||||
|
||||
Endpoint prefix:
|
||||
|
||||
```text
|
||||
/api/v1/*
|
||||
@@ -198,22 +406,88 @@ Supports:
|
||||
|
||||
* URL creation
|
||||
* URL management
|
||||
* Landing pages
|
||||
* QR generation
|
||||
* Analytics access
|
||||
* Bulk operations
|
||||
|
||||
---
|
||||
|
||||
### CLI Automation
|
||||
|
||||
Create and manage links directly from the command line.
|
||||
|
||||
Examples:
|
||||
|
||||
Create automatic code:
|
||||
|
||||
```bash
|
||||
bzod shorten https://example.com
|
||||
```
|
||||
|
||||
Create custom slug:
|
||||
|
||||
```bash
|
||||
bzod shorten https://example.com --slug !office
|
||||
```
|
||||
|
||||
Expand code:
|
||||
|
||||
```bash
|
||||
bzod expand 1bb170
|
||||
```
|
||||
|
||||
Expand custom slug:
|
||||
|
||||
```bash
|
||||
bzod expand !office
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Backup & Restore
|
||||
|
||||
BZOD includes integrated backup and restore functionality through both the CLI and Web UI.
|
||||
|
||||
CLI:
|
||||
|
||||
```bash
|
||||
bzod backup
|
||||
bzod restore --file backup.tar.gz
|
||||
```
|
||||
|
||||
Web UI:
|
||||
|
||||
```text
|
||||
Settings → Maintenance & DB Utilities
|
||||
```
|
||||
|
||||
Features:
|
||||
|
||||
* Compressed tar.gz backups
|
||||
* Full database restoration
|
||||
* Backup validation
|
||||
* Disaster recovery support
|
||||
* No external tools required
|
||||
|
||||
Protected databases:
|
||||
|
||||
* admin.db
|
||||
* content.db
|
||||
* analytics.db
|
||||
* system.db
|
||||
|
||||
---
|
||||
|
||||
### Security
|
||||
|
||||
* Password-protected administration interface
|
||||
* Password-protected administration
|
||||
* Password-protected links
|
||||
* Argon2id password hashing
|
||||
* Session management
|
||||
* CSRF protection
|
||||
* Session management
|
||||
* API token authentication
|
||||
* Audit logging
|
||||
* Link access controls
|
||||
* Access controls
|
||||
|
||||
---
|
||||
|
||||
@@ -235,7 +509,7 @@ No:
|
||||
* PostgreSQL
|
||||
* MongoDB
|
||||
* Kubernetes
|
||||
* External SaaS
|
||||
* SaaS dependencies
|
||||
|
||||
---
|
||||
|
||||
@@ -243,21 +517,19 @@ No:
|
||||
|
||||
### Databases
|
||||
|
||||
nx9-url-shortener uses four SQLite databases.
|
||||
BZOD uses four SQLite databases.
|
||||
|
||||
| Database | Purpose |
|
||||
| ------------ | ------------------------------------------------- |
|
||||
| admin.db | Users, sessions, API keys |
|
||||
| content.db | URLs, landing pages, preview pages, tags |
|
||||
| analytics.db | Visits, QR scans, statistics |
|
||||
| system.db | Audit events, jobs, migrations, health monitoring |
|
||||
| Database | Purpose |
|
||||
| ------------ | ------------------------------ |
|
||||
| admin.db | Users, sessions, API keys |
|
||||
| content.db | URLs, landing pages, metadata |
|
||||
| analytics.db | Visits, QR scans, statistics |
|
||||
| system.db | Audit events, jobs, monitoring |
|
||||
|
||||
---
|
||||
|
||||
## Initial Setup
|
||||
|
||||
Create an administrator account:
|
||||
|
||||
### Native Installation
|
||||
|
||||
```bash
|
||||
@@ -267,11 +539,40 @@ cargo run -- create-admin
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker exec -it nx9-url-shortener nx9-url-shortener create-admin
|
||||
docker exec -it bzod bzod create-admin
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disaster Recovery Validation
|
||||
|
||||
The backup and restore system has been validated through a complete recovery workflow.
|
||||
|
||||
Validation procedure:
|
||||
|
||||
1. Create backup archive
|
||||
2. Stop application
|
||||
3. Restore backup
|
||||
4. Restart application
|
||||
5. Verify application integrity
|
||||
|
||||
Verified components:
|
||||
|
||||
* URL registry
|
||||
* Landing pages
|
||||
* Analytics
|
||||
* Audit logs
|
||||
* API tokens
|
||||
* QR assets
|
||||
* Settings
|
||||
* Health monitoring
|
||||
|
||||
Expected outcome:
|
||||
|
||||
The application returns to a fully operational state without data loss.
|
||||
|
||||
---
|
||||
|
||||
## Screenshots
|
||||
|
||||
### Dashboard
|
||||
@@ -298,22 +599,22 @@ docker exec -it nx9-url-shortener nx9-url-shortener create-admin
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
### Build
|
||||
Build:
|
||||
|
||||
```bash
|
||||
docker compose build
|
||||
```
|
||||
|
||||
### Start
|
||||
Start:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Logs
|
||||
Logs:
|
||||
|
||||
```bash
|
||||
docker logs -f nx9-url-shortener
|
||||
docker logs -f bzod
|
||||
```
|
||||
|
||||
---
|
||||
@@ -322,9 +623,9 @@ docker logs -f nx9-url-shortener
|
||||
|
||||
```yaml
|
||||
services:
|
||||
nx9-url-shortener:
|
||||
bzod:
|
||||
build: .
|
||||
container_name: nx9-url-shortener
|
||||
container_name: bzod
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
@@ -344,33 +645,44 @@ services:
|
||||
|
||||
## Development
|
||||
|
||||
### Build
|
||||
Build:
|
||||
|
||||
```bash
|
||||
cargo build
|
||||
```
|
||||
|
||||
### Run
|
||||
Run:
|
||||
|
||||
```bash
|
||||
cargo run -- serve
|
||||
```
|
||||
|
||||
### Create Administrator
|
||||
Create administrator:
|
||||
|
||||
```bash
|
||||
cargo run -- create-admin
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
Run tests:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Development & Testing
|
||||
|
||||
See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing, validation, backup, restore, disaster recovery, and release procedures.
|
||||
See:
|
||||
|
||||
```text
|
||||
docs/TESTING.md
|
||||
```
|
||||
|
||||
for testing, validation, backup, restore, disaster recovery, and release procedures.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
@@ -394,20 +706,17 @@ src/
|
||||
|
||||
Planned features:
|
||||
|
||||
* Vanity URLs
|
||||
* CSV import/export
|
||||
* Geo analytics
|
||||
* Multi-user administration
|
||||
* SSO integration
|
||||
* Signed temporary links
|
||||
* OpenAPI documentation
|
||||
* Webhook support
|
||||
|
||||
---
|
||||
|
||||
## Production Deployment
|
||||
|
||||
Recommended stack:
|
||||
Recommended architecture:
|
||||
|
||||
```text
|
||||
Internet
|
||||
@@ -416,7 +725,7 @@ Internet
|
||||
Nginx Proxy Manager
|
||||
│
|
||||
▼
|
||||
nx9-url-shortener
|
||||
BZOD
|
||||
│
|
||||
▼
|
||||
SQLite
|
||||
@@ -424,6 +733,12 @@ SQLite
|
||||
|
||||
HTTPS is strongly recommended.
|
||||
|
||||
---
|
||||
## Documentation
|
||||
|
||||
- [Testing Guide](docs/TESTING.md)
|
||||
- [Docker Deployment Guide](docs/DOCKER-Deploy.md)
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
@@ -437,4 +752,3 @@ Apache License 2.0
|
||||
Sunil Purushottam Thakare
|
||||
|
||||
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable 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}"
|
||||
+19
-26
@@ -1,67 +1,60 @@
|
||||
name: app-bzod
|
||||
|
||||
services:
|
||||
bzod:
|
||||
image: nx9-url-shortener:v0.1.0
|
||||
|
||||
build:
|
||||
context: .
|
||||
context: /DATA/AppData/bzod
|
||||
dockerfile: Dockerfile
|
||||
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
container_name: bzod
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
environment:
|
||||
- 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
|
||||
|
||||
hostname: bzod
|
||||
privileged: false
|
||||
|
||||
cpu_shares: 90
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: app_default
|
||||
|
||||
x-casaos:
|
||||
hostname: ""
|
||||
scheme: http
|
||||
index: /
|
||||
port_map: "8654"
|
||||
author: self
|
||||
category: self
|
||||
hostname: ""
|
||||
icon: ""
|
||||
index: /
|
||||
is_uncontrolled: false
|
||||
port_map: "8654"
|
||||
scheme: http
|
||||
title:
|
||||
custom: nx9-url-shortener
|
||||
+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,24 @@
|
||||
# Changelog
|
||||
|
||||
## 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
|
||||
@@ -0,0 +1,382 @@
|
||||
# BZOD vs Other Self-Hosted URL Shorteners
|
||||
|
||||
**BZOD (nx9-url-shortener)** is a modern, privacy-focused, self-hosted URL management platform built in Rust as part of the **NX9 Platform**.
|
||||
|
||||
Unlike many traditional URL shorteners that focus solely on redirects, BZOD combines:
|
||||
|
||||
* URL shortening
|
||||
* Landing pages
|
||||
* QR code generation
|
||||
* QR analytics
|
||||
* Password protection
|
||||
* Link expiration
|
||||
* REST API
|
||||
* CLI automation
|
||||
* Backup & restore
|
||||
* Audit logging
|
||||
|
||||
into a single lightweight deployment.
|
||||
|
||||
**Philosophy:** *One binary. One command. Full ownership.*
|
||||
|
||||
---
|
||||
|
||||
## At a Glance
|
||||
|
||||
* Rust-based
|
||||
* Single ~18 MB binary
|
||||
* Embedded SQLite
|
||||
* No external services required
|
||||
* Landing pages
|
||||
* QR generation & analytics
|
||||
* Password-protected links
|
||||
* REST API
|
||||
* CLI automation
|
||||
* Backup & restore
|
||||
* Audit trail
|
||||
* MIT OR Apache-2.0 licensed
|
||||
* One-command deployment
|
||||
|
||||
---
|
||||
|
||||
## 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 |
|
||||
| License | MIT OR Apache-2.0 | MIT | MIT | MIT |
|
||||
|
||||
---
|
||||
|
||||
## Design Philosophy
|
||||
|
||||
| Principle | BZOD |
|
||||
| -------------------------- | ----------------- |
|
||||
| Self-hosted | ✅ |
|
||||
| Privacy-first | ✅ |
|
||||
| Open Source | MIT OR Apache-2.0 |
|
||||
| Vendor Lock-in | None |
|
||||
| Telemetry | None |
|
||||
| External Services Required | None |
|
||||
| Database Server Required | No (SQLite) |
|
||||
| Runtime Dependencies | None |
|
||||
| Single Binary | Yes (~18 MB) |
|
||||
| Linux-first | Yes |
|
||||
|
||||
---
|
||||
|
||||
## The NX9 Philosophy
|
||||
|
||||
BZOD is part of the **NX9 Platform**.
|
||||
|
||||
NX9 projects follow strict engineering principles:
|
||||
|
||||
* Linux-native first
|
||||
* Rust-first
|
||||
* Single binary deployments
|
||||
* No NodeJS
|
||||
* No React
|
||||
* No Python runtime dependencies
|
||||
* No vendor lock-in
|
||||
* No telemetry
|
||||
* Privacy-first by default
|
||||
* MIT OR Apache-2.0 licensed
|
||||
|
||||
GitHub and Codeberg are source-code repositories, not the projects themselves.
|
||||
|
||||
The software is the project.
|
||||
|
||||
The goal of NX9 is simple:
|
||||
|
||||
> Build technology that serves people, organizations, communities, and governments — not advertising networks, data brokers, or vendor ecosystems.
|
||||
|
||||
---
|
||||
|
||||
## Why BZOD Exists
|
||||
|
||||
Most self-hosted URL shorteners optimize for one of two extremes:
|
||||
|
||||
### 1. Minimal Redirect Service
|
||||
|
||||
A tiny application that creates short links and redirects traffic.
|
||||
|
||||
Advantages:
|
||||
|
||||
* Extremely lightweight
|
||||
* Easy to understand
|
||||
* Easy to maintain
|
||||
|
||||
Disadvantages:
|
||||
|
||||
* Limited administration
|
||||
* Limited analytics
|
||||
* Limited security features
|
||||
* Often requires additional tools
|
||||
|
||||
### 2. Large Multi-Service Platform
|
||||
|
||||
Feature-rich systems with extensive integrations and dependencies.
|
||||
|
||||
Advantages:
|
||||
|
||||
* Powerful analytics
|
||||
* Advanced routing
|
||||
* Large ecosystems
|
||||
|
||||
Disadvantages:
|
||||
|
||||
* More infrastructure
|
||||
* More maintenance
|
||||
* Higher resource requirements
|
||||
|
||||
### BZOD's Approach
|
||||
|
||||
BZOD intentionally sits in the middle.
|
||||
|
||||
It is:
|
||||
|
||||
* Small enough for a Raspberry Pi
|
||||
* Powerful enough for organizations
|
||||
* Simple enough for homelabs
|
||||
* Complete enough for production use
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Go URL Shorteners
|
||||
|
||||
Popular Go projects include:
|
||||
|
||||
* Krtk
|
||||
* Slash
|
||||
* Goshorly
|
||||
* Shortr
|
||||
* Custom Gin/Echo implementations
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD (Rust) | Typical Go Projects |
|
||||
| ------------------- | -------------------- | ------------------- |
|
||||
| Binary | Single ~18 MB binary | Usually 10–20 MB |
|
||||
| Runtime | None | None |
|
||||
| Database | Embedded SQLite | SQLite / PostgreSQL |
|
||||
| Landing Pages | ✅ Built-in | Rare |
|
||||
| QR Generation | ✅ | Sometimes |
|
||||
| QR Analytics | ✅ | Rare |
|
||||
| Password Protection | ✅ | Varies |
|
||||
| Link Expiry | ✅ | Often |
|
||||
| One-Time Links | ✅ | Rare |
|
||||
| UTM Builder | ✅ | Rare |
|
||||
| REST API | ✅ | Usually |
|
||||
| CLI | ✅ Extensive | Usually limited |
|
||||
| Backup & Restore | ✅ Built-in | Rare |
|
||||
| Audit Logs | ✅ | Rare |
|
||||
| Admin Dashboard | ✅ | Varies |
|
||||
|
||||
### Summary
|
||||
|
||||
Go shorteners are often:
|
||||
|
||||
* Extremely simple
|
||||
* Fast
|
||||
* Easy to extend
|
||||
|
||||
BZOD focuses on:
|
||||
|
||||
* Rich built-in functionality
|
||||
* Complete ownership
|
||||
* Minimal operations
|
||||
* Batteries-included deployment
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Python URL Shorteners
|
||||
|
||||
Popular Python projects include:
|
||||
|
||||
* Pygmy
|
||||
* ReducePy
|
||||
* Schort
|
||||
* Flask/FastAPI examples
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD (Rust) | Python Solutions |
|
||||
| ---------------- | --------------------- | ----------------- |
|
||||
| Runtime | None | Python required |
|
||||
| Deploy Size | ~18 MB | Often 100+ MB |
|
||||
| Memory Usage | Very Low | Moderate |
|
||||
| Landing Pages | ✅ | Rare |
|
||||
| QR Analytics | ✅ | Rare |
|
||||
| Backup & Restore | ✅ | Rare |
|
||||
| CLI Tools | ✅ | Limited |
|
||||
| Dashboard | ✅ | Varies |
|
||||
| Security | Argon2id + Audit Logs | Project dependent |
|
||||
| Performance | Excellent | Good |
|
||||
|
||||
### Summary
|
||||
|
||||
Python solutions are ideal when:
|
||||
|
||||
* Already using Python
|
||||
* Rapid prototyping
|
||||
* Easy customization
|
||||
|
||||
BZOD is ideal when:
|
||||
|
||||
* Long-term deployment matters
|
||||
* Resource efficiency matters
|
||||
* Minimal maintenance is desired
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Shlink
|
||||
|
||||
Shlink is one of the most mature self-hosted URL shorteners available.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | Shlink |
|
||||
| ------------------------ | ------------- | ---------------- |
|
||||
| Language | Rust | PHP |
|
||||
| Deployment | Single binary | PHP stack |
|
||||
| External DB | No | Usually yes |
|
||||
| Landing Pages | ✅ | ❌ |
|
||||
| Password Protected Links | ✅ | Limited |
|
||||
| QR Analytics | ✅ | Partial |
|
||||
| UTM Builder | ✅ | ❌ |
|
||||
| Backup & Restore | ✅ | External tooling |
|
||||
| Audit Trail | ✅ | Limited |
|
||||
| Dynamic Redirect Rules | ❌ | ✅ |
|
||||
| Multi-domain | Planned | ✅ |
|
||||
| Ecosystem | Growing | Mature |
|
||||
|
||||
### Summary
|
||||
|
||||
Choose Shlink when:
|
||||
|
||||
* Multi-domain management is critical
|
||||
* Dynamic redirect rules are required
|
||||
* Enterprise-scale analytics matter
|
||||
|
||||
Choose BZOD when:
|
||||
|
||||
* Simplicity matters
|
||||
* Privacy matters
|
||||
* Minimal infrastructure matters
|
||||
* Landing pages are important
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs YOURLS
|
||||
|
||||
YOURLS is the classic self-hosted URL shortener.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | YOURLS |
|
||||
| ------------- | ------------- | ---------- |
|
||||
| Language | Rust | PHP |
|
||||
| Architecture | Single binary | LAMP stack |
|
||||
| Plugins | Not required | Extensive |
|
||||
| Landing Pages | ✅ | Plugin |
|
||||
| QR Analytics | ✅ | Plugin |
|
||||
| Audit Logs | ✅ | Plugin |
|
||||
| Backup Tools | ✅ | External |
|
||||
| API | ✅ | ✅ |
|
||||
| Maintenance | Minimal | Moderate |
|
||||
|
||||
### Summary
|
||||
|
||||
YOURLS wins on:
|
||||
|
||||
* Age
|
||||
* Community
|
||||
* Plugin ecosystem
|
||||
|
||||
BZOD wins on:
|
||||
|
||||
* Simplicity
|
||||
* Deployment
|
||||
* Modern architecture
|
||||
* Integrated features
|
||||
|
||||
---
|
||||
|
||||
# BZOD vs Chhoto URL
|
||||
|
||||
Chhoto URL is the closest Rust-based competitor.
|
||||
|
||||
## Detailed Comparison
|
||||
|
||||
| Aspect | BZOD | Chhoto URL |
|
||||
| ---------------- | ------ | ---------- |
|
||||
| Language | Rust | Rust |
|
||||
| Landing Pages | ✅ | ❌ |
|
||||
| QR Codes | ✅ | ✅ |
|
||||
| QR Analytics | ✅ | ❌ |
|
||||
| Password Links | ✅ | ❌ |
|
||||
| Backup & Restore | ✅ | ❌ |
|
||||
| REST API | ✅ | JSON-RPC |
|
||||
| Audit Logs | ✅ | ❌ |
|
||||
| Analytics | Rich | Basic |
|
||||
| Binary Size | ~18 MB | Smaller |
|
||||
|
||||
### Summary
|
||||
|
||||
Choose Chhoto URL for:
|
||||
|
||||
* Maximum simplicity
|
||||
* Minimal footprint
|
||||
|
||||
Choose BZOD for:
|
||||
|
||||
* Feature completeness
|
||||
* Better administration
|
||||
* Better analytics
|
||||
|
||||
---
|
||||
|
||||
## Future Comparisons
|
||||
|
||||
Additional comparison sections may be added in the future for:
|
||||
|
||||
* Bitly
|
||||
* Dub
|
||||
* Pygmy
|
||||
* Krtk
|
||||
* Other self-hosted URL management platforms
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**BZOD is not merely a URL shortener.**
|
||||
|
||||
It is a lightweight URL management platform that combines:
|
||||
|
||||
* Link shortening
|
||||
* Landing pages
|
||||
* QR services
|
||||
* Analytics
|
||||
* Automation
|
||||
* Security
|
||||
* Backups
|
||||
|
||||
into a single deployable Rust binary.
|
||||
|
||||
As part of the NX9 Platform, BZOD follows a simple principle:
|
||||
|
||||
> Own your links. Own your data. Own your infrastructure.
|
||||
|
||||
No telemetry. No vendor lock-in. No unnecessary complexity.
|
||||
|
||||
For users seeking privacy, simplicity, ownership, and long-term sustainability, BZOD offers a compelling alternative to both cloud SaaS platforms and traditional self-hosted URL shorteners.
|
||||
@@ -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.
|
||||
+36
-6
@@ -223,8 +223,25 @@ Overall status: HEALTHY
|
||||
and original record counts preserved.
|
||||
|
||||
---
|
||||
## 9. Disaster Recovery Scenario
|
||||
|
||||
## 9. Disaster Recovery Test
|
||||
1. Create backup
|
||||
2. Stop container
|
||||
3. Delete databases
|
||||
4. Restore from backup
|
||||
5. Fix permissions
|
||||
6. Restart container
|
||||
7. Validate:
|
||||
- URLs
|
||||
- Landing pages
|
||||
- Audit logs
|
||||
- Settings
|
||||
- Analytics
|
||||
- Status page
|
||||
|
||||
Expected Result:
|
||||
System fully restored without data loss.
|
||||
## 10. Disaster Recovery Test
|
||||
|
||||
This is the most important test.
|
||||
|
||||
@@ -255,7 +272,7 @@ Expected Result:
|
||||
|
||||
---
|
||||
|
||||
## 10. Database Health Verification
|
||||
## 11. Database Health Verification
|
||||
|
||||
Run:
|
||||
|
||||
@@ -281,7 +298,7 @@ Overall status: HEALTHY
|
||||
|
||||
---
|
||||
|
||||
## 11. SQLite Integrity Checks
|
||||
## 12. SQLite Integrity Checks
|
||||
|
||||
Manual verification.
|
||||
|
||||
@@ -302,7 +319,7 @@ for all databases.
|
||||
|
||||
---
|
||||
|
||||
## 12. Web Interface Verification
|
||||
## 13. Web Interface Verification
|
||||
|
||||
Start server.
|
||||
|
||||
@@ -321,7 +338,7 @@ Verify:
|
||||
|
||||
---
|
||||
|
||||
## 13. Docker Verification
|
||||
## 14. Docker Verification
|
||||
|
||||
Build image.
|
||||
|
||||
@@ -357,7 +374,7 @@ inside container.
|
||||
|
||||
---
|
||||
|
||||
## 14. Upgrade Verification
|
||||
## 15. Upgrade Verification
|
||||
|
||||
1. Create backup.
|
||||
2. Upgrade binary.
|
||||
@@ -378,7 +395,20 @@ Expected Result:
|
||||
* No migration failures
|
||||
|
||||
---
|
||||
## Analytics Verification
|
||||
|
||||
Verify:
|
||||
|
||||
* URL analytics page loads
|
||||
* Landing page analytics page loads
|
||||
* Visitor activity table renders
|
||||
* Empty visitor tables render correctly
|
||||
* CSV export downloads successfully
|
||||
* JSON export downloads successfully
|
||||
* Date filtering works
|
||||
* Invalid date filters return HTTP 400
|
||||
* Pagination preserves active filters
|
||||
* Exports respect active filters
|
||||
# Release Acceptance Criteria
|
||||
|
||||
A release is considered production-ready only if:
|
||||
|
||||
+42
-6
@@ -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,5 +1,5 @@
|
||||
use crate::auth::session::authenticate_api_key;
|
||||
use crate::models::User;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
use axum::{
|
||||
extract::{FromRef, FromRequestParts},
|
||||
@@ -7,7 +7,7 @@ use axum::{
|
||||
};
|
||||
|
||||
// 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
|
||||
@@ -25,9 +25,10 @@ where
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
||||
|
||||
let conn = app_state.admin_db.lock().unwrap();
|
||||
match authenticate_api_key(&conn, auth_header) {
|
||||
Ok(Some(user)) => Ok(ApiUser(user)),
|
||||
let admin_conn = app_state.admin_db.lock().unwrap();
|
||||
let users_conn = app_state.users_db.lock().unwrap();
|
||||
match authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
Ok(Some(actor)) => Ok(ApiUser(actor)),
|
||||
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
|
||||
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
|
||||
}
|
||||
|
||||
+3
-1
@@ -6,4 +6,6 @@ pub mod session;
|
||||
pub use csrf::{generate_csrf_token, verify_csrf};
|
||||
pub use middleware::ApiUser;
|
||||
pub use password::{hash_password, verify_password, verify_sha256};
|
||||
pub use session::{authenticate_api_key, authenticate_session, generate_token};
|
||||
pub use session::{
|
||||
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
|
||||
};
|
||||
+255
-17
@@ -1,11 +1,11 @@
|
||||
use crate::db::admin::{
|
||||
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
|
||||
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
|
||||
};
|
||||
use crate::models::User;
|
||||
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rand::{thread_rng, RngCore};
|
||||
use rusqlite::Connection;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
// Generate a secure random token (hex-encoded)
|
||||
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
|
||||
hex::encode(key)
|
||||
}
|
||||
|
||||
// Authenticate session from cookies
|
||||
pub fn authenticate_session(
|
||||
// Authenticate administrator session from cookies
|
||||
pub fn authenticate_admin_session(
|
||||
conn: &Connection,
|
||||
jar: &CookieJar,
|
||||
) -> Result<Option<(User, String)>, rusqlite::Error> {
|
||||
@@ -26,7 +26,33 @@ pub fn authenticate_session(
|
||||
};
|
||||
|
||||
let session_id = cookie.value();
|
||||
let session = match get_session(conn, session_id)? {
|
||||
let session_opt: Option<AdminSession> = conn
|
||||
.query_row(
|
||||
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
|
||||
[session_id],
|
||||
|row| {
|
||||
let id: String = row.get(0)?;
|
||||
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
|
||||
let user_id_str: String = match row.get::<_, String>(1) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
let i: i64 = row.get(1)?;
|
||||
i.to_string()
|
||||
}
|
||||
};
|
||||
let expires_at: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
Ok(AdminSession {
|
||||
id,
|
||||
user_id: user_id_str,
|
||||
expires_at,
|
||||
created_at,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
let session = match session_opt {
|
||||
Some(s) => s,
|
||||
None => return Ok(None),
|
||||
};
|
||||
@@ -41,19 +67,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);
|
||||
}
|
||||
@@ -68,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,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -32,8 +32,9 @@ 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)?;
|
||||
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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -3,12 +3,23 @@ use clap::{Parser, Subcommand};
|
||||
pub mod backup;
|
||||
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")]
|
||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||
@@ -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()
|
||||
}
|
||||
+41
-4
@@ -6,6 +6,46 @@ use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub fn perform_restore(
|
||||
file_path: &std::path::Path,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Open the archive
|
||||
let f = File::open(file_path)?;
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
|
||||
// 2. Validate that the archive contains the expected BZOD database files
|
||||
let mut has_admin = false;
|
||||
let mut has_content = false;
|
||||
let mut has_analytics = false;
|
||||
let mut has_system = false;
|
||||
|
||||
for entry_res in archive.entries()? {
|
||||
let entry = entry_res?;
|
||||
let path = entry.path()?;
|
||||
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
match file_name {
|
||||
"admin.db" => has_admin = true,
|
||||
"content.db" => has_content = true,
|
||||
"analytics.db" => has_analytics = true,
|
||||
"system.db" => has_system = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if !has_admin || !has_content || !has_analytics || !has_system {
|
||||
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into());
|
||||
}
|
||||
|
||||
// 3. Unpack archive to data_dir
|
||||
let f2 = File::open(file_path)?;
|
||||
let tar_gz2 = GzDecoder::new(f2);
|
||||
let mut archive2 = Archive::new(tar_gz2);
|
||||
archive2.unpack(data_dir)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
file: String,
|
||||
data_dir: Option<String>,
|
||||
@@ -40,10 +80,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,269 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
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
|
||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||
{
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
let tx = system_conn.transaction()?;
|
||||
|
||||
// Delete any existing global slugs owned by this user
|
||||
tx.execute(
|
||||
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
|
||||
[target_user_id],
|
||||
)?;
|
||||
|
||||
// Register URLs
|
||||
{
|
||||
let mut stmt =
|
||||
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let 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 now = Utc::now().to_rfc3339();
|
||||
|
||||
let existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Register Landing Pages
|
||||
{
|
||||
let mut stmt = restored_content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let 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 existing_owner: Option<i64> = tx
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some(owner) = existing_owner {
|
||||
if owner != target_user_id {
|
||||
error!(
|
||||
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
|
||||
slug, owner
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
tx.execute(
|
||||
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
}
|
||||
|
||||
// 5. Reconcile quotas for restored user
|
||||
crate::db::users::reconcile_user_quotas(
|
||||
&db.users.lock().unwrap(),
|
||||
target_user_id,
|
||||
&restored_content_conn,
|
||||
)?;
|
||||
|
||||
info!(
|
||||
"User '{}' (ID: {}) successfully restored from backup.",
|
||||
metadata.username, target_user_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -63,11 +63,29 @@ pub async fn run(
|
||||
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,73 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn run(
|
||||
target_url: String,
|
||||
slug: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Basic URL validation
|
||||
if reqwest::Url::parse(&target_url).is_err() {
|
||||
return Err("Invalid destination URL format".into());
|
||||
}
|
||||
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
// 2. Validate/normalize slug/code
|
||||
let code = match slug {
|
||||
Some(s) => {
|
||||
let normalized = s.trim().to_lowercase();
|
||||
if !crate::utils::validation::validate_custom_slug(&normalized) {
|
||||
return Err(
|
||||
"Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
normalized
|
||||
}
|
||||
None => crate::utils::random::generate_token(3),
|
||||
};
|
||||
|
||||
// 3. Persist URL
|
||||
let conn = db.content.lock().unwrap();
|
||||
let res = crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&target_url,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
match res {
|
||||
Ok(_) => {
|
||||
let proto = if config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, config.port));
|
||||
|
||||
// Output only the shortened URL as requested
|
||||
println!("{}/{}", base_url, code);
|
||||
Ok(())
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
Err("Short code/slug already exists".into())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -71,10 +71,20 @@ pub fn create_session(
|
||||
) -> rusqlite::Result<Session> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
// Bind `user_id` as integer when it appears to be numeric so that numeric
|
||||
// user IDs inserted into `users.db` keep the integer affinity and avoid
|
||||
// InvalidColumnType errors when read as i64 elsewhere.
|
||||
if let Ok(id_i64) = user_id.parse::<i64>() {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, id_i64, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
} else {
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(Session {
|
||||
id: session_id.to_string(),
|
||||
|
||||
+241
-3
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
|
||||
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
|
||||
)?;
|
||||
|
||||
for r in records {
|
||||
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
|
||||
r.referer,
|
||||
r.accept_language,
|
||||
r.country,
|
||||
r.status_code
|
||||
r.status_code,
|
||||
r.owner_user_id
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -508,6 +509,243 @@ 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::*;
|
||||
|
||||
@@ -47,6 +47,19 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
|
||||
Ok(tags)
|
||||
}
|
||||
|
||||
/// Get the total count of URLs associated with a specific tag name.
|
||||
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
|
||||
let tag_name = tag.trim().to_lowercase();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM urls u
|
||||
JOIN url_tags ut ON u.id = ut.url_id
|
||||
JOIN tags t ON ut.tag_id = t.id
|
||||
WHERE t.name = ?1;",
|
||||
params![tag_name],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// The full column list used in all URL SELECT queries.
|
||||
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
|
||||
|
||||
|
||||
+189
-5
@@ -111,10 +111,11 @@ pub fn print_migration_plan(
|
||||
// Migration definitions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
pub const ADMIN_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
@@ -156,7 +157,26 @@ pub const ADMIN_MIGRATIONS: &[Migration] = &[Migration {
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "remove_api_keys_fk",
|
||||
sql: r#"
|
||||
CREATE TABLE api_keys_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
key_hash TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_used_at TEXT
|
||||
);
|
||||
INSERT INTO api_keys_new (id, user_id, key_hash, name, created_at, last_used_at)
|
||||
SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys;
|
||||
DROP TABLE api_keys;
|
||||
ALTER TABLE api_keys_new RENAME TO api_keys;
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const CONTENT_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
@@ -315,6 +335,11 @@ pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_qr_access_ts ON qr_access_log(timestamp);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "add_owner_user_id",
|
||||
sql: "ALTER TABLE visits ADD COLUMN owner_user_id INTEGER;",
|
||||
},
|
||||
];
|
||||
|
||||
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
@@ -384,4 +409,163 @@ pub const SYSTEM_MIGRATIONS: &[Migration] = &[
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_events(action);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "global_slugs_and_moderation",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_user_id INTEGER NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
deleted_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_owner ON global_slugs(owner_user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_status ON global_slugs(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_slugs_target ON global_slugs(target_type, target_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS moderation_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
timestamp TEXT NOT NULL,
|
||||
admin_username TEXT NOT NULL,
|
||||
target_user_id INTEGER NOT NULL,
|
||||
target_username TEXT,
|
||||
resource_type TEXT NOT NULL,
|
||||
resource_identifier TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
severity TEXT NOT NULL,
|
||||
reason TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS slug_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
slug TEXT NOT NULL,
|
||||
old_owner_user_id INTEGER,
|
||||
new_owner_user_id INTEGER,
|
||||
action TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
admin_username TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reserved_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
reason TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Seed defaults
|
||||
INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (3, datetime('now'));
|
||||
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('soft_delete_retention_days', '30');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('quota_reconcile_interval_hours', '24');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('allow_registration', 'false');
|
||||
INSERT OR IGNORE INTO settings (key, value) VALUES ('maintenance_mode', 'false');
|
||||
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('admin', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('login', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('logout', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('dashboard', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('api', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('docs', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('assets', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('static', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('favicon.ico', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('robots.txt', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('health', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('metrics', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('install', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('setup', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('support', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('help', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('security', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('abuse', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('billing', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('status', 'System route');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('legacy_admin', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('administrator', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('system', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('root', 'System reserved');
|
||||
INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES ('www', 'System reserved');
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
pub const USERS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
created_at TEXT NOT NULL,
|
||||
last_login TEXT,
|
||||
account_type TEXT DEFAULT 'standard',
|
||||
organization_id INTEGER NULL,
|
||||
metadata TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS quotas (
|
||||
user_id INTEGER PRIMARY KEY,
|
||||
max_urls INTEGER DEFAULT 100,
|
||||
max_landings INTEGER DEFAULT 10,
|
||||
max_api_tokens INTEGER DEFAULT 5,
|
||||
max_storage_mb INTEGER DEFAULT 100,
|
||||
current_urls INTEGER DEFAULT 0,
|
||||
current_landings INTEGER DEFAULT 0,
|
||||
current_api_tokens INTEGER DEFAULT 0,
|
||||
current_storage_mb INTEGER DEFAULT 0,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
token_hash TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS username_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
old_username TEXT NOT NULL,
|
||||
new_username TEXT NOT NULL,
|
||||
changed_at TEXT NOT NULL,
|
||||
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "repair_admin_account_type",
|
||||
sql: r#"
|
||||
UPDATE users
|
||||
SET account_type = 'admin'
|
||||
WHERE username = 'admin' AND account_type = 'standard';
|
||||
"#,
|
||||
},
|
||||
];
|
||||
+377
-46
@@ -1,6 +1,7 @@
|
||||
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;
|
||||
@@ -15,6 +16,7 @@ pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod sqlite;
|
||||
pub mod users;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
@@ -22,70 +24,88 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// Pre-migration detection of admin account repair
|
||||
let repair_needed = {
|
||||
let stmt = users_conn.prepare(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'standard');"
|
||||
);
|
||||
match stmt {
|
||||
Ok(mut s) => s
|
||||
.query_row([], |row| row.get::<_, bool>(0))
|
||||
.unwrap_or(false),
|
||||
Err(_) => false,
|
||||
}
|
||||
};
|
||||
|
||||
// Run migrations for admin.db and users.db
|
||||
info!("Running admin migrations");
|
||||
run_migrations(
|
||||
&mut admin_conn,
|
||||
@@ -93,14 +113,131 @@ impl Db {
|
||||
ADMIN_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
info!("Running content migrations");
|
||||
info!("Running users migrations");
|
||||
run_migrations(
|
||||
&mut users_conn,
|
||||
"users",
|
||||
USERS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
// Post-migration: audit log if repaired
|
||||
if repair_needed {
|
||||
let admin_is_now_admin: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin' AND account_type = 'admin');",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if admin_is_now_admin {
|
||||
let system_conn = system_arc.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"admin",
|
||||
"migration_repair",
|
||||
"users",
|
||||
"admin",
|
||||
Some("Repaired standard account type to admin"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up expired sessions from users.db on startup
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
|
||||
|
||||
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
|
||||
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
|
||||
|
||||
// Ensure legacy_admin (user ID 1) exists in users.db
|
||||
let legacy_admin_id = 1i64;
|
||||
let legacy_admin_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[legacy_admin_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !legacy_admin_exists {
|
||||
// Get copied administrator password hash
|
||||
let admin_password_hash: String = admin_conn
|
||||
.query_row(
|
||||
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
// If admin_db is empty, hash a default password
|
||||
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
|
||||
});
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
legacy_admin_id,
|
||||
"legacy_admin",
|
||||
admin_password_hash,
|
||||
"disabled",
|
||||
now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
|
||||
// Seed quotas
|
||||
users_conn.execute(
|
||||
"INSERT INTO quotas (user_id) VALUES (?1);",
|
||||
[legacy_admin_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
|
||||
fs::create_dir_all(&legacy_user_dir)?;
|
||||
|
||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
||||
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),
|
||||
)?;
|
||||
info!("Running analytics migrations");
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
@@ -108,26 +245,220 @@ impl Db {
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
Ok(Self {
|
||||
// If we just migrated legacy content, populate the global_slugs table in system.db
|
||||
if legacy_migration_needed {
|
||||
info!("Populating global slug index with legacy content...");
|
||||
let mut sys_lock = system_arc.lock().unwrap();
|
||||
let tx = sys_lock.transaction()?;
|
||||
|
||||
// Extract urls from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt =
|
||||
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract landing pages from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt = content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
info!("Global slug index populated successfully.");
|
||||
}
|
||||
|
||||
let db = Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
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);
|
||||
|
||||
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(())
|
||||
}
|
||||
|
||||
+545
@@ -0,0 +1,545 @@
|
||||
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
// --- User Operations ---
|
||||
|
||||
pub fn is_reserved_username(username: &str) -> bool {
|
||||
let u = username.trim().to_lowercase();
|
||||
u == "admin" || u == "legacy_admin" || u == "administrator" || u == "system" || u == "root"
|
||||
}
|
||||
|
||||
pub fn create_admin_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
) -> rusqlite::Result<TenantUser> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
let account_type = "admin";
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
|
||||
params![username, password_hash, status, created_at, account_type],
|
||||
)?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
// Seed default quotas
|
||||
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
|
||||
|
||||
Ok(TenantUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
password_hash: password_hash.to_string(),
|
||||
status: status.to_string(),
|
||||
created_at,
|
||||
last_login: None,
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_user(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
password_hash: &str,
|
||||
account_type: &str,
|
||||
metadata: Option<&str>,
|
||||
) -> rusqlite::Result<TenantUser> {
|
||||
if is_reserved_username(username) {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Username is reserved".to_string()),
|
||||
));
|
||||
}
|
||||
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
params![
|
||||
username,
|
||||
password_hash,
|
||||
status,
|
||||
created_at,
|
||||
account_type,
|
||||
metadata
|
||||
],
|
||||
)?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
// Seed default quotas
|
||||
conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", params![id])?;
|
||||
|
||||
Ok(TenantUser {
|
||||
id,
|
||||
username: username.to_string(),
|
||||
password_hash: password_hash.to_string(),
|
||||
status: status.to_string(),
|
||||
created_at,
|
||||
last_login: None,
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: metadata.map(|s| s.to_string()),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1;",
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn get_user_by_username(
|
||||
conn: &Connection,
|
||||
username: &str,
|
||||
) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
params![username],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn delete_user(conn: &Connection, id: i64) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM users WHERE id = ?1;", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_user_status(conn: &Connection, id: i64, status: &str) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE users SET status = ?1 WHERE id = ?2;",
|
||||
params![status, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_user_account_type(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
account_type: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE users SET account_type = ?1 WHERE id = ?2;",
|
||||
params![account_type, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reset_user_password(
|
||||
conn: &Connection,
|
||||
id: i64,
|
||||
new_password_hash: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE users SET password_hash = ?1 WHERE id = ?2;",
|
||||
params![new_password_hash, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"UPDATE users SET last_login = ?1 WHERE id = ?2;",
|
||||
params![now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users ORDER BY username ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut users = Vec::new();
|
||||
for u in rows {
|
||||
users.push(u?);
|
||||
}
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
pub fn log_username_change(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
old_username: &str,
|
||||
new_username: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO username_history (user_id, old_username, new_username, changed_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![user_id, old_username, new_username, now],
|
||||
)?;
|
||||
conn.execute(
|
||||
"UPDATE users SET username = ?1 WHERE id = ?2;",
|
||||
params![new_username, user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Session Operations ---
|
||||
|
||||
pub fn create_user_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
user_id: i64,
|
||||
expires_at_rfc3339: &str,
|
||||
) -> rusqlite::Result<UserSession> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
params![session_id, user_id, expires_at_rfc3339, created_at],
|
||||
)?;
|
||||
Ok(UserSession {
|
||||
id: session_id.to_string(),
|
||||
user_id,
|
||||
expires_at: expires_at_rfc3339.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_session(
|
||||
conn: &Connection,
|
||||
session_id: &str,
|
||||
) -> rusqlite::Result<Option<UserSession>> {
|
||||
conn.query_row(
|
||||
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
|
||||
params![session_id],
|
||||
|row| {
|
||||
Ok(UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn delete_user_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
|
||||
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cleanup_expired_user_sessions(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
// --- Quota Operations ---
|
||||
|
||||
pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Option<UserQuotas>> {
|
||||
conn.query_row(
|
||||
"SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb,
|
||||
current_urls, current_landings, current_api_tokens, current_storage_mb
|
||||
FROM quotas WHERE user_id = ?1;",
|
||||
params![user_id],
|
||||
|row| {
|
||||
Ok(UserQuotas {
|
||||
user_id: row.get(0)?,
|
||||
max_urls: row.get(1)?,
|
||||
max_landings: row.get(2)?,
|
||||
max_api_tokens: row.get(3)?,
|
||||
max_storage_mb: row.get(4)?,
|
||||
current_urls: row.get(5)?,
|
||||
current_landings: row.get(6)?,
|
||||
current_api_tokens: row.get(7)?,
|
||||
current_storage_mb: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn update_user_quotas(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
max_urls: i64,
|
||||
max_landings: i64,
|
||||
max_api_tokens: i64,
|
||||
max_storage_mb: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE quotas SET max_urls = ?1, max_landings = ?2, max_api_tokens = ?3, max_storage_mb = ?4
|
||||
WHERE user_id = ?5;",
|
||||
params![max_urls, max_landings, max_api_tokens, max_storage_mb, user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn increment_quota_counter(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
field: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let sql = match field {
|
||||
"urls" => "UPDATE quotas SET current_urls = current_urls + 1 WHERE user_id = ?1;",
|
||||
"landings" => {
|
||||
"UPDATE quotas SET current_landings = current_landings + 1 WHERE user_id = ?1;"
|
||||
}
|
||||
"api_tokens" => {
|
||||
"UPDATE quotas SET current_api_tokens = current_api_tokens + 1 WHERE user_id = ?1;"
|
||||
}
|
||||
_ => return Err(rusqlite::Error::InvalidQuery),
|
||||
};
|
||||
conn.execute(sql, params![user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decrement_quota_counter(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
field: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let sql = match field {
|
||||
"urls" => "UPDATE quotas SET current_urls = MAX(0, current_urls - 1) WHERE user_id = ?1;",
|
||||
"landings" => "UPDATE quotas SET current_landings = MAX(0, current_landings - 1) WHERE user_id = ?1;",
|
||||
"api_tokens" => "UPDATE quotas SET current_api_tokens = MAX(0, current_api_tokens - 1) WHERE user_id = ?1;",
|
||||
_ => return Err(rusqlite::Error::InvalidQuery),
|
||||
};
|
||||
conn.execute(sql, params![user_id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_quota_storage(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
storage_mb: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
conn.execute(
|
||||
"UPDATE quotas SET current_storage_mb = ?1 WHERE user_id = ?2;",
|
||||
params![storage_mb, user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- API Token Operations ---
|
||||
|
||||
pub fn create_user_api_token(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
token_hash: &str,
|
||||
) -> rusqlite::Result<UserApiToken> {
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
conn.execute(
|
||||
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
|
||||
params![user_id, token_hash, created_at],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
|
||||
// Increment api token counter
|
||||
let _ = increment_quota_counter(conn, user_id, "api_tokens");
|
||||
|
||||
Ok(UserApiToken {
|
||||
id,
|
||||
user_id,
|
||||
token_hash: token_hash.to_string(),
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_user_api_tokens(
|
||||
conn: &Connection,
|
||||
user_id: i64,
|
||||
) -> rusqlite::Result<Vec<UserApiToken>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1 ORDER BY id DESC;",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![user_id], |row| {
|
||||
Ok(UserApiToken {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
token_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
let mut tokens = Vec::new();
|
||||
for t in rows {
|
||||
tokens.push(t?);
|
||||
}
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqlite::Result<()> {
|
||||
let deleted = conn.execute(
|
||||
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
|
||||
params![id, user_id],
|
||||
)?;
|
||||
if deleted > 0 {
|
||||
let _ = decrement_quota_counter(conn, user_id, "api_tokens");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// --- Global Slug & Quota Reconciliation Helpers ---
|
||||
|
||||
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
// 1. Check reserved list
|
||||
let reserved: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if reserved {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// 2. Check global slugs
|
||||
let exists: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1);",
|
||||
[slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
Ok(!exists)
|
||||
}
|
||||
|
||||
pub fn register_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_user_id: i64,
|
||||
target_type: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"],
|
||||
)?;
|
||||
|
||||
// Insert history
|
||||
system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
|
||||
VALUES (?1, NULL, ?2, 'created', ?3);",
|
||||
rusqlite::params![slug, owner_user_id, now],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn release_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_user_id: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [slug])?;
|
||||
|
||||
// Insert history
|
||||
system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
|
||||
VALUES (?1, ?2, NULL, 'released', ?3);",
|
||||
rusqlite::params![slug, owner_user_id, now],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn soft_delete_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_user_id: i64,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
system_conn.execute(
|
||||
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![now, slug],
|
||||
)?;
|
||||
|
||||
// Insert history
|
||||
system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3);",
|
||||
rusqlite::params![slug, owner_user_id, now],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn reconcile_user_quotas(
|
||||
users_conn: &Connection,
|
||||
user_id: i64,
|
||||
content_conn: &Connection,
|
||||
) -> rusqlite::Result<()> {
|
||||
let urls_count: i64 = content_conn
|
||||
.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let landings_count: i64 = content_conn
|
||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
let api_tokens_count: i64 = users_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM api_tokens WHERE user_id = ?1;",
|
||||
[user_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
users_conn.execute(
|
||||
"UPDATE quotas SET current_urls = ?1, current_landings = ?2, current_api_tokens = ?3 WHERE user_id = ?4;",
|
||||
rusqlite::params![urls_count, landings_count, api_tokens_count, user_id],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
+32
-15
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
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;
|
||||
}
|
||||
|
||||
+38
-6
@@ -51,6 +51,36 @@ pub async fn perform_backup(
|
||||
std::fs::create_dir_all(&out_dir)?;
|
||||
}
|
||||
|
||||
// Force checkpoint on all databases to flush WAL contents to the main DB files
|
||||
if let Ok(conn) = db.admin.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.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);
|
||||
@@ -59,12 +89,14 @@ pub async fn perform_backup(
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
|
||||
for f in files {
|
||||
let db_file = config.data_dir.join(f);
|
||||
if db_file.exists() {
|
||||
tar.append_path_with_name(&db_file, f)?;
|
||||
}
|
||||
let admin_dir = 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()?;
|
||||
|
||||
+24
-5
@@ -10,13 +10,32 @@ pub async fn run_expiry_checker(db: Db) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
|
||||
let count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::expire_urls(&conn).unwrap_or(0)
|
||||
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()
|
||||
};
|
||||
|
||||
if count > 0 {
|
||||
info!(expired_count = count, "Expired URLs marked");
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+40
-30
@@ -37,40 +37,50 @@ pub async fn perform_link_check(
|
||||
db: &Db,
|
||||
client: &Client,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let urls = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::list_urls_for_health_check(&conn)?
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id FROM users;")?;
|
||||
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for (id, dest) in urls {
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
for user_id in user_ids {
|
||||
let conn = match super::open_user_content_conn(db, user_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
let urls = crate::db::content::list_urls_for_health_check(&conn)?;
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for (id, dest) in urls {
|
||||
let (status, detail_status, status_code, latency_ms, err_msg) =
|
||||
check_url_health(client, &dest).await;
|
||||
{
|
||||
crate::db::content::update_url_health_extended(
|
||||
&conn,
|
||||
&id,
|
||||
&status,
|
||||
&detail_status,
|
||||
Some(latency_ms),
|
||||
)?;
|
||||
}
|
||||
|
||||
// Log to system.db.health_checks
|
||||
{
|
||||
let sys_conn = db.system.lock().unwrap();
|
||||
let hc_id = Uuid::new_v4().to_string();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let is_healthy = if status == "healthy" { 1 } else { 0 };
|
||||
let _ = sys_conn.execute(
|
||||
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting sleep between external requests
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db::Db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection};
|
||||
use std::sync::Mutex;
|
||||
@@ -35,3 +36,38 @@ pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Op
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub mod quota_reconcile;
|
||||
pub use quota_reconcile::run_quota_reconciliation;
|
||||
|
||||
// --- Database Connection Helpers for User-specific Databases ---
|
||||
|
||||
pub fn open_user_content_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
pub fn open_user_analytics_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("analytics.db");
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
|
||||
Ok(conn)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
||||
loop {
|
||||
// Sleep first
|
||||
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
||||
info!("Running background quota reconciliation...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
error!("Failed to prepare select user IDs: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
error!("Failed to query user IDs: {:?}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
for user_id in user_ids {
|
||||
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
|
||||
if let Err(e) =
|
||||
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
|
||||
{
|
||||
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
info!("Quota reconciliation finished.");
|
||||
}
|
||||
}
|
||||
+42
-10
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+50
@@ -44,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(())
|
||||
|
||||
+4
-1
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
|
||||
pub use audit::AuditLog;
|
||||
pub use page::LandingPage;
|
||||
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
|
||||
pub use user::{Session, User};
|
||||
pub use user::{
|
||||
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
|
||||
UserQuotas, UserSession, UsernameHistory,
|
||||
};
|
||||
pub use visit::{SummaryEntry, VisitRecord};
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -2,15 +2,25 @@ 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;
|
||||
|
||||
#[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 {
|
||||
pub admin_db: Arc<Mutex<Connection>>,
|
||||
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,106 @@
|
||||
use crate::models::{LandingPage, Url};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct VisitorLogEntry {
|
||||
pub sr: usize,
|
||||
pub timestamp: String,
|
||||
pub ip_address: String,
|
||||
pub country: String,
|
||||
pub referrer: String,
|
||||
pub browser: String,
|
||||
pub user_agent: String,
|
||||
pub utm_source: String,
|
||||
pub utm_campaign: String,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "url_analytics.html")]
|
||||
pub struct UrlAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub url: Url,
|
||||
pub total_clicks: i64,
|
||||
pub unique_visitors: i64,
|
||||
pub qr_scans: i64,
|
||||
pub direct_clicks: i64,
|
||||
pub traffic_chart: String,
|
||||
pub monthly_chart: String,
|
||||
pub countries_chart: String,
|
||||
pub referrers_chart: String,
|
||||
pub browsers_chart: String,
|
||||
// Paginated visitor logs
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
pub total_records: i64,
|
||||
pub page_start: usize,
|
||||
pub page_end: usize,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
}
|
||||
|
||||
impl UrlAnalyticsTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UrlAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "page_analytics.html")]
|
||||
pub struct PageAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub page: LandingPage,
|
||||
pub total_views: i64,
|
||||
pub unique_visitors: i64,
|
||||
pub traffic_chart: String,
|
||||
pub monthly_chart: String,
|
||||
pub countries_chart: String,
|
||||
pub referrers_chart: String,
|
||||
// Paginated visitor logs
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
pub total_records: i64,
|
||||
pub page_start: usize,
|
||||
pub page_end: usize,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
}
|
||||
|
||||
impl PageAnalyticsTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for PageAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
+345
-6
@@ -1,26 +1,41 @@
|
||||
pub mod analytics;
|
||||
pub mod dashboard;
|
||||
pub mod pages;
|
||||
pub mod settings;
|
||||
pub mod stats;
|
||||
pub mod urls;
|
||||
pub mod user_dashboard;
|
||||
pub mod user_pages;
|
||||
pub mod user_settings;
|
||||
pub mod user_urls;
|
||||
pub mod users;
|
||||
|
||||
pub use dashboard::DashboardTemplate;
|
||||
pub use pages::PagesTemplate;
|
||||
pub use settings::SettingsTemplate;
|
||||
pub use stats::{AuditTemplate, StatusTemplate};
|
||||
pub use urls::UrlsTemplate;
|
||||
|
||||
pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
pub use dashboard::DashboardTemplate;
|
||||
pub use pages::PagesTemplate;
|
||||
pub use settings::SettingsTemplate;
|
||||
pub use stats::{AuditTemplate, StatusTemplate, UserAuditTemplate, UserStatusTemplate};
|
||||
pub use urls::UrlsTemplate;
|
||||
pub use user_dashboard::UserDashboardTemplate;
|
||||
pub use user_pages::UserPagesTemplate;
|
||||
pub use user_settings::UserSettingsTemplate;
|
||||
pub use user_urls::UserUrlsTemplate;
|
||||
pub use users::UsersTemplate;
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "login.html")]
|
||||
pub struct LoginTemplate {
|
||||
pub error: Option<String>,
|
||||
pub csrf_token: String,
|
||||
pub action: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub button_text: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for LoginTemplate {
|
||||
@@ -79,3 +94,327 @@ impl IntoResponse for PreviewTemplate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users_new.html")]
|
||||
pub struct UsersNewTemplate {
|
||||
pub admin_username: String,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UsersNewTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_detail.html")]
|
||||
pub struct UserDetailTemplate {
|
||||
pub admin_username: String,
|
||||
pub target_user: crate::models::TenantUser,
|
||||
pub stats: crate::web::admin::UserDetailStats,
|
||||
pub sessions: Vec<crate::models::UserSession>,
|
||||
pub tokens: Vec<crate::models::UserApiToken>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserDetailTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_edit.html")]
|
||||
pub struct UserEditTemplate {
|
||||
pub admin_username: String,
|
||||
pub target_user: crate::models::TenantUser,
|
||||
pub quotas: crate::models::UserQuotas,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserEditTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "moderation.html")]
|
||||
pub struct ModerationTemplate {
|
||||
pub admin_username: String,
|
||||
pub flagged_items: Vec<crate::web::admin::GlobalSlugRow>,
|
||||
pub logs: Vec<crate::web::admin::ModerationLogEntry>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ModerationTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "slugs.html")]
|
||||
pub struct SlugsTemplate {
|
||||
pub admin_username: String,
|
||||
pub slugs: Vec<crate::web::admin::GlobalSlugRow>,
|
||||
pub history: Vec<crate::web::admin::SlugHistoryRow>,
|
||||
pub csrf_token: String,
|
||||
pub search_filter: Option<String>,
|
||||
pub owner_filter: Option<i64>,
|
||||
pub status_filter: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for SlugsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "sessions.html")]
|
||||
pub struct SessionsTemplate {
|
||||
pub admin_username: String,
|
||||
pub sessions: Vec<crate::models::UserSession>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for SessionsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "quotas.html")]
|
||||
pub struct QuotasTemplate {
|
||||
pub admin_username: String,
|
||||
pub quotas: Vec<crate::models::UserQuotas>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for QuotasTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "health.html")]
|
||||
pub struct HealthTemplate {
|
||||
pub admin_username: String,
|
||||
pub db_reports: Vec<crate::db::sqlite::DatabaseHealthReport>,
|
||||
pub total_data_size: String,
|
||||
pub system_db_size: String,
|
||||
pub users_db_size: String,
|
||||
pub admin_db_size: String,
|
||||
pub tenants_db_size: String,
|
||||
pub job_history: Vec<crate::web::admin::JobHistoryRow>,
|
||||
pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for HealthTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "backups.html")]
|
||||
pub struct BackupsTemplate {
|
||||
pub admin_username: String,
|
||||
pub files: Vec<crate::web::admin::BackupFileRow>,
|
||||
pub history: Vec<crate::web::admin::BackupHistoryRow>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for BackupsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "api_tokens.html")]
|
||||
pub struct ApiTokensTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub tokens: Vec<crate::models::UserApiToken>,
|
||||
pub new_token: Option<String>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for ApiTokensTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_analytics.html")]
|
||||
pub struct UserAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub total_clicks: i64,
|
||||
pub unique_visitors: i64,
|
||||
pub direct_clicks: i64,
|
||||
pub referred_clicks: i64,
|
||||
pub referrers_chart: String,
|
||||
pub browsers_chart: String,
|
||||
pub visits: Vec<crate::models::VisitRecord>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_url_analytics.html")]
|
||||
pub struct UserUrlAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub url_code: String,
|
||||
pub destination: String,
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserUrlAnalyticsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_page_analytics.html")]
|
||||
pub struct UserPageAnalyticsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub page_code: String,
|
||||
pub title: String,
|
||||
pub visits: Vec<VisitorLogEntry>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserPageAnalyticsTemplate {
|
||||
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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,15 @@ pub struct PagesTemplate {
|
||||
pub pages: Vec<LandingPage>,
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
impl PagesTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for PagesTemplate {
|
||||
|
||||
@@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_status.html")]
|
||||
pub struct UserStatusTemplate {
|
||||
pub admin_username: String,
|
||||
pub app_status: &'static str,
|
||||
pub db_status: String,
|
||||
pub queue_size: usize,
|
||||
pub memory_usage: String,
|
||||
pub uptime: String,
|
||||
pub version: &'static str,
|
||||
pub git_commit: &'static str,
|
||||
pub urls: Vec<crate::models::Url>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_audit.html")]
|
||||
pub struct UserAuditTemplate {
|
||||
pub admin_username: String,
|
||||
pub logs: Vec<AuditLog>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserStatusTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UserAuditTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,15 @@ pub struct UrlsTemplate {
|
||||
pub error: Option<String>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub base_url: String,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UrlsTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UrlsTemplate {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_dashboard.html")]
|
||||
pub struct UserDashboardTemplate {
|
||||
pub admin_username: String,
|
||||
pub total_urls: i64,
|
||||
pub total_pages: i64,
|
||||
pub total_clicks: i64,
|
||||
pub active_links: i64,
|
||||
pub dead_links: i64,
|
||||
pub traffic_chart: String,
|
||||
pub countries_chart: String,
|
||||
pub browsers_chart: String,
|
||||
pub referrers_chart: String,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserDashboardTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::models::LandingPage;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_pages.html")]
|
||||
pub struct UserPagesTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub pages: Vec<LandingPage>,
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UserPagesTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UserPagesTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_settings.html")]
|
||||
pub struct UserSettingsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UserSettingsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
use crate::models::Url;
|
||||
use askama::Template;
|
||||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "user_urls.html")]
|
||||
pub struct UserUrlsTemplate {
|
||||
pub admin_username: String,
|
||||
pub username: String,
|
||||
pub urls: Vec<Url>,
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub tag_filter: Option<String>,
|
||||
pub base_url: String,
|
||||
pub current_page: usize,
|
||||
pub total_pages: usize,
|
||||
pub visible_pages: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UserUrlsTemplate {
|
||||
pub fn is_current(&self, page: &usize) -> bool {
|
||||
*page == self.current_page
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for UserUrlsTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
use crate::models::TenantUser;
|
||||
use askama::Template;
|
||||
use axum::response::{Html, IntoResponse, Response};
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "users.html")]
|
||||
pub struct UsersTemplate {
|
||||
pub admin_username: String,
|
||||
pub users: Vec<TenantUser>,
|
||||
pub csrf_token: String,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl IntoResponse for UsersTemplate {
|
||||
fn into_response(self) -> Response {
|
||||
match self.render() {
|
||||
Ok(html) => Html(html).into_response(),
|
||||
Err(e) => (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Render error: {}", e),
|
||||
)
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod network;
|
||||
pub mod random;
|
||||
pub mod system;
|
||||
pub mod time;
|
||||
pub mod validation;
|
||||
|
||||
pub use hashing::sha256_hash;
|
||||
pub use network::get_client_ip;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
pub fn validate_custom_slug(slug: &str) -> bool {
|
||||
if !slug.starts_with('!') {
|
||||
return false;
|
||||
}
|
||||
let rest = &slug[1..];
|
||||
if rest.is_empty() || rest.len() > 24 {
|
||||
return false;
|
||||
}
|
||||
rest.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
|
||||
}
|
||||
|
||||
pub fn validate_redirect_code(code: &str) -> bool {
|
||||
(code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||
}
|
||||
|
||||
pub fn validate_page_code(code: &str) -> bool {
|
||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||
}
|
||||
+4871
-93
File diff suppressed because it is too large.
Load diff
+52
-19
@@ -33,6 +33,9 @@ pub struct CreateUrlRequest {
|
||||
pub expires_at: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub max_access_count: Option<i64>,
|
||||
pub utm_source: Option<String>,
|
||||
pub utm_medium: Option<String>,
|
||||
pub utm_campaign: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -84,17 +87,47 @@ pub async fn api_create_url(
|
||||
if code.is_empty() {
|
||||
code = generate_token(3); // 6 hex
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Short code must be 6 hex characters".to_string(),
|
||||
error: "Short code must be 6 hex characters or a custom slug starting with !"
|
||||
.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let mut dest = payload.destination.trim().to_string();
|
||||
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
||||
let mut has_utm = false;
|
||||
{
|
||||
let mut query = parsed.query_pairs_mut();
|
||||
if let Some(ref src) = payload.utm_source {
|
||||
if !src.trim().is_empty() {
|
||||
query.append_pair("utm_source", src.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref med) = payload.utm_medium {
|
||||
if !med.trim().is_empty() {
|
||||
query.append_pair("utm_medium", med.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref camp) = payload.utm_campaign {
|
||||
if !camp.trim().is_empty() {
|
||||
query.append_pair("utm_campaign", camp.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_utm {
|
||||
dest = parsed.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = payload.password {
|
||||
if pwd.is_empty() {
|
||||
None
|
||||
@@ -121,7 +154,7 @@ pub async fn api_create_url(
|
||||
match crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&payload.destination,
|
||||
&dest,
|
||||
payload.title.as_deref(),
|
||||
payload.description.as_deref(),
|
||||
&tags,
|
||||
@@ -134,7 +167,7 @@ pub async fn api_create_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
@@ -147,7 +180,7 @@ pub async fn api_create_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_CREATION",
|
||||
"url",
|
||||
&url.id,
|
||||
@@ -281,7 +314,7 @@ pub async fn api_update_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -294,7 +327,7 @@ pub async fn api_update_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_UPDATE",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -336,7 +369,7 @@ pub async fn api_delete_url(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&uuid),
|
||||
@@ -349,7 +382,7 @@ pub async fn api_delete_url(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"URL_DELETION",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -390,11 +423,11 @@ pub async fn api_create_page(
|
||||
if code.is_empty() {
|
||||
code = generate_token(2); // 4 hex
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !crate::utils::validation::validate_page_code(&code) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Short code must be 4 hex characters".to_string(),
|
||||
error: "Short code must be 4 hex characters or start with ! followed by 1-24 characters of a-z, 0-9, -, _".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -415,7 +448,7 @@ pub async fn api_create_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
@@ -516,7 +549,7 @@ pub async fn api_update_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_UPDATE",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -557,7 +590,7 @@ pub async fn api_delete_page(
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&uuid),
|
||||
@@ -856,7 +889,7 @@ pub async fn api_set_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -943,7 +976,7 @@ pub async fn api_delete_preview(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"DELETE_PREVIEW",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1018,7 +1051,7 @@ pub async fn api_set_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"SET_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1076,7 +1109,7 @@ pub async fn api_remove_password(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"REMOVE_PASSWORD",
|
||||
"url",
|
||||
&uuid,
|
||||
@@ -1144,7 +1177,7 @@ pub async fn api_create_qr(
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"CREATE_QR",
|
||||
"qr_code",
|
||||
&payload.url_id,
|
||||
|
||||
+2
-2
@@ -120,7 +120,7 @@ pub async fn api_bulk_qr(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_QR_EXPORT",
|
||||
"bulk",
|
||||
"qr",
|
||||
@@ -271,7 +271,7 @@ pub async fn api_bulk_url(
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.0.username,
|
||||
user.0.username(),
|
||||
"BULK_URL_CREATION",
|
||||
"bulk",
|
||||
"url",
|
||||
|
||||
@@ -2,6 +2,7 @@ pub mod admin;
|
||||
pub mod api;
|
||||
pub mod bulk;
|
||||
pub mod middleware;
|
||||
pub mod multi_user;
|
||||
pub mod pages;
|
||||
pub mod password_gate;
|
||||
pub mod qr;
|
||||
|
||||
@@ -0,0 +1,991 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ApiUser;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateUserRequest {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub account_type: Option<String>,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct UserResponse {
|
||||
pub id: i64,
|
||||
pub username: String,
|
||||
pub status: String,
|
||||
pub account_type: String,
|
||||
pub created_at: String,
|
||||
pub metadata: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UpdateUserStatusRequest {
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct UpdateUserQuotasRequest {
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ResetPasswordRequest {
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct TransferSlugRequest {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ModerateSlugRequest {
|
||||
pub slug: String,
|
||||
pub action: String, // 'flagged', 'disabled', 'active'
|
||||
pub severity: String, // 'low', 'medium', 'high', 'critical'
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ModerationEventResponse {
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub admin_username: String,
|
||||
pub target_user_id: i64,
|
||||
pub target_username: Option<String>,
|
||||
pub resource_type: String,
|
||||
pub resource_identifier: String,
|
||||
pub action: String,
|
||||
pub severity: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct ChangeOwnPasswordRequest {
|
||||
pub old_password: String,
|
||||
pub new_password: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
pub struct CreateApiTokenRequest {
|
||||
// No request body needed, token is generated securely
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct CreateApiTokenResponse {
|
||||
pub id: i64,
|
||||
pub token: String, // Cleartext token returned once
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// Helper: Ensure the request actor is an Admin
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> {
|
||||
match &user.0 {
|
||||
ApiActor::Admin(admin) => Ok(admin),
|
||||
_ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: User CRUD Endpoints ---
|
||||
|
||||
// GET /api/v1/admin/users
|
||||
pub async fn admin_list_users(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
if let Err(err_resp) = require_admin_role(&user) {
|
||||
return err_resp;
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::list_users(&conn) {
|
||||
Ok(users) => {
|
||||
let resp: Vec<UserResponse> = users
|
||||
.into_iter()
|
||||
.map(|u| UserResponse {
|
||||
id: u.id,
|
||||
username: u.username,
|
||||
status: u.status,
|
||||
account_type: u.account_type,
|
||||
created_at: u.created_at,
|
||||
metadata: u.metadata,
|
||||
})
|
||||
.collect();
|
||||
Json(resp).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users
|
||||
pub async fn admin_create_user(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateUserRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// Username validation: minimum 3 chars, alphanumeric, hyphen, underscore
|
||||
let username = payload.username.trim().to_lowercase();
|
||||
if username.len() < 3 {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must be at least 3 characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Username must contain only alphanumeric characters, hyphens, or underscores",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Hash password
|
||||
let hash = match crate::auth::password::hash_password(&payload.password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let account_type = payload.account_type.as_deref().unwrap_or("standard");
|
||||
match crate::db::users::create_user(
|
||||
&conn,
|
||||
&username,
|
||||
&hash,
|
||||
account_type,
|
||||
payload.metadata.as_deref(),
|
||||
) {
|
||||
Ok(new_user) => {
|
||||
// Write system audit event
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_CREATION",
|
||||
"user",
|
||||
&new_user.id.to_string(),
|
||||
Some(&format!("Username: {}", new_user.username)),
|
||||
);
|
||||
}
|
||||
|
||||
Json(UserResponse {
|
||||
id: new_user.id,
|
||||
username: new_user.username,
|
||||
status: new_user.status,
|
||||
account_type: new_user.account_type,
|
||||
created_at: new_user.created_at,
|
||||
metadata: new_user.metadata,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
(StatusCode::CONFLICT, "Username already exists").into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/:id/status
|
||||
pub async fn admin_update_user_status(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserStatusRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let status = payload.status.trim().to_lowercase();
|
||||
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid user status").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_status(&conn, target_id, &status) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_STATUS_UPDATE",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!("New Status: {}", status)),
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/v1/admin/users/:id/quotas
|
||||
pub async fn admin_update_user_quotas(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<UpdateUserQuotasRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_quotas(
|
||||
&conn,
|
||||
target_id,
|
||||
payload.max_urls,
|
||||
payload.max_landings,
|
||||
payload.max_api_tokens,
|
||||
payload.max_storage_mb,
|
||||
) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_QUOTA_UPDATE",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!(
|
||||
"max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}",
|
||||
payload.max_urls,
|
||||
payload.max_landings,
|
||||
payload.max_api_tokens,
|
||||
payload.max_storage_mb
|
||||
)),
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/admin/users/:id/password
|
||||
pub async fn admin_reset_user_password(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
Json(payload): Json<ResetPasswordRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let hash = match crate::auth::password::hash_password(&payload.password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&conn, target_id, &hash) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"USER_PASSWORD_RESET",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_user_resources(
|
||||
state: &AppState,
|
||||
target_id: i64,
|
||||
admin_username: &str,
|
||||
force: bool,
|
||||
) -> Result<(), String> {
|
||||
if target_id == 1 && !force {
|
||||
return Err("Deleting legacy_admin system account requires force flag".to_string());
|
||||
}
|
||||
|
||||
let user_details = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::get_user_by_id(&conn, target_id) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return Err("User not found".to_string()),
|
||||
Err(e) => return Err(e.to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
|
||||
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([target_id], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to release slugs: {}", e))?;
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_id.to_string());
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?;
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
admin_username,
|
||||
"USER_DELETION",
|
||||
"user",
|
||||
&target_id.to_string(),
|
||||
Some(&format!("Username: {}", user_details.username)),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// DELETE /api/v1/admin/users/:id
|
||||
pub async fn admin_delete_user(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(target_id): Path<i64>,
|
||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let force = params.get("force").map(|v| v == "true").unwrap_or(false);
|
||||
match delete_user_resources(&state, target_id, &admin.username, force) {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(),
|
||||
Err(err) if err == "Deleting legacy_admin system account requires force flag" => {
|
||||
(StatusCode::BAD_REQUEST, err).into_response()
|
||||
}
|
||||
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- Admin: Slug Transfer ---
|
||||
|
||||
// POST /api/v1/admin/transfers
|
||||
pub async fn admin_transfer_slug(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<TransferSlugRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// 1. Check if the slug exists and get details
|
||||
let (old_owner_user_id, target_type, _target_id) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let row_opt = stmt
|
||||
.query_row([&payload.slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional();
|
||||
|
||||
match row_opt {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner_user_id == payload.new_owner_user_id {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner must be different from the current owner",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Fetch destination databases
|
||||
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load current owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load new owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
|
||||
let mut new_target_id = String::new();
|
||||
let transfer_success = {
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
// Get URL record
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_url) => {
|
||||
new_target_id = new_url.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy URL to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
// Get page record
|
||||
let page_opt =
|
||||
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_page) => {
|
||||
new_target_id = new_page.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy Page to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !transfer_success {
|
||||
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
|
||||
}
|
||||
|
||||
// 4. Update system global_slugs, slug_history and adjust quotas
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug],
|
||||
);
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
|
||||
);
|
||||
|
||||
// Adjust quotas
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let field = if target_type == "url" {
|
||||
"urls"
|
||||
} else {
|
||||
"landings"
|
||||
};
|
||||
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
payload.new_owner_user_id,
|
||||
field,
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, payload.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// --- Admin: Content Moderation ---
|
||||
|
||||
// POST /api/v1/admin/moderation
|
||||
pub async fn admin_moderate_slug(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<ModerateSlugRequest>,
|
||||
) -> Response {
|
||||
let admin = match require_admin_role(&user) {
|
||||
Ok(a) => a,
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
let action = payload.action.trim().to_lowercase();
|
||||
if !["flagged", "disabled", "active"].contains(&action.as_str()) {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
// 1. Verify slug and get owner user ID
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&payload.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve owner username for log snapshot
|
||||
let owner_username = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
};
|
||||
|
||||
// 2. Perform moderation update in global_slugs
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
|
||||
// Record moderation event
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
now,
|
||||
admin.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
payload.slug,
|
||||
action,
|
||||
payload.severity,
|
||||
payload.reason
|
||||
],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"CONTENT_MODERATION",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!("Action: {}, Reason: {}", action, payload.reason)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// GET /api/v1/admin/moderation/events
|
||||
pub async fn admin_list_moderation_events(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
) -> Response {
|
||||
if let Err(err_resp) = require_admin_role(&user) {
|
||||
return err_resp;
|
||||
}
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason
|
||||
FROM moderation_events ORDER BY timestamp DESC;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(ModerationEventResponse {
|
||||
id: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
admin_username: row.get(2)?,
|
||||
target_user_id: row.get(3)?,
|
||||
target_username: row.get(4)?,
|
||||
resource_type: row.get(5)?,
|
||||
resource_identifier: row.get(6)?,
|
||||
action: row.get(7)?,
|
||||
severity: row.get(8)?,
|
||||
reason: row.get(9)?,
|
||||
})
|
||||
});
|
||||
|
||||
match rows {
|
||||
Ok(mapped) => {
|
||||
let events: Vec<ModerationEventResponse> = mapped.filter_map(|r| r.ok()).collect();
|
||||
Json(events).into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// --- User: Dashboard, profile settings, API tokens ---
|
||||
|
||||
// GET /api/v1/user/profile
|
||||
pub async fn user_get_profile(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Profile endpoints are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
|
||||
|
||||
Json(serde_json::json!({
|
||||
"id": tenant_user.id,
|
||||
"username": tenant_user.username,
|
||||
"status": tenant_user.status,
|
||||
"account_type": tenant_user.account_type,
|
||||
"created_at": tenant_user.created_at,
|
||||
"metadata": tenant_user.metadata,
|
||||
"quotas": quotas,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /api/v1/user/password
|
||||
pub async fn user_change_password(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Json(payload): Json<ChangeOwnPasswordRequest>,
|
||||
) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Change password is for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Verify old password
|
||||
if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) {
|
||||
return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response();
|
||||
}
|
||||
|
||||
// Hash new password
|
||||
let hash = match crate::auth::password::hash_password(&payload.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Hashing error: {}", e),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"PASSWORD_CHANGE",
|
||||
"user",
|
||||
&tenant_user.id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/v1/user/api-tokens
|
||||
pub async fn user_list_api_tokens(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) {
|
||||
Ok(tokens) => Json(tokens).into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/v1/user/api-tokens
|
||||
pub async fn user_create_api_token(State(state): State<AppState>, user: ApiUser) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Quota check
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
|
||||
if let Some(quota) = quotas {
|
||||
if quota.current_api_tokens >= quota.max_api_tokens {
|
||||
return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Generate secure token
|
||||
let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars
|
||||
|
||||
// Hash token using SHA-256 for storing
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(token_secret.as_bytes());
|
||||
let token_hash = hex::encode(hasher.finalize());
|
||||
|
||||
match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) {
|
||||
Ok(api_token) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"API_TOKEN_CREATION",
|
||||
"api_token",
|
||||
&api_token.id.to_string(),
|
||||
None,
|
||||
);
|
||||
|
||||
Json(CreateApiTokenResponse {
|
||||
id: api_token.id,
|
||||
token: token_secret, // Return cleartext once
|
||||
created_at: api_token.created_at,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/v1/user/api-tokens/:id
|
||||
pub async fn user_delete_api_token(
|
||||
State(state): State<AppState>,
|
||||
user: ApiUser,
|
||||
Path(token_id): Path<i64>,
|
||||
) -> Response {
|
||||
let tenant_user = match user.0 {
|
||||
ApiActor::User(u) => u,
|
||||
ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"API tokens are for tenant users only",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) {
|
||||
Ok(_) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&tenant_user.username,
|
||||
"API_TOKEN_DELETION",
|
||||
"api_token",
|
||||
&token_id.to_string(),
|
||||
None,
|
||||
);
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
}
|
||||
+64
-4
@@ -4,12 +4,12 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::landing_pages::get_landing_page_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
@@ -21,15 +21,62 @@ pub async fn resolve_page(
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !crate::utils::validation::validate_page_code(&code) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let page_opt = match get_landing_page_by_code(&state.db, &code) {
|
||||
Ok(page) => page,
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "page".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_landing_page_by_code(&conn, &code) {
|
||||
Ok(page) => page,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
match page_opt {
|
||||
Some(page) => {
|
||||
// Check state
|
||||
@@ -67,6 +114,7 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
@@ -121,3 +169,15 @@ pub async fn root_landing() -> Response {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn deploy_script() -> Response {
|
||||
match tokio::fs::read("www/deploy.sh").await {
|
||||
Ok(content) => (
|
||||
StatusCode::OK,
|
||||
[("content-type", "text/plain; charset=utf-8")],
|
||||
content,
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
|
||||
}
|
||||
}
|
||||
+86
-11
@@ -1,5 +1,4 @@
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use axum::{
|
||||
@@ -11,6 +10,7 @@ use std::net::SocketAddr;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
|
||||
pub async fn qr_handler(
|
||||
State(state): State<AppState>,
|
||||
@@ -24,9 +24,10 @@ pub async fn qr_handler(
|
||||
let auth_header = headers.get("Authorization").and_then(|h| h.to_str().ok());
|
||||
|
||||
let authenticated = if let Some(auth) = auth_header {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
matches!(
|
||||
crate::auth::session::authenticate_api_key(&conn, auth),
|
||||
crate::auth::session::authenticate_api_key(&admin_conn, &users_conn, auth),
|
||||
Ok(Some(_user))
|
||||
)
|
||||
} else {
|
||||
@@ -37,23 +38,63 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &file) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &file) {
|
||||
Ok(u) => u,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;",
|
||||
rusqlite::params![url.id],
|
||||
@@ -72,15 +113,49 @@ pub async fn qr_handler(
|
||||
let code = parts[0];
|
||||
let ext = parts[1].to_lowercase();
|
||||
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
if !crate::utils::validation::validate_redirect_code(code) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, code) {
|
||||
Ok(u) => u,
|
||||
// We need to look up owner_user_id and status from global_slugs
|
||||
let (owner_user_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn
|
||||
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, status))) => (uid, status),
|
||||
Ok(None) => (1, "active".to_string()), // fallback to admin
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(u) => u,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
@@ -144,7 +219,7 @@ pub async fn qr_handler(
|
||||
.map(|s| s.to_string());
|
||||
|
||||
{
|
||||
let analytics_conn = state.db.analytics.lock().unwrap();
|
||||
let analytics_conn = user_dbs.analytics.lock().unwrap();
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&url.id,
|
||||
|
||||
+61
-13
@@ -5,12 +5,12 @@ use axum::{
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -24,22 +24,69 @@ pub async fn resolve_redirect(
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
) -> Response {
|
||||
// Basic validation of code (must be 6 hex characters)
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
// Basic validation of code (must be 6 hex characters or a valid custom slug)
|
||||
if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
|
||||
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 2. Get user specific database connections
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
};
|
||||
|
||||
// 1. Expiration check
|
||||
// 3. Expiration check
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
@@ -49,7 +96,7 @@ pub async fn resolve_redirect(
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
@@ -60,7 +107,7 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Access limit check
|
||||
// 4. Access limit check
|
||||
if url.is_access_exhausted() {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
@@ -69,7 +116,7 @@ pub async fn resolve_redirect(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// 3. Password protection check
|
||||
// 5. Password protection check
|
||||
if url.is_password_protected() {
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let authorized = jar
|
||||
@@ -82,14 +129,14 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Increment access count & retrieve preview config
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = state.db.content.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -123,12 +170,13 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
// Push to memory queue (non-blocking)
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
// 5. Render Preview or Redirect
|
||||
// 7. Render Preview or Redirect
|
||||
if let Some(preview) = preview_opt {
|
||||
PreviewTemplate {
|
||||
code,
|
||||
|
||||
+160
-2
@@ -1,7 +1,7 @@
|
||||
use crate::state::AppState;
|
||||
use crate::web::{admin, api, bulk, pages, password_gate, qr, redirect, system};
|
||||
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
|
||||
use axum::{
|
||||
routing::{get, post},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,46 @@ pub fn create_router(state: AppState) -> Router {
|
||||
// --- System Health & Diagnostics ---
|
||||
.route("/status", get(system::status_endpoint))
|
||||
.route("/metrics", get(system::metrics_endpoint))
|
||||
// --- Public User Login ---
|
||||
.route(
|
||||
"/login",
|
||||
get(admin::public_login_get).post(admin::public_login_post),
|
||||
)
|
||||
.route("/logout", get(admin::public_logout))
|
||||
.route("/user/dashboard", get(admin::user_dashboard_get))
|
||||
.route("/user/urls", get(admin::user_urls_get))
|
||||
.route("/user/urls/create", post(admin::user_urls_create))
|
||||
.route("/user/urls/delete/:id", post(admin::user_urls_delete))
|
||||
.route("/user/audit", get(admin::user_audit_get))
|
||||
.route("/user/status", get(admin::user_status_get))
|
||||
.route("/user/pages", get(admin::user_pages_get))
|
||||
.route("/user/pages/create", post(admin::user_pages_create))
|
||||
.route("/user/pages/delete/:id", post(admin::user_pages_delete))
|
||||
.route("/user/settings", get(admin::user_settings_get))
|
||||
.route(
|
||||
"/user/settings/password",
|
||||
post(admin::user_change_password_post),
|
||||
)
|
||||
.route("/user/settings/backup", get(admin::user_download_backup))
|
||||
.route(
|
||||
"/user/settings/restore",
|
||||
post(admin::user_restore_backup_post),
|
||||
)
|
||||
.route("/analytics", get(admin::user_analytics_get))
|
||||
.route(
|
||||
"/user/analytics/url/:id",
|
||||
get(admin::user_url_analytics_get),
|
||||
)
|
||||
.route(
|
||||
"/user/analytics/page/:id",
|
||||
get(admin::user_page_analytics_get),
|
||||
)
|
||||
.route("/api-tokens", get(admin::api_tokens_get))
|
||||
.route("/api-tokens/create", post(admin::api_tokens_create_post))
|
||||
.route(
|
||||
"/api-tokens/revoke/:id",
|
||||
post(admin::api_tokens_revoke_post),
|
||||
)
|
||||
// --- Admin UI Login/Logout ---
|
||||
.route("/admin", get(admin::admin_index))
|
||||
.route(
|
||||
@@ -37,7 +77,44 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages", get(admin::pages_get))
|
||||
.route("/admin/pages/create", post(admin::pages_create))
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
|
||||
.route("/deploy.sh", get(pages::deploy_script))
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/csv",
|
||||
get(admin::url_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/json",
|
||||
get(admin::url_analytics_json_export),
|
||||
)
|
||||
.route("/admin/analytics/page/:id", get(admin::page_analytics_get))
|
||||
.route(
|
||||
"/admin/analytics/page/:id/export/csv",
|
||||
get(admin::page_analytics_csv_export),
|
||||
)
|
||||
.route(
|
||||
"/admin/analytics/page/:id/export/json",
|
||||
get(admin::page_analytics_json_export),
|
||||
)
|
||||
.route("/admin/settings", get(admin::settings_get))
|
||||
.route("/admin/users", get(admin::users_get))
|
||||
.route("/admin/users/new", get(admin::users_new_get))
|
||||
.route("/admin/users/:id", get(admin::user_detail_get))
|
||||
.route(
|
||||
"/admin/users/:id/edit",
|
||||
get(admin::user_edit_get).post(admin::user_edit_post),
|
||||
)
|
||||
.route("/admin/users/create", post(admin::users_create_post))
|
||||
.route(
|
||||
"/admin/users/status/:id",
|
||||
post(admin::users_update_status_post),
|
||||
)
|
||||
.route("/admin/users/type/:id", post(admin::users_update_type_post))
|
||||
.route(
|
||||
"/admin/users/password/:id",
|
||||
post(admin::users_reset_password_post),
|
||||
)
|
||||
.route("/admin/users/delete/:id", post(admin::users_delete_post))
|
||||
.route(
|
||||
"/admin/settings/password",
|
||||
post(admin::change_password_post),
|
||||
@@ -48,6 +125,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/admin/settings/compact", post(admin::compact_db_post))
|
||||
.route("/admin/settings/backup", get(admin::download_backup))
|
||||
.route("/admin/settings/restore", post(admin::restore_backup_post))
|
||||
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
|
||||
.route(
|
||||
"/admin/settings/api-keys/create",
|
||||
@@ -59,6 +137,39 @@ pub fn create_router(state: AppState) -> Router {
|
||||
)
|
||||
.route("/admin/audit", get(admin::audit_get))
|
||||
.route("/admin/status", get(admin::status_get))
|
||||
.route(
|
||||
"/admin/moderation",
|
||||
get(admin::moderation_get).post(admin::moderation_post),
|
||||
)
|
||||
.route("/admin/slugs", get(admin::slugs_get))
|
||||
.route("/admin/slugs/transfer", post(admin::slugs_transfer_post))
|
||||
.route("/admin/slugs/status", post(admin::slugs_status_post))
|
||||
.route("/admin/slugs/delete", post(admin::slugs_delete_post))
|
||||
.route("/admin/sessions", get(admin::sessions_get))
|
||||
.route(
|
||||
"/admin/sessions/revoke/:id",
|
||||
post(admin::sessions_revoke_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/sessions/revoke-all",
|
||||
post(admin::sessions_revoke_all_post),
|
||||
)
|
||||
.route(
|
||||
"/admin/quotas",
|
||||
get(admin::quotas_get).post(admin::quotas_post),
|
||||
)
|
||||
.route("/admin/health", get(admin::health_get))
|
||||
.route("/admin/backups", get(admin::backups_get))
|
||||
.route("/admin/backups/create", post(admin::backups_create_post))
|
||||
.route(
|
||||
"/admin/backups/download/:filename",
|
||||
get(admin::backups_download_get),
|
||||
)
|
||||
.route(
|
||||
"/admin/backups/delete/:filename",
|
||||
post(admin::backups_delete_post),
|
||||
)
|
||||
.route("/admin/backups/restore", post(admin::backups_restore_post))
|
||||
// --- REST API v1 JSON Endpoints ---
|
||||
.route(
|
||||
"/api/v1/urls",
|
||||
@@ -104,6 +215,53 @@ pub fn create_router(state: AppState) -> Router {
|
||||
"/api/v1/urls/:uuid/password",
|
||||
post(api::api_set_password).delete(api::api_remove_password),
|
||||
)
|
||||
// --- Multi-User REST API v1 Admin Endpoints ---
|
||||
.route(
|
||||
"/api/v1/admin/users",
|
||||
get(multi_user::admin_list_users).post(multi_user::admin_create_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/status",
|
||||
put(multi_user::admin_update_user_status),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/quotas",
|
||||
put(multi_user::admin_update_user_quotas),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id/password",
|
||||
post(multi_user::admin_reset_user_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/users/:id",
|
||||
delete(multi_user::admin_delete_user),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/transfers",
|
||||
post(multi_user::admin_transfer_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation",
|
||||
post(multi_user::admin_moderate_slug),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/admin/moderation/events",
|
||||
get(multi_user::admin_list_moderation_events),
|
||||
)
|
||||
// --- Multi-User REST API v1 Tenant User Endpoints ---
|
||||
.route("/api/v1/user/profile", get(multi_user::user_get_profile))
|
||||
.route(
|
||||
"/api/v1/user/password",
|
||||
post(multi_user::user_change_password),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens",
|
||||
get(multi_user::user_list_api_tokens).post(multi_user::user_create_api_token),
|
||||
)
|
||||
.route(
|
||||
"/api/v1/user/api-tokens/:id",
|
||||
delete(multi_user::user_delete_api_token),
|
||||
)
|
||||
// --- Static Asset Stub ---
|
||||
.route(
|
||||
"/static/style.css",
|
||||
|
||||
+6
-5
@@ -6,7 +6,7 @@ use axum::{
|
||||
use axum_extra::extract::CookieJar;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::auth::{authenticate_api_key, authenticate_session};
|
||||
use crate::auth::{authenticate_admin_session, authenticate_api_key};
|
||||
use crate::db::admin::get_user_count;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
@@ -15,15 +15,16 @@ use crate::utils::{get_db_file_info, get_memory_usage};
|
||||
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
|
||||
// 1. Try Authorization header
|
||||
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) {
|
||||
let admin_conn = state.admin_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_api_key(&admin_conn, &users_conn, auth_header) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try cookie session
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_session(&conn, jar) {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
if let Ok(Some(_)) = authenticate_admin_session(&conn, jar) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}My API Tokens - BZOD{% endblock %}
|
||||
|
||||
{% block active_tokens %}active{% endblock %}
|
||||
|
||||
{% block header_title %}API Tokens & Automation{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Token Card -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Generate API Token</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
|
||||
API tokens allow you to automate link shortening and landing page creations. Generated tokens are hashed immediately; you will only be shown the raw token once.
|
||||
</p>
|
||||
|
||||
{% if let Some(raw_token) = new_token %}
|
||||
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); padding: 1rem; border-radius: 8px; margin-bottom: 1.25rem; word-break: break-all;">
|
||||
<span style="display: block; font-size: 0.75rem; color: var(--success-color); font-weight: 700; margin-bottom: 0.25rem; text-transform: uppercase;">Raw Token (Copy now!)</span>
|
||||
<code style="font-size: 1.1rem; color: var(--text-primary); font-family: monospace; font-weight: 600;">{{ raw_token }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/api-tokens/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn" style="width: 100%;">Generate New Token</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Active Tokens List -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Active API Tokens</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Token ID</th>
|
||||
<th>Created At</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if tokens.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No active API tokens generated yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td>{{ t.id }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
|
||||
<td>
|
||||
<form action="/api-tokens/revoke/{{ t.id }}" method="POST" onsubmit="return confirm('Revoke this API token? Any applications using it will be blocked.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,144 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Backup Management - BZOD{% endblock %}
|
||||
|
||||
{% block active_settings %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Database Backups Console{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<form action="/admin/backups/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn">Create Backup Archive</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- List of Backup Files -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Available Backup Archives</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Archive File</th>
|
||||
<th>File Size</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if files.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No backup archive files found in backups folder.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for f in files %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong style="font-family: monospace;">{{ f.filename }}</strong>
|
||||
</td>
|
||||
<td>{{ f.size_str }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ f.created_str }}</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<a href="/admin/backups/download/{{ f.filename }}" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Download</a>
|
||||
|
||||
<form action="/admin/backups/delete/{{ f.filename }}" method="POST" style="margin: 0;" onsubmit="return confirm('Delete backup file {{ f.filename }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Restore Database Panel -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.15rem; margin-bottom: 1rem; color: var(--danger-color);">Restore Platform Databases</h3>
|
||||
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
|
||||
To restore the system databases, select a `.tar.gz` backup archive file. Warning: This will overwrite all active user accounts, quotas, links, and analytics data!
|
||||
</p>
|
||||
|
||||
<form action="/admin/backups/restore" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="backup_file">Upload Backup File (.tar.gz)</label>
|
||||
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirm_text">Type RESTORE to continue</label>
|
||||
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger" style="width: 100%;">Upload & Restore Now</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup History Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden; margin-top: 1.5rem;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Backup Audit History</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Backup File Path</th>
|
||||
<th>Status</th>
|
||||
<th>Size</th>
|
||||
<th>Error Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if history.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No backup execution logs found.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for h in history %}
|
||||
<tr>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ h.created_at[0..19].replace("T", " ") }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ h.backup_path }}</td>
|
||||
<td>
|
||||
<span class="badge {% if h.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
|
||||
{{ h.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ h.size_bytes }} B</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ h.error_message.as_deref().unwrap_or("-") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,171 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}System Health & Diagnostics - BZOD{% endblock %}
|
||||
|
||||
{% block active_status %}active{% endblock %}
|
||||
|
||||
{% block header_title %}System Health Dashboard{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
||||
<!-- DB Health Report -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
Database Structural Health
|
||||
</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Database File</th>
|
||||
<th>Schema Ver.</th>
|
||||
<th>Journal Mode</th>
|
||||
<th>FK State</th>
|
||||
<th>Integrity Check</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for db in db_reports %}
|
||||
<tr>
|
||||
<td><strong>{{ db.database }}.db</strong></td>
|
||||
<td>v{{ db.schema_version }}</td>
|
||||
<td><span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ db.journal_mode }}</span></td>
|
||||
<td>{% if db.foreign_keys_enabled %}ON{% else %}OFF{% endif %}</td>
|
||||
<td>
|
||||
{% if db.integrity_ok %}
|
||||
<span class="badge badge-healthy">Passed</span>
|
||||
{% else %}
|
||||
<span class="badge badge-dead">Corrupt/Failed</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Utilization -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/></svg>
|
||||
Storage Utilization
|
||||
</h3>
|
||||
</div>
|
||||
<div style="padding: 1.5rem; display: flex; flex-direction: column; gap: 1rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||
<span>Total Data Directory Size:</span>
|
||||
<strong>{{ total_data_size }}</strong>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||
<span>System Database size (`system.db`):</span>
|
||||
<span>{{ system_db_size }}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||
<span>Users/Quotas Database size (`users.db`):</span>
|
||||
<span>{{ users_db_size }}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color); padding-bottom: 0.5rem;">
|
||||
<span>Administration database (`admin.db`):</span>
|
||||
<span>{{ admin_db_size }}</span>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span>Standard Tenants Databases (`/users/*`):</span>
|
||||
<span>{{ tenants_db_size }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Job Execution Status -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
|
||||
Maintenance & Reconcile Jobs
|
||||
</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job Name</th>
|
||||
<th>Last Run Status</th>
|
||||
<th>Started At</th>
|
||||
<th>Error Msg</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if job_history.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No job history logs.</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for job in job_history %}
|
||||
<tr>
|
||||
<td><strong>{{ job.job_name }}</strong></td>
|
||||
<td>
|
||||
<span class="badge {% if job.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ job.started_at[0..19].replace("T", " ") }}</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary); max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
{{ job.error_message.as_deref().unwrap_or("-") }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Health Check Diagnostics -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Diagnostics Checks (URLs/Pages)
|
||||
</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Resource</th>
|
||||
<th>Status</th>
|
||||
<th>Code</th>
|
||||
<th>Checked At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if health_checks.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No resource diagnostics logs.</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for check in health_checks %}
|
||||
<tr>
|
||||
<td><span style="font-family: monospace;">{{ check.object_type }}:{{ check.object_id }}</span></td>
|
||||
<td>
|
||||
<span class="badge {% if check.is_healthy == 1 %}badge-healthy{% else %}badge-dead{% endif %}">
|
||||
{% if check.is_healthy == 1 %}healthy{% else %}unhealthy{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ check.status_code.unwrap_or(0) }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ check.checked_at[0..16].replace("T", " ") }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -378,6 +378,7 @@
|
||||
</div>
|
||||
|
||||
<ul class="nav-links">
|
||||
{% block sidebar_links %}
|
||||
<li class="{% block active_dashboard %}{% endblock %}">
|
||||
<a href="/admin/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
@@ -396,6 +397,12 @@
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_users %}{% endblock %}">
|
||||
<a href="/admin/users">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
|
||||
Users Management
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_settings %}{% endblock %}">
|
||||
<a href="/admin/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
@@ -414,8 +421,10 @@
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
</ul>
|
||||
|
||||
{% block sidebar_footer %}
|
||||
<div class="sidebar-footer">
|
||||
<div class="admin-user-info">
|
||||
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||
@@ -423,6 +432,7 @@
|
||||
</div>
|
||||
<a href="/admin/logout" class="logout-btn">Log Out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Main Workspace -->
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Login - BZOD Platform</title>
|
||||
<title>{{ title }} - BZOD Platform</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-base: #070a13;
|
||||
@@ -143,9 +143,9 @@
|
||||
|
||||
<div class="login-card">
|
||||
<div class="logo">
|
||||
BZOD <span class="logo-dot"></span>
|
||||
{{ title }} <span class="logo-dot"></span>
|
||||
</div>
|
||||
<p class="subtitle">Personal Redirects & Landing Pages</p>
|
||||
<p class="subtitle">{{ subtitle }}</p>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert-error">
|
||||
@@ -153,7 +153,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form action="/admin/login" method="POST">
|
||||
<form action="{{ action }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
@@ -166,7 +166,7 @@
|
||||
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Sign In</button>
|
||||
<button type="submit" class="btn">{{ button_text }}</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Content Moderation - BZOD{% endblock %}
|
||||
|
||||
{% block active_moderation %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Content Moderation Panel{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
||||
<!-- Moderation Form -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Moderate a Resource</h3>
|
||||
<form action="/admin/moderation" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="slug">Resource Slug</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. !hello or abcdef" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="action">Moderation Action</label>
|
||||
<select id="action" name="action" class="form-input">
|
||||
<option value="active">Activate (Publicly accessible)</option>
|
||||
<option value="flagged">Flag (Flagged, visible to admins)</option>
|
||||
<option value="disabled">Disable (Returns 410 Gone)</option>
|
||||
<option value="deleted">Delete (Release slug completely)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="severity">Severity Level</label>
|
||||
<select id="severity" name="severity" class="form-input">
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high" selected>High</option>
|
||||
<option value="critical">Critical</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="reason">Reason / Notes</label>
|
||||
<textarea id="reason" name="reason" class="form-input" rows="3" placeholder="Violation detail, abuse report summary..." required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Apply Action</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Active Flags & Disabled Resources -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Currently Flagged or Disabled Content</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>Owner ID</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Updated At</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if flagged_items.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No resources are currently flagged or disabled.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for item in flagged_items %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong style="color: var(--accent-color);">/{{ item.slug }}</strong>
|
||||
</td>
|
||||
<td>{{ item.owner_user_id }}</td>
|
||||
<td>{{ item.target_type }}</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {% if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ item.updated_at[0..10] }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Moderation Events Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Moderation Action Log</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Operator</th>
|
||||
<th>Target User</th>
|
||||
<th>Resource</th>
|
||||
<th>Action</th>
|
||||
<th>Severity</th>
|
||||
<th>Reason</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if logs.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No moderation actions recorded.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ log.timestamp[0..19].replace("T", " ") }}</td>
|
||||
<td><strong>{{ log.admin_username }}</strong></td>
|
||||
<td>ID: {{ log.target_user_id }}</td>
|
||||
<td><span style="font-family: monospace;">{{ log.resource_type }}:{{ log.resource_identifier }}</span></td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ log.action }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {% if log.severity == "critical" %}rgba(239,68,68,0.2){% else %}rgba(255,255,255,0.05){% endif %}; color: {% if log.severity == "critical" %}var(--danger-color){% else %}var(--text-secondary){% endif %}; font-weight: 700;">
|
||||
{{ log.severity }}
|
||||
</span>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.reason }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,223 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Landing Page Analytics - {{ page.slug }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_pages %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/admin/analytics/page/{{ page.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export CSV
|
||||
</a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export JSON
|
||||
</a>
|
||||
<a href="/admin/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Landing Pages
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Date Filter Form -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<form method="GET" action="/admin/analytics/page/{{ page.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_to" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date To</label>
|
||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Page Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Page Details
|
||||
</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Title</span>
|
||||
<span style="font-weight: 500;">{{ page.title }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Publish State</span>
|
||||
<span class="badge badge-{{ page.state }}">{{ page.state }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Created Date</span>
|
||||
<span>{{ page.created_at[0..10] }} {{ page.created_at[11..16] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats Cards -->
|
||||
<div class="grid-stats" style="margin-bottom: 2rem; grid-template-columns: repeat(2, 1fr);">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Views</span>
|
||||
<span class="stat-val" style="color: #60a5fa;">{{ total_views }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Unique Visitors</span>
|
||||
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Charts (Timeline) -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Daily Page Views (Last 30 Days)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ traffic_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Monthly Page Views
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ monthly_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geographic and Referrers Analysis -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<!-- Countries -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Geographic Analysis (Top Countries)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ countries_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Referrer Channels (Top Referrers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ referrers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="margin-top: 2rem; padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
{{ entry.country }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div style="padding: 1.25rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; gap: 0.75rem;">
|
||||
<div style="font-size: 0.9rem; color: var(--text-secondary);">
|
||||
Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+40
-2
@@ -36,12 +36,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state">
|
||||
@@ -78,6 +83,7 @@
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
@@ -85,7 +91,7 @@
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
@@ -115,6 +121,11 @@
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
@@ -132,6 +143,33 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Quota Management - BZOD{% endblock %}
|
||||
|
||||
{% block active_users %}active{% endblock %}
|
||||
|
||||
{% block header_title %}User Quota Management{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<form action="/admin/quotas" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="reconcile_all">
|
||||
<button type="submit" class="btn">Sync & Reconcile All Quotas</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
<th>URLs (Used/Max)</th>
|
||||
<th>Pages (Used/Max)</th>
|
||||
<th>API Tokens (Used/Max)</th>
|
||||
<th>Storage (Used/Max MB)</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if quotas.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No quotas defined.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for q in quotas %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/admin/users/{{ q.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
|
||||
User ID: {{ q.user_id }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span>{{ q.current_urls }} / {{ q.max_urls }}</span>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ q.urls_pct() }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span>{{ q.current_landings }} / {{ q.max_landings }}</span>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ q.landings_pct() }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span>{{ q.current_api_tokens }} / {{ q.max_api_tokens }}</span>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ q.api_tokens_pct() }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
||||
<span>{{ q.current_storage_mb }} / {{ q.max_storage_mb }} MB</span>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ q.storage_pct() }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/admin/users/{{ q.user_id }}/edit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Edit</a>
|
||||
<form action="/admin/quotas" method="POST" style="margin: 0;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="reconcile">
|
||||
<input type="hidden" name="user_id" value="{{ q.user_id }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Reconcile</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,73 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Active Sessions - BZOD{% endblock %}
|
||||
|
||||
{% block active_sessions %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Session Administration{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<form action="/admin/sessions/revoke-all" method="POST" onsubmit="return confirm('Revoke ALL active user sessions? This will log out everyone including you.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger">Revoke All Sessions</button>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User ID</th>
|
||||
<th>Session ID (Masked)</th>
|
||||
<th>Created At</th>
|
||||
<th>Expires At</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if sessions.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No active sessions found in the system.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for s in sessions %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/admin/users/{{ s.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
|
||||
User ID: {{ s.user_id }}
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{{ s.id[0..6] }}...
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..19].replace("T", " ") }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..19].replace("T", " ") }}</td>
|
||||
<td>
|
||||
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -105,6 +105,27 @@
|
||||
Generates a tarball of admin.db, content.db, and analytics.db.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: 0.5rem;">
|
||||
<form action="/admin/settings/restore" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<label for="backup_file" style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.5rem;">Restore Database from Backup</label>
|
||||
<input type="file" id="backup_file" name="backup_file" class="form-input" style="padding: 0.35rem 0.5rem; margin-bottom: 0.75rem;" required accept=".tar.gz">
|
||||
|
||||
<p style="font-size: 0.8rem; color: #fca5a5; margin-bottom: 0.75rem; line-height: 1.4; font-weight: 500;">
|
||||
Warning: This operation will overwrite all current data.
|
||||
</p>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 0.75rem;">
|
||||
<label for="confirm_text" style="font-size: 0.75rem; color: var(--text-secondary);">Type RESTORE to continue:</label>
|
||||
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger" style="width: 100%; justify-content: center;">
|
||||
Restore Backup
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Global Slug Namespace - BZOD{% endblock %}
|
||||
|
||||
{% block active_slugs %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Global Slug Directory{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Search & Filtering -->
|
||||
<div class="card">
|
||||
<form action="/admin/slugs" method="GET" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; align-items: end;">
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="search">Search Slug</label>
|
||||
<input type="text" id="search" name="search" class="form-input" placeholder="e.g. !hello" value="{% if let Some(s) = search_filter %}{{ s }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="owner">Owner User ID</label>
|
||||
<input type="number" id="owner" name="owner" class="form-input" placeholder="e.g. 1" value="{% if let Some(o) = owner_filter %}{{ o }}{% endif %}">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="margin-bottom: 0;">
|
||||
<label for="status">Status</label>
|
||||
<select id="status" name="status" class="form-input">
|
||||
<option value="" selected>All Statuses</option>
|
||||
<option value="active" {% if let Some(s) = status_filter %}{% if s == "active" %}selected{% endif %}{% endif %}>active</option>
|
||||
<option value="flagged" {% if let Some(s) = status_filter %}{% if s == "flagged" %}selected{% endif %}{% endif %}>flagged</option>
|
||||
<option value="disabled" {% if let Some(s) = status_filter %}{% if s == "disabled" %}selected{% endif %}{% endif %}>disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn">Apply Filters</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Slugs Directory Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 1.5rem;">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Slug</th>
|
||||
<th>Owner (ID)</th>
|
||||
<th>Resource Type</th>
|
||||
<th>Resource ID</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if slugs.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No registered slugs found matching filters.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for item in slugs %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong style="color: var(--accent-color); font-family: monospace; font-size: 1.05rem;">/{{ item.slug }}</strong>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/users/{{ item.owner_user_id }}" style="color: var(--text-primary); text-decoration: underline;">
|
||||
User ID: {{ item.owner_user_id }}
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ item.target_type }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ item.target_id }}</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {% if item.status == "active" %}rgba(16, 185, 129, 0.15){% else if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "active" %}var(--success-color){% else if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
|
||||
{{ item.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<!-- Transfer Form -->
|
||||
<form action="/admin/slugs/transfer" method="POST" style="display: flex; gap: 0.25rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="slug" value="{{ item.slug }}">
|
||||
<input type="number" name="new_owner_user_id" placeholder="New ID" required style="width: 80px; padding: 0.35rem 0.5rem; font-size: 0.85rem;">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Transfer</button>
|
||||
</form>
|
||||
|
||||
<!-- Disable / Enable Form -->
|
||||
{% if item.status == "active" %}
|
||||
<form action="/admin/slugs/status" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="slug" value="{{ item.slug }}">
|
||||
<input type="hidden" name="status" value="disabled">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Disable</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/admin/slugs/status" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="slug" value="{{ item.slug }}">
|
||||
<input type="hidden" name="status" value="active">
|
||||
<button type="submit" class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.85rem; background: rgba(16,185,129,0.1); color: var(--success-color); border: 1px solid rgba(16,185,129,0.2);">Enable</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Form -->
|
||||
<form action="/admin/slugs/delete" method="POST" onsubmit="return confirm('Release slug /{{ item.slug }}? this cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="slug" value="{{ item.slug }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slug History Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.15rem;">Slug Ownership History</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Slug</th>
|
||||
<th>Old Owner</th>
|
||||
<th>New Owner</th>
|
||||
<th>Action</th>
|
||||
<th>Admin</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if history.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No history records logged.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for log in history %}
|
||||
<tr>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.timestamp[0..19].replace("T", " ") }}</td>
|
||||
<td><strong style="font-family: monospace;">/{{ log.slug }}</strong></td>
|
||||
<td>{% if let Some(old_id) = log.old_owner_user_id %}User ID: {{ old_id }}{% else %}-{% endif %}</td>
|
||||
<td>{% if let Some(new_id) = log.new_owner_user_id %}User ID: {{ new_id }}{% else %}-{% endif %}</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ log.action }}</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if let Some(admin) = log.admin_username %}
|
||||
{{ admin }}
|
||||
{% else %}
|
||||
System
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,252 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}URL Analytics - {{ url.code }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<div style="display: flex; gap: 0.5rem;">
|
||||
<a href="/admin/analytics/url/{{ url.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export CSV
|
||||
</a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📥 Export JSON
|
||||
</a>
|
||||
<a href="/admin/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to URLs
|
||||
</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Date Filter Form -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<form method="GET" action="/admin/analytics/url/{{ url.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
|
||||
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
|
||||
<label for="date_to" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date To</label>
|
||||
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
|
||||
</div>
|
||||
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- URL Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path><path d="M13.73 21a2 2 0 0 1-3.46 0"></path></svg>
|
||||
Link Details
|
||||
</h3>
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1.5rem;">
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
|
||||
<a href="{{ url.destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Title</span>
|
||||
<span style="font-weight: 500;">{{ url.title.as_deref().unwrap_or("No Title") }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Created Date</span>
|
||||
<span>{{ url.created_at[0..10] }} {{ url.created_at[11..16] }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Tags</span>
|
||||
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; margin-top: 0.25rem;">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span style="color: var(--text-muted); font-size: 0.85rem;">No tags</span>
|
||||
{% else %}
|
||||
{% for t in url.tags %}
|
||||
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); border: 1px solid var(--border-color);">{{ t }}</span>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div style="margin-top: 1.25rem; border-top: 1px solid var(--border-color); padding-top: 1rem;">
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Description</span>
|
||||
<p style="color: var(--text-secondary); font-size: 0.95rem;">{{ desc }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Overview Stats Cards -->
|
||||
<div class="grid-stats" style="margin-bottom: 2rem;">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Clicks</span>
|
||||
<span class="stat-val" style="color: #60a5fa;">{{ total_clicks }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Unique Visitors</span>
|
||||
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">QR Code scans</span>
|
||||
<span class="stat-val" style="color: #f472b6;">{{ qr_scans }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Direct Clicks</span>
|
||||
<span class="stat-val" style="color: #34d399;">{{ direct_clicks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Traffic Charts (Timeline) -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Daily Click Traffic (Last 30 Days)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ traffic_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Monthly Click Traffic
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ monthly_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geographic, Referrers, and Browsers Analysis -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<!-- Countries -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Geographic Analysis (Top Countries)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ countries_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Referrer Channels (Top Referrers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ referrers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browsers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
|
||||
Browser breakdown (Top Browsers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ browsers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="margin-top: 2rem; padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
{{ entry.country }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div style="padding: 1.25rem 1.5rem; border-top: 1px solid var(--border-color); display: flex; flex-direction: column; align-items: center; gap: 0.75rem;">
|
||||
<div style="font-size: 0.9rem; color: var(--text-secondary);">
|
||||
Showing {{ page_start }}-{{ page_end }} of {{ total_records }} visits
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
+63
-1
@@ -34,6 +34,11 @@
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home (! followed by a-z, 0-9, -, _)" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Title (optional)</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
|
||||
@@ -64,6 +69,30 @@
|
||||
<input type="number" id="max_access_count" name="max_access_count" class="form-input" placeholder="e.g. 10 (auto-expires after N clicks)" min="1">
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="border-top: 1px solid var(--border-color); padding-top: 0.75rem; margin-top: 0.75rem;">
|
||||
<label style="font-weight: 600; font-size: 0.85rem; display: flex; align-items: center; gap: 0.25rem; cursor: pointer;">
|
||||
<input type="checkbox" id="enable_utm" onchange="document.getElementById('utm_fields').style.display = this.checked ? 'flex' : 'none';" style="margin-right: 0.25rem;">
|
||||
Add Campaign Tracking (UTM)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="utm_fields" style="display: none; flex-direction: column; gap: 0.5rem; margin-bottom: 0.75rem;">
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem;">
|
||||
<div class="form-group">
|
||||
<label for="utm_source" style="font-size: 0.75rem;">UTM Source</label>
|
||||
<input type="text" id="utm_source" name="utm_source" placeholder="e.g. bzod" class="form-input" style="padding: 0.35rem 0.5rem;">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="utm_medium" style="font-size: 0.75rem;">UTM Medium</label>
|
||||
<input type="text" id="utm_medium" name="utm_medium" placeholder="e.g. shortlink" class="form-input" style="padding: 0.35rem 0.5rem;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="utm_campaign" style="font-size: 0.75rem;">UTM Campaign</label>
|
||||
<input type="text" id="utm_campaign" name="utm_campaign" placeholder="e.g. office" class="form-input" style="padding: 0.35rem 0.5rem;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -92,6 +121,7 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
@@ -99,7 +129,7 @@
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened URLs registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
@@ -184,6 +214,11 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.created_at[0..10] }}
|
||||
</td>
|
||||
@@ -201,6 +236,33 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
|
||||
{% else %}
|
||||
<a href="/admin/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/admin/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></a>
|
||||
<a href="/admin/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,99 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}My Analytics Dashboard - BZOD{% endblock %}
|
||||
|
||||
{% block active_analytics %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Analytics Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Overview Stats Cards -->
|
||||
<div class="grid-stats" style="margin-bottom: 2rem;">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Link Clicks</span>
|
||||
<span class="stat-val" style="color: #60a5fa;">{{ total_clicks }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Unique Visitors</span>
|
||||
<span class="stat-val" style="color: #c084fc;">{{ unique_visitors }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Direct Traffic</span>
|
||||
<span class="stat-val" style="color: #34d399;">{{ direct_clicks }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Referral Traffic</span>
|
||||
<span class="stat-val" style="color: #f472b6;">{{ referred_clicks }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Geographic, Referrers, and Browsers Analysis -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<!-- Referrers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Referrer Channels (Top Referrers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ referrers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browsers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/></svg>
|
||||
Visitor Browsers
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ browsers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Log Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="font-size: 1.15rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Recent Visitor Access Logs
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Slug</th>
|
||||
<th>IP Address</th>
|
||||
<th>Browser / Platform</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor records recorded yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for visit in visits %}
|
||||
<tr>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ visit.timestamp[0..19].replace("T", " ") }}</td>
|
||||
<td><strong style="color: var(--accent-color);">/{{ visit.target_id }}</strong></td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ visit.ip_address }}</td>
|
||||
<td style="font-size: 0.85rem; max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ visit.user_agent }}</td>
|
||||
<td>{{ visit.country }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ visit.referer }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,71 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}Audit Logs - BZOD{% endblock %}
|
||||
|
||||
{% block active_audit %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Security Audit Logs{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
|
||||
System Action Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
<th>Action</th>
|
||||
<th>Operator (User)</th>
|
||||
<th>Source IP Address</th>
|
||||
<th>Log Details / Context</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if logs.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No audit records logged yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for log in logs %}
|
||||
<tr>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
|
||||
{{ log.timestamp[0..19].replace("T", " ") }}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99,102,241,0.2);">
|
||||
{{ log.action }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span style="font-weight: 600; color: var(--text-primary);">{{ log.username }}</span>
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{{ log.ip_address.as_deref().unwrap_or("Unknown") }}
|
||||
</td>
|
||||
<td style="color: var(--text-secondary); font-size: 0.85rem;">
|
||||
{% if let Some(obj_type) = log.object_type %}
|
||||
<span style="font-weight: 500;">Type:</span> {{ obj_type }}
|
||||
{% endif %}
|
||||
{% if let Some(obj_id) = log.object_id %}
|
||||
| <span style="font-weight: 500;">ID:</span> {{ obj_id }}
|
||||
{% endif %}
|
||||
{% if let Some(ua) = log.user_agent %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">UA: {{ ua }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,80 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}User Dashboard - BZOD{% endblock %}
|
||||
|
||||
{% block active_dashboard %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Dashboard Overview{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Overview Cards -->
|
||||
<div class="grid-stats">
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Short URLs</span>
|
||||
<span class="stat-val" style="color: #60a5fa;">{{ total_urls }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Landing Pages</span>
|
||||
<span class="stat-val" style="color: #c084fc;">{{ total_pages }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Total Visits / Clicks</span>
|
||||
<span class="stat-val" style="color: #34d399;">{{ total_clicks }}</span>
|
||||
</div>
|
||||
<div class="card stat-card">
|
||||
<span class="stat-label">Active / Dead Links</span>
|
||||
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.25rem;">
|
||||
<span class="stat-val" style="color: #10b981;">{{ active_links }}</span>
|
||||
<span style="color: var(--text-muted); font-size: 1.25rem;">/</span>
|
||||
<span style="font-size: 1.25rem; font-weight: 700; color: #ef4444;">{{ dead_links }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Traffic Chart -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
|
||||
Click Traffic Trend (Last 30 Days)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ traffic_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Secondary Charts Grid -->
|
||||
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
|
||||
<!-- Countries -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
||||
Geographic Analysis (Top Countries)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ countries_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Referrers -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Referrer Channels (Top Referrers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ referrers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browsers -->
|
||||
<div class="card" style="grid-column: span 1;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
|
||||
User Agent breakdown (Top Browsers)
|
||||
</h3>
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
|
||||
{{ browsers_chart|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,257 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}User Detail: {{ target_user.username }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_users %}active{% endblock %}
|
||||
|
||||
{% block header_title %}User Detail: {{ target_user.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="/admin/users" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
|
||||
← Back to Users List
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
|
||||
<!-- Profile & Actions -->
|
||||
<div class="card">
|
||||
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">User Profile & Settings</h2>
|
||||
|
||||
<table style="width: 100%; border-collapse: collapse; margin-bottom: 1.5rem;">
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary); width: 35%;">User ID</td>
|
||||
<td>{{ target_user.id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Username</td>
|
||||
<td><strong>{{ target_user.username }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Account Type</td>
|
||||
<td><span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ target_user.account_type }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Account Status</td>
|
||||
<td>
|
||||
<span class="badge" style="background-color: {% if target_user.status == "active" %}rgba(16, 185, 129, 0.15){% else %}rgba(239, 68, 68, 0.15){% endif %}; color: {% if target_user.status == "active" %}var(--success-color){% else %}var(--danger-color){% endif %};">
|
||||
{{ target_user.status }}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Created At</td>
|
||||
<td>{{ target_user.created_at }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Last Login</td>
|
||||
<td>
|
||||
{% if let Some(login) = target_user.last_login %}
|
||||
{{ login }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% if let Some(meta) = target_user.metadata %}
|
||||
<tr>
|
||||
<td style="font-weight: 600; color: var(--text-secondary);">Metadata</td>
|
||||
<td>{{ meta }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Administrative Actions</h3>
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 0.75rem;">
|
||||
<a href="/admin/users/{{ target_user.id }}/edit" class="btn btn-secondary">Edit Settings</a>
|
||||
|
||||
{% if target_user.status == "active" %}
|
||||
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="status" value="disabled">
|
||||
<button type="submit" class="btn btn-danger" style="background: rgba(239, 68, 68, 0.1); color: var(--danger-color); border: 1px solid rgba(239, 68, 68, 0.2);">
|
||||
Disable User
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form action="/admin/users/status/{{ target_user.id }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="status" value="active">
|
||||
<button type="submit" class="btn" style="background: rgba(16, 185, 129, 0.1); color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2);">
|
||||
Enable User
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form action="/admin/users/delete/{{ target_user.id }}" method="POST" onsubmit="return confirm('Permanently delete user {{ target_user.username }}? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger">Delete Account</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="border-top: 1px solid var(--border-color); padding-top: 1.5rem; margin-top: 1.5rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem;">Reset Password</h3>
|
||||
<form action="/admin/users/password/{{ target_user.id }}" method="POST" style="display: flex; gap: 0.75rem; align-items: end;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group" style="margin-bottom: 0; flex-grow: 1;">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8">
|
||||
</div>
|
||||
<button type="submit" class="btn">Update</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quota & Resource Usage -->
|
||||
<div class="card">
|
||||
<h2 style="font-size: 1.25rem; margin-bottom: 1.25rem;">Quota & Storage Usage</h2>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
|
||||
<span>Short URLs Created</span>
|
||||
<span>{{ stats.current_urls }} / {{ stats.max_urls }}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.url_pct }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
|
||||
<span>Landing Pages</span>
|
||||
<span>{{ stats.current_landings }} / {{ stats.max_landings }}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.landing_pct }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
|
||||
<span>API Tokens</span>
|
||||
<span>{{ stats.current_api_tokens }} / {{ stats.max_api_tokens }}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.token_pct }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; font-size: 0.9rem; font-weight: 600; margin-bottom: 0.25rem;">
|
||||
<span>Storage (MB)</span>
|
||||
<span>{{ stats.current_storage_mb }} / {{ stats.max_storage_mb }}</span>
|
||||
</div>
|
||||
<div style="background: rgba(255,255,255,0.05); height: 8px; border-radius: 4px; overflow: hidden;">
|
||||
<div style="background: var(--primary-grad); height: 100%; width: {{ stats.storage_pct }}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background-color: rgba(255, 255, 255, 0.02); padding: 1rem; border-radius: 8px; border: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<strong style="display: block;">Total Clicks/Visits</strong>
|
||||
<span style="color: var(--text-secondary); font-size: 0.85rem;">Combined visitor traffic</span>
|
||||
</div>
|
||||
<span style="font-size: 1.5rem; font-weight: 700; color: var(--accent-color);">{{ stats.total_visits }}</span>
|
||||
</div>
|
||||
|
||||
<form action="/admin/quotas" method="POST" style="margin-top: 0.5rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="hidden" name="action" value="reconcile">
|
||||
<input type="hidden" name="user_id" value="{{ target_user.id }}">
|
||||
<button type="submit" class="btn btn-secondary" style="width: 100%;">Run Quota Reconcile Job</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Sessions & API Tokens -->
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem;">Active Sessions</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if sessions.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No active sessions.</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for s in sessions %}
|
||||
<tr>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..10] }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem;">API Tokens</h3>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Created At</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if tokens.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 2rem;">No API tokens.</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for t in tokens %}
|
||||
<tr>
|
||||
<td>{{ t.id }}</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
|
||||
<td>
|
||||
<form action="/admin/settings/api-keys/revoke/{{ t.id }}" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.3rem 0.6rem; font-size: 0.8rem; color: var(--danger-color);">Revoke</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Edit User: {{ target_user.username }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_users %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Edit User: {{ target_user.username }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="margin-bottom: 1.5rem;">
|
||||
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary" style="padding: 0.5rem 1rem;">
|
||||
← Back to User Detail
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="card" style="max-width: 600px; margin: 0 auto;">
|
||||
<form action="/admin/users/{{ target_user.id }}/edit" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<h2 style="font-size: 1.25rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">Account Settings</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="account_type">Account Type</label>
|
||||
<select id="account_type" name="account_type" class="form-input">
|
||||
<option value="standard" {% if target_user.account_type == "standard" %}selected{% endif %}>Standard</option>
|
||||
<option value="admin" {% if target_user.account_type == "admin" %}selected{% endif %}>Admin</option>
|
||||
<option value="organization" {% if target_user.account_type == "organization" %}selected{% endif %}>Organization</option>
|
||||
<option value="service" {% if target_user.account_type == "service" %}selected{% endif %}>Service</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="metadata">Metadata / Notes</label>
|
||||
<input type="text" id="metadata" name="metadata" class="form-input" value="{% if let Some(m) = target_user.metadata %}{{ m }}{% endif %}" placeholder="Department, team, or notes">
|
||||
</div>
|
||||
|
||||
<h2 style="font-size: 1.25rem; margin-top: 2rem; margin-bottom: 1.5rem; border-bottom: 1px solid var(--border-color); padding-bottom: 0.75rem;">User Resource Quotas</h2>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_urls">Max Short URLs</label>
|
||||
<input type="number" id="max_urls" name="max_urls" class="form-input" value="{{ quotas.max_urls }}" required min="1">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_landings">Max Landing Pages</label>
|
||||
<input type="number" id="max_landings" name="max_landings" class="form-input" value="{{ quotas.max_landings }}" required min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_api_tokens">Max API Tokens</label>
|
||||
<input type="number" id="max_api_tokens" name="max_api_tokens" class="form-input" value="{{ quotas.max_api_tokens }}" required min="0">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="max_storage_mb">Max Storage Limit (MB)</label>
|
||||
<input type="number" id="max_storage_mb" name="max_storage_mb" class="form-input" value="{{ quotas.max_storage_mb }}" required min="1">
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2rem; display: flex; gap: 0.75rem;">
|
||||
<button type="submit" class="btn" style="flex-grow: 1;">Save Configuration</button>
|
||||
<a href="/admin/users/{{ target_user.id }}" class="btn btn-secondary">Cancel</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block sidebar_links %}
|
||||
<li class="{% block active_dashboard %}{% endblock %}">
|
||||
<a href="/user/dashboard">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
|
||||
Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_urls %}{% endblock %}">
|
||||
<a href="/user/urls">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Short URLs
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_pages %}{% endblock %}">
|
||||
<a href="/user/pages">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Pages
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_settings %}{% endblock %}">
|
||||
<a href="/user/settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_audit %}{% endblock %}">
|
||||
<a href="/user/audit">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
|
||||
Audit Log
|
||||
</a>
|
||||
</li>
|
||||
<li class="{% block active_status %}{% endblock %}">
|
||||
<a href="/user/status">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
|
||||
Status
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar_footer %}
|
||||
<div class="sidebar-footer">
|
||||
<div class="admin-user-info">
|
||||
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
|
||||
<span>{{ admin_username }}</span>
|
||||
</div>
|
||||
<a href="/logout" class="logout-btn">Log Out</a>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,87 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_pages %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to Landing Pages
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Landing Page Details
|
||||
</h3>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
|
||||
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available for this landing page.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
{% if entry.country == "Unknown" %}
|
||||
<span style="color: var(--text-muted);">Unknown</span>
|
||||
{% else %}
|
||||
{{ entry.country }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,162 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}My Landing Pages - BZOD{% endblock %}
|
||||
|
||||
{% block active_pages %}active{% endblock %}
|
||||
|
||||
{% block header_title %}My Landing Pages{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New Landing Page
|
||||
</h3>
|
||||
|
||||
<form action="/user/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="html_content">HTML Content *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
My Landing Pages
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,65 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}My Settings - BZOD{% endblock %}
|
||||
|
||||
{% block active_settings %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Account Settings{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Change Password
|
||||
</h3>
|
||||
|
||||
<form action="/user/settings/password" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="current_password">Current Password</label>
|
||||
<input type="password" id="current_password" name="current_password" class="form-input" required autocomplete="current-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="new_password">New Password</label>
|
||||
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<button type="submit" class="btn">Update Password</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
|
||||
Backup & Restore
|
||||
</h3>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1rem;">
|
||||
<a class="btn" href="/user/settings/backup">Download Backup</a>
|
||||
<form action="/user/settings/restore" method="POST" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<div class="form-group">
|
||||
<label for="backup_file">Restore Backup File</label>
|
||||
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirm_text">Type RESTORE to confirm</label>
|
||||
<input type="text" id="confirm_text" name="confirm_text" class="form-input" required autocomplete="off">
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">Restore Backup</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,161 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}System Diagnostics - BZOD{% endblock %}
|
||||
|
||||
{% block active_status %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Server Status & Diagnostics{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
|
||||
<!-- Left Column: Core Performance Metrics -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
|
||||
System Status
|
||||
</h3>
|
||||
|
||||
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
|
||||
<span style="color: var(--text-secondary);">Application Status</span>
|
||||
<span class="badge badge-healthy" style="font-size: 0.85rem;">{{ app_status }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
|
||||
<span style="color: var(--text-secondary);">Uptime</span>
|
||||
<span style="font-weight: 600; font-family: monospace;">{{ uptime }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
|
||||
<span style="color: var(--text-secondary);">Memory Usage</span>
|
||||
<span style="font-weight: 600; font-family: monospace;">{{ memory_usage }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
|
||||
<span style="color: var(--text-secondary);">Analytics Memory Queue Size</span>
|
||||
<span style="font-weight: 600; font-family: monospace; color: {% if queue_size > 100 %}var(--warning-color){% else %}var(--success-color){% endif %};">
|
||||
{{ queue_size }} items
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
|
||||
<span style="color: var(--text-secondary);">Build Version</span>
|
||||
<span style="font-weight: 600; font-family: monospace;">v{{ version }}</span>
|
||||
</div>
|
||||
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<span style="color: var(--text-secondary);">Git Commit Hash</span>
|
||||
<span style="font-weight: 600; font-family: monospace; color: var(--text-muted);">{{ git_commit }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Database Storage info -->
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-2.239 10-5V5c0-2.761-4.477-5-10-5S2 2.239 2 5v12c0 2.761 4.477 5 10 5z"/><path d="M2 5c0 2.761 4.477 5 10 5s10-2.761 10-5"/><path d="M2 11c0 2.761 4.477 5 10 5s10-2.761 10-5"/></svg>
|
||||
Database Status
|
||||
</h3>
|
||||
|
||||
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1.25rem; border: 1px solid rgba(255, 255, 255, 0.03); white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6;">{{ db_status }}</div>
|
||||
|
||||
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 1rem; line-height: 1.4;">
|
||||
All SQLite databases are running with Write-Ahead Logging (WAL) enabled, providing concurrent reads and high transactional throughput.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Full Width: Link Health Dashboard Section -->
|
||||
<div class="card" style="margin-top: 1.5rem; padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
Link Health Registry
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
<th>Destination URL</th>
|
||||
<th>General Status</th>
|
||||
<th>Detailed Diagnostic</th>
|
||||
<th>Latency</th>
|
||||
<th>Last Checked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened links registered to monitor.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 0.95rem;">
|
||||
/{{ url.code }}
|
||||
</a>
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.1rem;">
|
||||
{{ url.title.as_deref().unwrap_or("") }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{{ url.destination }}
|
||||
</td>
|
||||
<td>
|
||||
{% if url.status == "healthy" %}
|
||||
<span class="badge badge-healthy">Healthy</span>
|
||||
{% else if url.status == "suspect" %}
|
||||
<span class="badge badge-suspect">Suspect</span>
|
||||
{% else %}
|
||||
<span class="badge badge-dead">Dead</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if let Some(last_status) = url.last_status %}
|
||||
{% if last_status == "healthy" %}
|
||||
<span class="badge" style="background-color: rgba(16, 185, 129, 0.15); color: #10b981; border: 1px solid rgba(16, 185, 129, 0.2);">HTTP OK</span>
|
||||
{% else if last_status == "timeout" %}
|
||||
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">Timeout</span>
|
||||
{% else if last_status == "dns_failure" %}
|
||||
<span class="badge" style="background-color: rgba(59, 130, 246, 0.15); color: #3b82f6; border: 1px solid rgba(59, 130, 246, 0.2);">DNS Error</span>
|
||||
{% else if last_status == "tls_error" %}
|
||||
<span class="badge" style="background-color: rgba(139, 92, 246, 0.15); color: #8b5cf6; border: 1px solid rgba(139, 92, 246, 0.2);">TLS Error</span>
|
||||
{% else if last_status == "connection_refused" %}
|
||||
<span class="badge" style="background-color: rgba(239, 68, 68, 0.15); color: #ef4444; border: 1px solid rgba(239, 68, 68, 0.2);">Refused</span>
|
||||
{% else if last_status == "redirect_loop" %}
|
||||
<span class="badge" style="background-color: rgba(236, 72, 153, 0.15); color: #ec4899; border: 1px solid rgba(236, 72, 153, 0.2);">Redirect Loop</span>
|
||||
{% else if last_status.starts_with("http_") %}
|
||||
<span class="badge" style="background-color: rgba(245, 158, 11, 0.15); color: #f59e0b; border: 1px solid rgba(245, 158, 11, 0.2);">{{ last_status.replace("http_", "HTTP ") }}</span>
|
||||
{% else %}
|
||||
<span class="badge" style="background-color: rgba(107, 114, 128, 0.15); color: #9ca3af; border: 1px solid rgba(107, 114, 128, 0.2);">{{ last_status }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span style="color: var(--text-muted); font-size: 0.85rem;">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{% if let Some(latency) = url.last_latency_ms %}
|
||||
{{ latency }} ms
|
||||
{% else %}
|
||||
-
|
||||
{% endif %}
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.updated_at[0..10] }} {{ url.updated_at[11..16] }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
|
||||
|
||||
{% block header_actions %}
|
||||
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
|
||||
Back to URLs
|
||||
</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Link Details Card -->
|
||||
<div class="card" style="margin-bottom: 2rem;">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
||||
Link Details
|
||||
</h3>
|
||||
<div>
|
||||
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
|
||||
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
|
||||
{{ destination }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Visitor Activity Log -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
||||
Visitor Activity Log
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Sr</th>
|
||||
<th>Timestamp</th>
|
||||
<th>IP Address</th>
|
||||
<th>Country</th>
|
||||
<th>Referrer</th>
|
||||
<th>Browser</th>
|
||||
<th>User-Agent</th>
|
||||
<th>UTM Source</th>
|
||||
<th>UTM Campaign</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if visits.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No visitor activity available for this short link.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for entry in visits %}
|
||||
<tr>
|
||||
<td>{{ entry.sr }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
|
||||
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
|
||||
<td>
|
||||
{% if entry.country == "Unknown" %}
|
||||
<span style="color: var(--text-muted);">Unknown</span>
|
||||
{% else %}
|
||||
{{ entry.country }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ entry.referrer }}</td>
|
||||
<td>{{ entry.browser }}</td>
|
||||
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
|
||||
{{ entry.user_agent }}
|
||||
</td>
|
||||
<td>{{ entry.utm_source }}</td>
|
||||
<td>{{ entry.utm_campaign }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,145 @@
|
||||
{% extends "user_layout.html" %}
|
||||
|
||||
{% block title %}My Short URLs - BZOD{% endblock %}
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block header_title %}My Short URL Library{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New URL
|
||||
</h3>
|
||||
|
||||
<form action="/user/urls/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="destination">Destination URL *</label>
|
||||
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (6-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !home" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="title">Title (optional)</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="description">Description (optional)</label>
|
||||
<textarea id="description" name="description" class="form-input" rows="2" placeholder="Notes or summary..."></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags (comma-separated, optional)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
My Short Links
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
<th>Destination</th>
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No short URLs created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||
<td>
|
||||
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;">
|
||||
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
|
||||
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
|
||||
</a>
|
||||
<div style="display: flex; gap: 0.25rem;">
|
||||
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
|
||||
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
|
||||
</td>
|
||||
<td>{{ url.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
{% endif %}
|
||||
|
||||
{% for p in visible_pages %}
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,127 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}Users - BZOD{% endblock %}
|
||||
|
||||
{% block active_users %}active{% endblock %}
|
||||
|
||||
{% block header_title %}Users Management{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if let Some(msg) = success %}
|
||||
<div class="alert alert-success">
|
||||
{{ msg }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if let Some(err) = error %}
|
||||
<div class="alert alert-error">
|
||||
{{ err }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<h2 style="margin-bottom: 1rem;">Create New User</h2>
|
||||
<form action="/admin/users/create" method="POST" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 1rem; align-items: end;">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input type="text" id="username" name="username" class="form-input" required autocomplete="username">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input type="password" id="password" name="password" class="form-input" required minlength="8" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="account_type">Account Type</label>
|
||||
<select id="account_type" name="account_type" class="form-input">
|
||||
<option value="standard" selected>Standard</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="organization">Organization</option>
|
||||
<option value="service">Service</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" style="min-width: 100%;">
|
||||
<label for="metadata">Metadata (optional)</label>
|
||||
<input type="text" id="metadata" name="metadata" class="form-input" placeholder="Organization, tags, or notes">
|
||||
</div>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn" style="grid-column: 1 / -1; width: fit-content;">Create User</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="overflow-x: auto;">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Username</th>
|
||||
<th>Status</th>
|
||||
<th>Type</th>
|
||||
<th>Last Login</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if users.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem 0;">
|
||||
No users found.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<td>
|
||||
<form action="/admin/users/status/{{ user.id }}" method="POST" style="display: flex; gap: 0.5rem; align-items: center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="status" class="form-input" style="min-width: 140px;">
|
||||
<option value="active" {% if user.status == "active" %}selected{% endif %}>active</option>
|
||||
<option value="disabled" {% if user.status == "disabled" %}selected{% endif %}>disabled</option>
|
||||
<option value="suspended" {% if user.status == "suspended" %}selected{% endif %}>suspended</option>
|
||||
<option value="pending" {% if user.status == "pending" %}selected{% endif %}>pending</option>
|
||||
<option value="deleted" {% if user.status == "deleted" %}selected{% endif %}>deleted</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.45rem 0.75rem;">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/users/type/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<select name="account_type" class="form-input" style="min-width: 140px;">
|
||||
<option value="standard" {% if user.account_type == "standard" %}selected{% endif %}>standard</option>
|
||||
<option value="admin" {% if user.account_type == "admin" %}selected{% endif %}>admin</option>
|
||||
<option value="organization" {% if user.account_type == "organization" %}selected{% endif %}>organization</option>
|
||||
<option value="service" {% if user.account_type == "service" %}selected{% endif %}>service</option>
|
||||
</select>
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.8rem;">Save</button>
|
||||
</form>
|
||||
</td>
|
||||
<td style="font-size: 0.85rem; color: var(--text-secondary);">
|
||||
{% if let Some(login) = user.last_login %}
|
||||
{{ login[0..16].replace("T", " ") }}
|
||||
{% else %}
|
||||
Never
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{{ user.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<div style="display: grid; gap: 0.5rem;">
|
||||
<form action="/admin/users/password/{{ user.id }}" method="POST" style="display: grid; gap: 0.4rem;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<input type="password" name="new_password" class="form-input" placeholder="New password" minlength="8" required style="width: 190px;">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Reset</button>
|
||||
</form>
|
||||
<form action="/admin/users/delete/{{ user.id }}" method="POST" onsubmit="return confirm('Delete user "{{ user.username }}"? This cannot be undone.');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">Delete</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Loaded 100 of 130 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user