41 Commits
Author SHA1 Message Date
thakares 7dfb8c0f1b BZOD v0.5.0 RC2: multi-user platform, dashboards, analytics, backups and validation 2026-06-19 14:51:38 +05:30
thakares 743502b183 ci: improve Rust workflow checks 2026-06-18 15:31:52 +05:30
thakares 7ee6ab2feb ci: rerun workflow 2026-06-18 15:25:06 +05:30
thakares 621a1eccdc Add project comparison guide and improve landing page 2026-06-18 15:09:09 +05:30
thakares cefb84f643 Add project comparison guide and improve landing page 2026-06-18 15:02:31 +05:30
thakares 9fae39dfa4 Use standard Rust dual-license layout 2026-06-18 13:07:34 +05:30
thakares 2212701b6b Add Apache 2.0 license file 2026-06-18 13:04:28 +05:30
thakares 536d885a3d Add API documentation and dual MIT/Apache licensing 2026-06-18 12:59:07 +05:30
thakares a4ffe9a509 Delete LICENSE-APACHE 2026-06-18 12:39:03 +05:30
thakares ad05af95e9 Add API documentation and dual MIT/Apache licensing 2026-06-18 12:32:51 +05:30
thakares 17d8618443 docs: add REST API documentation and installation guide 2026-06-18 12:17:16 +05:30
thakares 74524cb7d2 docs: add one-command deployment section with deploy.sh 2026-06-17 20:45:54 +05:30
thakares e2140e6614 Add deploy script endpoint and improve landing page 2026-06-17 20:33:25 +05:30
thakares 1a9bf096f3 Add deploy.sh endpoint and one-command installation flow 2026-06-17 20:17:19 +05:30
thakares 900f72a299 release: update installer 2026-06-17 19:08:19 +05:30
thakares d42f6da94a feat: production deployment script with rollback and multi-arch support 2026-06-17 19:00:41 +05:30
thakares 18d8b16a2c docs: update documentation for v0.4.0 release 2026-06-17 16:49:47 +05:30
thakares f6dcf58b79 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:47:14 +05:30
thakares 84c48fa5c1 Add analytics drill-down, visitor logs, exports and pagination 2026-06-17 15:35:59 +05:30
thakares 0e111d61ff docs: add Docker deployment guide 2026-06-14 21:02:36 +05:30
thakares 81eb8950dd ci: add automatic Docker image push to GHCR on main 2026-06-14 20:53:32 +05:30
thakares 914563e883 ci: fix Docker image loading in CI health check 2026-06-14 20:49:59 +05:30
thakares 8b521f1a71 ci: fix YAML structure in GitHub workflow 2026-06-14 20:46:55 +05:30
thakares f1d72c8cbe Update rust.yml 2026-06-14 20:40:22 +05:30
thakares d9979ba04c docs: refine README and project documentation 2026-06-14 19:50:12 +05:30
thakares 6f42b43608 docs: refine README and project documentation 2026-06-14 19:44:36 +05:30
thakares 3e9dc47604 docs: refine README and project documentation 2026-06-14 19:43:46 +05:30
thakares e2e61fc412 docs: refine README and project documentation 2026-06-14 19:42:35 +05:30
thakares a0a73b918c docs: refine README and project documentation 2026-06-14 19:36:29 +05:30
thakares d2174aa111 docs: refine README and project documentation 2026-06-14 19:26:21 +05:30
thakares 6a45474e97 docs: refine README and project documentation 2026-06-14 19:24:38 +05:30
thakares c6486763e3 Add custom slugs, restore UI, UTM builder, and CLI link tools 2026-06-14 19:11:21 +05:30
thakares 02a26cfd94 style: fix cargo fmt issues in pages.rs and root_landing_tests.rs 2026-06-13 22:29:03 +05:30
thakares 3c0a1bdd91 ci: enhance GitHub workflow with Docker build + better Rust CI 2026-06-13 22:24:37 +05:30
thakares ee6d3135d5 docker: optimize build cache with better layer ordering 2026-06-13 22:20:25 +05:30
thakares 671370571a chore: add .dockerignore for cleaner Docker builds 2026-06-13 22:13:28 +05:30
thakares 42a2df33dd Add root landing page support and package static assets 2026-06-13 21:47:14 +05:30
thakares 80038fb851 docs: improve testing guide and add README documentation links 2026-06-13 18:48:26 +05:30
thakares 9c5e3e4d58 Add TESTING.md with validation and recovery procedures 2026-06-13 18:35:05 +05:30
thakares 27c4ad6805 Update README for v0.2.0 feature set 2026-06-12 20:17:18 +05:30
thakares 592154e961 Update README for v0.2.0 feature set 2026-06-12 20:13:22 +05:30
131 changed files with 21705 additions and 725 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# Dependencies & Build artifacts
target/
**/target/
# Environment & secrets
.env
.env.*
*.key
*.pem
# Development & testing files
.git/
.gitignore
.github/
.gitattributes
# Documentation & notes
README*.md
docs/
CONTRIBUTING.md
CHANGELOG.md
LICENSE
# Logs & temporary files
*.log
*.tmp
data/
backups/
# Editor & IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# Docker related
Dockerfile*
.dockerignore
docker-compose*.yml
.docker/
# Test files
tests/
__tests__/
*.test.*
*.spec.*
# Other unnecessary files
node_modules/
dist/
build/
+80 -17
View File
@@ -1,22 +1,85 @@
name: Rust
name: Rust CI
on:
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
+1
View File
@@ -5,3 +5,4 @@ data/
*.db-shm
.env
.idea/
Generated
+95 -1
View File
@@ -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
View File
@@ -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"
+26 -22
View File
@@ -1,7 +1,6 @@
# ==========================================
# Stage 1: Build
# Stage 1: Builder (with optimized caching)
# ==========================================
# FROM rust:1.82-slim-bookworm AS builder
FROM rust:1.89-bookworm AS builder
WORKDIR /app
@@ -10,34 +9,33 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy configuration files
# COPY Cargo.toml ./
# Copy only Cargo files first (best caching)
COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Create dummy source for dependency caching
RUN mkdir -p src && \
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
cargo build --release && \
rm -rf src target/release/deps/bzod*
# Copy source and templates
# Copy real source code + assets
COPY src ./src
COPY templates ./templates
COPY www ./www
# Trigger rebuilding with actual source
RUN touch src/main.rs
# Build the real application
RUN cargo build --release
# ==========================================
# Stage 2: Runner
# Stage 2: Runtime (slim)
# ==========================================
FROM debian:bookworm-slim
WORKDIR /app
# Install runtime dependencies
# Runtime dependencies
RUN apt-get update && apt-get install -y \
openssl \
ca-certificates \
@@ -47,23 +45,29 @@ RUN apt-get update && apt-get install -y \
# Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Create non-root user and data directory
# 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
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
# Create data directory
RUN mkdir -p /app/data && \
chown -R bzod:bzod /app
USER bzod
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"]
CMD ["serve"]
View File
File renamed without changes.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Sunil Purushottam Thakare
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+511 -65
View File
@@ -1,90 +1,493 @@
# nx9-url-shortener
# nx9-url-shortener (the BZOD daemon)
A lightweight, self-hosted URL shortener and landing page platform written in Rust.
**A lightweight, self-hosted URL management platform written in Rust.**
`nx9-url-shortener` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services.
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, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint.
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: MIT OR Apache-2.0](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue.svg)](#license)
---
## Highlights
### v0.4.0
* 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
* 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/Multi-Time Links | ✅ |
| Audit Trail | ✅ |
| Analytics Dashboard | ✅ |
| Health Monitoring | ✅ |
| REST API | ✅ |
| 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 using dedicated page identifiers.
Create standalone landing pages hosted directly by BZOD.
Example:
Generated page:
```text
https://<your-short-domain>/p/1a2b
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.
Supported formats:
* PNG
* SVG
Features:
* Downloadable QR assets
* QR scan analytics
* Bulk QR export
* Print-friendly SVG output
---
### Password-Protected Links
Protect sensitive links using Argon2id password hashing.
Features:
* Password gate
* Secure session handling
* Access restrictions
* Audit logging
---
### Smart Preview Pages
Display branded preview pages before redirecting visitors.
Features:
* Custom title
* Description
* Logo support
* Open Graph metadata
* Social sharing previews
---
### Link Lifecycle Management
Control link validity.
Features:
* Expiration dates
* One-time links
* 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
* Country statistics
* Unique visitors
* QR scans
* 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 activity.
Recorded events include:
* Login
* Logout
* URL creation
* URL updates
* URL deletion
* Backup creation
* Restore operations
* QR exports
* Configuration changes
---
### Administrative Dashboard
Web-based administration interface featuring:
Web-based management interface.
* URL management
* Landing page management
Features:
* URL registry
* Landing pages
* QR management
* Analytics
* API token management
* Audit logs
* Health checks
* Analytics dashboard
* SVG charts
* Backup utilities
* Restore utilities
* Health monitoring
* 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/*
```
Supports:
* URL creation
* URL management
* Landing pages
* QR generation
* Analytics access
---
### 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
* Session management
* Password-protected administration
* Password-protected links
* Argon2id password hashing
* CSRF protection
* Session management
* API token authentication
* Audit logging
* Access controls
---
@@ -103,8 +506,10 @@ No:
* React
* Node.js
* Redis
* PostgreSQL
* MongoDB
* Kubernetes
* External databases
* SaaS dependencies
---
@@ -112,27 +517,59 @@ No:
### Databases
The application uses four SQLite databases.
BZOD uses four SQLite databases.
| Database | Purpose |
| ------------ | ---------------------------------------- |
| admin.db | Users, sessions, API keys, audit logs |
| content.db | URLs, landing pages, tags |
| analytics.db | Visits and statistics |
| system.db | Jobs, migrations, backups, health checks |
| 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 |
---
## Default Credentials
## Initial Setup
Initial login:
### Native Installation
```text
Username: admin
Password: admin
```bash
cargo run -- create-admin
```
Change the password immediately after first login.
### Docker
```bash
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.
---
@@ -142,9 +579,9 @@ Change the password immediately after first login.
![Dashboard](screenshots/dashboard.png)
### Short URL Management
### URL Management
![Short URL Management](screenshots/short-url-panel.png)
![URL Management](screenshots/short-url-panel.png)
### Landing Pages
@@ -162,19 +599,19 @@ Change the password immediately after first login.
## Docker Deployment
### Build
Build:
```bash
docker compose build
```
### Start
Start:
```bash
docker compose up -d
```
### View Logs
Logs:
```bash
docker logs -f bzod
@@ -208,31 +645,25 @@ services:
## Development
### Build
Build:
```bash
cargo build
```
### Run
Run:
```bash
cargo run -- serve
```
### Run Migrations
```bash
cargo run -- migrate
```
### Create Admin User
Create administrator:
```bash
cargo run -- create-admin
```
### Run Tests
Run tests:
```bash
cargo test
@@ -240,6 +671,18 @@ cargo test
---
## Development & Testing
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure
```text
@@ -263,20 +706,17 @@ src/
Planned features:
* QR code generation
* Link expiration
* Link disabling
* CSV exports
* Bulk URL import
* GeoIP integration
* Geo analytics
* Multi-user administration
* SSO support
* SSO integration
* Signed temporary links
* OpenAPI documentation
---
## Production Deployment
Recommended stack:
Recommended architecture:
```text
Internet
@@ -285,7 +725,7 @@ Internet
Nginx Proxy Manager
│
▼
nx9-url-shortener
BZOD
│
▼
SQLite
@@ -293,6 +733,12 @@ SQLite
HTTPS is strongly recommended.
---
## Documentation
- [Testing Guide](docs/TESTING.md)
- [Docker Deployment Guide](docs/DOCKER-Deploy.md)
---
## License
@@ -305,4 +751,4 @@ Apache License 2.0
Sunil Purushottam Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+155 -76
View File
@@ -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}"
+20 -27
View File
@@ -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
custom: nx9-url-shortener
+391
View File
@@ -0,0 +1,391 @@
# BZOD REST API
> Programmatic access to URLs, Landing Pages, QR Codes, Analytics, and Audit Logs.
## Overview
The BZOD REST API allows automation and integration with external systems such as:
* Home Assistant
* Shell Scripts
* CI/CD Pipelines
* Monitoring Systems
* Internal Applications
* Self-hosted Services
All API endpoints require authentication using an API Token generated from:
```text
Admin Dashboard → Settings → REST API Tokens
```
---
# Authentication
Generate an API token from the Admin Dashboard.
Example token:
```text
bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
```
Pass the token using the `Authorization` header.
## Example
```bash
curl \
-H "Authorization: bzo_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" \
https://your-domain.com/api/v1/stats
```
---
# Base URL
```text
https://your-domain.com/api/v1
```
Example:
```text
https://bzo.in/api/v1
```
---
# Response Format
Successful responses:
```json
{
"success": true,
"data": {}
}
```
Error responses:
```json
{
"success": false,
"error": "Invalid API token"
}
```
---
# URL Management
## List URLs
```http
GET /api/v1/urls
```
### Example
```bash
curl \
-H "Authorization: TOKEN" \
https://your-domain.com/api/v1/urls
```
---
## Create URL
```http
POST /api/v1/urls
```
### Request
```json
{
"code": "rust",
"target_url": "https://www.rust-lang.org",
"description": "Rust Language"
}
```
### Example
```bash
curl \
-X POST \
-H "Authorization: TOKEN" \
-H "Content-Type: application/json" \
-d '{
"code":"rust",
"target_url":"https://www.rust-lang.org"
}' \
https://your-domain.com/api/v1/urls
```
---
## Get URL
```http
GET /api/v1/urls/{uuid}
```
Example:
```http
GET /api/v1/urls/5d4d9e98-7cb7-4c97-9a0a-123456789abc
```
---
## Update URL
```http
PUT /api/v1/urls/{uuid}
```
---
## Delete URL
```http
DELETE /api/v1/urls/{uuid}
```
---
## URL Preview
```http
GET /api/v1/urls/{uuid}/preview
```
Returns rendered metadata used by preview cards.
---
# Landing Pages
## List Pages
```http
GET /api/v1/pages
```
---
## Create Page
```http
POST /api/v1/pages
```
### Example Request
```json
{
"title": "My Product",
"slug": "product",
"description": "Product Landing Page",
"content": "<h1>Hello World</h1>"
}
```
---
## Get Page
```http
GET /api/v1/pages/{uuid}
```
---
## Update Page
```http
PUT /api/v1/pages/{uuid}
```
---
## Delete Page
```http
DELETE /api/v1/pages/{uuid}
```
---
# Analytics
## Global Statistics
```http
GET /api/v1/stats
```
Returns overall platform metrics.
Example response:
```json
{
"total_urls": 125,
"total_pages": 12,
"total_clicks": 8431,
"total_qr_scans": 241
}
```
---
## URL Statistics
```http
GET /api/v1/stats/url/{uuid}
```
Returns analytics for a single URL.
---
## Landing Page Statistics
```http
GET /api/v1/stats/page/{uuid}
```
Returns analytics for a single landing page.
---
# QR Codes
## Download QR Code
```http
GET /api/v1/qr/{code}
```
Example:
```http
GET /api/v1/qr/rust
```
Returns QR image.
---
# Bulk Operations
## Bulk QR Export
```http
POST /api/v1/bulk/qr
```
Generate QR codes for multiple URLs.
---
## Bulk URL Operations
```http
POST /api/v1/bulk/url
```
Bulk create, update, or manage URLs.
---
# Audit Log
## List Audit Events
```http
GET /api/v1/audit
```
Returns administrative activity history.
Example response:
```json
[
{
"event": "url_created",
"user": "admin",
"timestamp": "2026-06-17T14:30:00Z"
}
]
```
---
# HTTP Status Codes
| Code | Description |
| ---- | --------------------- |
| 200 | Success |
| 201 | Created |
| 400 | Invalid Request |
| 401 | Authentication Failed |
| 403 | Access Denied |
| 404 | Resource Not Found |
| 409 | Conflict |
| 500 | Internal Server Error |
---
# Security Notes
* API tokens are displayed only once during creation.
* Tokens are stored as hashes and cannot be recovered.
* Revoke unused tokens immediately.
* Always use HTTPS.
* Never embed API tokens in public repositories.
---
# Example: Create URL From Shell Script
```bash
TOKEN="bzo_xxxxxxxxxxxxxxxxx"
curl \
-X POST \
-H "Authorization: ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"code":"example",
"target_url":"https://example.com"
}' \
https://your-domain.com/api/v1/urls
```
---
# API Stability
The BZOD API follows semantic versioning.
Current API namespace:
```text
/api/v1
```
Future breaking changes will be introduced under a new versioned namespace.
Example:
```text
/api/v2
```
+24
View File
@@ -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
+382
View File
@@ -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.
+545
View File
@@ -0,0 +1,545 @@
# Docker Deployment Guide for BZOD
This guide covers deployment, upgrades, backup, restore, troubleshooting, and production best practices for **BZOD (nx9-url-shortener)** using Docker.
---
# Overview
BZOD is a lightweight self-hosted URL shortener and landing page platform written in Rust.
Features include:
* URL shortening
* Human-readable custom slugs (`!office`, `!home`, etc.)
* Landing pages
* QR code generation
* Analytics
* Audit logging
* API access
* Backup and restore
* SQLite-based storage
* Docker deployment
BZOD is designed to remain simple:
* No PostgreSQL
* No Redis
* No external dependencies
* No vendor lock-in
---
# Quick Start
## Clone Repository
```bash
git clone https://github.com/thakares/nx9-url-shortener.git
cd nx9-url-shortener
```
## Build and Start
```bash
docker compose up -d --build
```
## Create Administrator
```bash
docker exec -it bzod bzod create-admin
```
Open:
```text
http://SERVER-IP:8654
```
Admin panel:
```text
http://SERVER-IP:8654/admin
```
---
# Docker Compose
Example:
```yaml
services:
bzod:
container_name: bzod
build: .
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- ./data:/app/data
- ./config:/app/config
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "./bzod", "doctor"]
interval: 30s
timeout: 10s
retries: 3
```
Start:
```bash
docker compose up -d
```
Verify:
```bash
docker ps
docker logs -f bzod
```
---
# Directory Layout
Typical deployment:
```text
bzod/
├── docker-compose.yml
├── Dockerfile
├── config/
├── data/
│ ├── admin.db
│ ├── content.db
│ ├── analytics.db
│ └── system.db
└── backups/
```
---
# Root Landing Page
BZOD can serve a static landing page from:
```text
www/index.html
```
This page is available at:
```text
https://your-domain/
```
Examples:
```text
https://bzo.in/
https://short.example.com/
```
The root landing page is packaged automatically inside the Docker image.
---
# Reverse Proxy Configuration
BZOD is intended to run behind a reverse proxy.
Example Nginx configuration:
```nginx
server {
server_name bzo.in;
location / {
proxy_pass http://127.0.0.1:8654;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Example deployment:
```text
Internet
↓
Nginx Proxy Manager
↓
BZOD Docker Container
```
---
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
Production recommendation:
```text
COOKIE_SECURE=true
```
when HTTPS is enabled.
---
# Analytics Export
Analytics pages support:
* Raw visitor logs
* CSV export
* JSON export
* Date filtering
Exports can be generated from:
Admin → Analytics
Backup
---
## Web UI
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Click:
```text
Download Backup
```
A compressed archive containing all databases will be downloaded.
---
## CLI Backup
Create backup:
```bash
docker exec -it bzod bzod backup
```
Example output:
```text
backup-2026-06-14.tar.gz
```
---
# Restore
## Web UI Restore
Navigate to:
```text
Admin → Settings → Maintenance & DB Utilities
```
Upload:
```text
backup.tar.gz
```
Type:
```text
RESTORE
```
Confirm restore.
The system will:
1. Validate archive contents
2. Restore databases
3. Reinitialize database access
4. Redirect to login
---
## CLI Restore
Copy backup archive into container or mounted volume.
Run:
```bash
docker exec -it bzod bash
cd /app/data
bzod restore --file backup.tar.gz
```
---
# Disaster Recovery
Example recovery procedure:
```bash
docker compose down
# Restore backup archive
docker compose up -d
```
Verify:
```bash
docker exec -it bzod bzod doctor
docker exec -it bzod bzod validate
```
Check:
* URLs
* Landing pages
* Analytics
* Audit logs
* Settings
---
# Useful CLI Commands
Health:
```bash
docker exec -it bzod bzod doctor
```
Statistics:
```bash
docker exec -it bzod bzod stats
```
Validate databases:
```bash
docker exec -it bzod bzod validate
```
Create admin:
```bash
docker exec -it bzod bzod create-admin
```
Shorten URL:
```bash
docker exec -it bzod bzod shorten https://example.com
```
Custom slug:
```bash
docker exec -it bzod bzod shorten https://example.com --slug !office
```
Expand URL:
```bash
docker exec -it bzod bzod expand !office
```
---
# Upgrading
Pull latest source:
```bash
git pull
```
Rebuild:
```bash
docker compose build --no-cache
```
Restart:
```bash
docker compose up -d
```
Verify:
```bash
docker logs -f bzod
```
# Upgrading to v0.4.0
1. Backup databases
2. Pull latest source
3. Rebuild container
4. Restart service
```bash
git pull
docker compose build --no-cache
docker compose up -d
---
# Troubleshooting
## Read-Only SQLite Database
Symptoms:
```text
attempt to write a readonly database
```
Check ownership:
```bash
ls -lah data/
```
Fix permissions:
```bash
docker exec -u 0 -it bzod bash
chown -R bzod:bzod /app/data
```
docker exec -it bzod bzod doctor
Restart:
```bash
docker compose restart bzod
```
---
## Missing Root Landing Page
Symptoms:
```text
404 on /
```
Verify:
```bash
docker exec -it bzod ls -lah /app/www
```
Expected:
```text
/app/www/index.html
```
Rebuild image if necessary:
```bash
docker compose build --no-cache
docker compose up -d
```
---
## Health Check Failure
Inspect logs:
```bash
docker logs bzod
```
Run:
```bash
docker exec -it bzod bzod doctor
```
---
## Port Already In Use
Change host port mapping:
```yaml
ports:
- "8080:8654"
```
Access:
```text
http://SERVER-IP:8080
```
---
# Production Recommendations
* Use HTTPS
* Run behind Nginx Proxy Manager or Nginx
* Use strong administrator credentials
* Schedule regular backups
* Periodically test restore procedures
* Monitor disk space
* Keep Docker images updated
---
# Validation Checklist
After deployment verify:
* [ ] Admin login works
* [ ] URL shortening works
* [ ] Custom slugs work
* [ ] Landing pages work
* [ ] QR generation works
* [ ] Analytics recorded
* [ ] Backup download works
* [ ] Restore workflow works
* [ ] Root landing page loads
* [ ] `bzod doctor` reports healthy
A deployment should not be considered production-ready until backup and restore procedures have been successfully tested.
+434
View File
@@ -0,0 +1,434 @@
# TESTING.md
# BZOD Test Procedures
This document describes the official verification procedures for BZOD.
The objective is not merely to confirm that code compiles, but to ensure that the complete platform can be built, deployed, backed up, restored, migrated, and recovered successfully.
---
# Philosophy
BZOD prioritizes:
1. Data Integrity
2. Operational Simplicity
3. Recovery Capability
4. Deployment Reproducibility
5. Functional Correctness
A passing unit test suite alone is insufficient.
A release is considered valid only if backup, restore, migration, and recovery procedures have been verified.
---
# Test Categories
## 1. Build Verification
Verify the application compiles successfully.
```bash
cargo check
cargo build
cargo build --release
```
Expected Result:
* No compiler errors
* No panics during startup
* Release binary generated successfully
---
## 2. Static Analysis
```bash
cargo fmt --check
cargo clippy --all-targets
```
Expected Result:
* Formatting passes
* No significant Clippy warnings
---
## 3. Unit Tests
```bash
cargo test
```
Expected Result:
* All tests pass
* No ignored critical tests
---
## 4. Database Initialization
Create a clean environment.
```bash
rm -rf data
./bzod stats
```
Expected Result:
* Databases are automatically created
* Migrations applied successfully
Verify:
```bash
./bzod doctor
```
Expected Result:
```text
Overall status: HEALTHY
```
---
## 5. Migration Verification
Run migrations repeatedly.
```bash
./bzod migrate
./bzod migrate
./bzod migrate
```
Expected Result:
* No duplicate migrations
* No errors
* Schema remains stable
---
## 6. Administrator Creation
Create an administrator account.
```bash
./bzod create-admin
```
Expected Result:
* User created successfully
* Authentication works
Attempt duplicate creation:
```bash
./bzod create-admin
```
Expected Result:
* Duplicate username rejected
---
## 7. Backup Verification
Create backup archive.
```bash
./bzod backup
```
Expected Result:
* Backup archive generated
* Archive contains all databases
Verify:
```bash
tar -tzf backup-*.tar.gz
```
Expected Result:
```text
admin.db
content.db
analytics.db
system.db
```
---
## 8. Restore Verification
Create sample data.
Generate:
* Administrator
* URL records
* Landing pages
* Analytics records
Create backup:
```bash
./bzod backup
```
Delete databases:
```bash
rm -rf data
```
Restore:
```bash
./bzod restore --file backup.tar.gz
```
Expected Result:
* Restore completes successfully
* All records preserved
Verify:
```bash
./bzod doctor
./bzod stats
```
Expected Result:
```text
Overall status: HEALTHY
```
and original record counts preserved.
---
## 9. Disaster Recovery Scenario
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.
Procedure:
1. Backup system.
2. Delete entire data directory.
3. Restore backup.
4. Start server.
5. Login to Admin UI.
Commands:
```bash
./bzod backup
rm -rf data
./bzod restore --file backup.tar.gz
./bzod serve
```
Expected Result:
* System fully operational
* No manual database repair required
---
## 11. Database Health Verification
Run:
```bash
./bzod doctor
```
Expected Result:
For every database:
```text
Integrity: ok
Foreign keys: enabled
Journal mode: wal
```
Final result:
```text
Overall status: HEALTHY
```
---
## 12. SQLite Integrity Checks
Manual verification.
```bash
sqlite3 data/admin.db "PRAGMA integrity_check;"
sqlite3 data/content.db "PRAGMA integrity_check;"
sqlite3 data/analytics.db "PRAGMA integrity_check;"
sqlite3 data/system.db "PRAGMA integrity_check;"
```
Expected Result:
```text
ok
```
for all databases.
---
## 13. Web Interface Verification
Start server.
```bash
./bzod serve
```
Verify:
* Homepage loads
* Redirects function
* Landing pages render
* Admin login works
* Dashboard loads
* API endpoints respond
---
## 14. Docker Verification
Build image.
```bash
docker compose build --no-cache
```
Start service.
```bash
docker compose up -d
```
Verify:
```bash
docker compose logs -f
```
Expected Result:
```text
Listening for requests
```
Verify:
```bash
./bzod doctor
```
inside container.
---
## 15. Upgrade Verification
1. Create backup.
2. Upgrade binary.
3. Run migration.
4. Start service.
```bash
./bzod backup
./bzod migrate
./bzod serve
```
Expected Result:
* Existing data preserved
* 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:
* Build verification passes
* Static analysis passes
* Unit tests pass
* Backup verification passes
* Restore verification passes
* Disaster recovery verification passes
* Doctor reports HEALTHY
* Docker deployment succeeds
* Web UI functions correctly
Failure of backup, restore, or disaster recovery tests is considered a release blocker.
---
# Guiding Principle
A successful release is not merely one that starts.
A successful release is one that can be recovered.
+42 -6
View File
@@ -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
);
}
}
}
}
+6 -5
View File
@@ -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
View File
@@ -6,4 +6,6 @@ pub mod session;
pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{authenticate_api_key, authenticate_session, generate_token};
pub use session::{
authenticate_admin_session, authenticate_api_key, authenticate_user_session, generate_token,
};
+255 -17
View File
@@ -1,11 +1,11 @@
use crate::db::admin::{
get_api_key_by_hash, get_session, get_user_by_id, update_api_key_last_used,
get_api_key_by_hash, get_user_by_id as get_admin_user_by_id, update_api_key_last_used,
};
use crate::models::User;
use crate::models::{ApiActor, Session as AdminSession, TenantUser, User, UserSession};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rand::{thread_rng, RngCore};
use rusqlite::Connection;
use rusqlite::{Connection, OptionalExtension};
use sha2::{Digest, Sha256};
// Generate a secure random token (hex-encoded)
@@ -15,8 +15,8 @@ pub fn generate_token(bytes_len: usize) -> String {
hex::encode(key)
}
// Authenticate session from cookies
pub fn authenticate_session(
// Authenticate administrator session from cookies
pub fn authenticate_admin_session(
conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> {
@@ -26,7 +26,33 @@ pub fn authenticate_session(
};
let session_id = cookie.value();
let session = match get_session(conn, session_id)? {
let session_opt: Option<AdminSession> = conn
.query_row(
"SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;",
[session_id],
|row| {
let id: String = row.get(0)?;
// `user_id` may be stored as integer (users.db) or text (admin.db UUID).
let user_id_str: String = match row.get::<_, String>(1) {
Ok(s) => s,
Err(_) => {
let i: i64 = row.get(1)?;
i.to_string()
}
};
let expires_at: String = row.get(2)?;
let created_at: String = row.get(3)?;
Ok(AdminSession {
id,
user_id: user_id_str,
expires_at,
created_at,
})
},
)
.optional()?;
let session = match session_opt {
Some(s) => s,
None => return Ok(None),
};
@@ -41,19 +67,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,
})));
}
}
+150
View File
@@ -0,0 +1,150 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::fs::File;
use std::path::{Path, PathBuf};
use tar::{Builder, Header};
use tracing::{error, info};
use zstd::Encoder;
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupMetadata {
id: i64,
username: String,
password_hash: String,
status: String,
created_at: String,
account_type: String,
metadata: Option<String>,
quotas: UserBackupQuotas,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct UserBackupQuotas {
max_urls: i64,
max_landings: i64,
max_api_tokens: i64,
max_storage_mb: i64,
}
pub async fn run(
username: String,
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let username_clean = username.trim().to_lowercase();
// 1. Get user details from users.db
let user_details = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_username(&conn, &username_clean)?
};
let user = match user_details {
Some(u) => u,
None => {
error!("User '{}' not found", username_clean);
return Ok(());
}
};
let user_id = user.id;
// 2. Fetch user's quotas
let quotas = {
let conn = db.users.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb FROM quotas WHERE user_id = ?1;",
[user_id],
|row| {
Ok(UserBackupQuotas {
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
})
}
)?
};
// 3. Define output path
let tar_path = match out {
Some(p) => PathBuf::from(p),
None => {
if !config.backup_dir.exists() {
std::fs::create_dir_all(&config.backup_dir)?;
}
config.backup_dir.join(format!(
"{}-{}.tar.zst",
username_clean,
Utc::now().format("%Y%m%d")
))
}
};
info!(
"Backing up user {} (ID: {}) to {:?}",
username_clean, user_id, tar_path
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("analytics.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(c) = rusqlite::Connection::open(user_dir.join("profile.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
// 5. Create tar.zst archive
let file = File::create(&tar_path)?;
let zst_enc = Encoder::new(file, 3)?;
let mut tar = Builder::new(zst_enc);
// Write metadata.json directly into tar
let metadata_obj = UserBackupMetadata {
id: user.id,
username: user.username,
password_hash: user.password_hash,
status: user.status,
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
let mut header = Header::new_gnu();
header.set_size(metadata_bytes.len() as u64);
header.set_path("metadata.json")?;
header.set_mode(0o644);
header.set_cksum();
tar.append(&header, &metadata_bytes[..])?;
// Append database files
let mut append_file =
|name_in_archive: &str, path_on_disk: &Path| -> Result<(), Box<dyn std::error::Error>> {
if path_on_disk.exists() {
let mut file = File::open(path_on_disk)?;
tar.append_file(name_in_archive, &mut file)?;
}
Ok(())
};
append_file("content.db", &user_dir.join("content.db"))?;
append_file("analytics.db", &user_dir.join("analytics.db"))?;
append_file("profile.db", &user_dir.join("profile.db"))?;
tar.into_inner()?.finish()?;
info!("User backup generated successfully at {:?}", tar_path);
Ok(())
}
+3 -2
View File
@@ -32,8 +32,9 @@ pub async fn run(
}
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let 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
+86
View File
@@ -0,0 +1,86 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let final_username = match username {
Some(u) => u,
None => read_input("Enter username: "),
};
let username_clean = final_username.trim().to_lowercase();
if username_clean.is_empty() {
error!("Username cannot be empty");
return Ok(());
}
if username_clean.len() < 3 {
error!("Username must be at least 3 characters");
return Ok(());
}
if !username_clean
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
error!("Username must contain only alphanumeric characters, hyphens, or underscores");
return Ok(());
}
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
// Check if user already exists
{
let conn = db.users.lock().unwrap();
if crate::db::users::get_user_by_username(&conn, &username_clean)?.is_some() {
error!("User already exists: {}", username_clean);
return Ok(());
}
}
// Create user in DB (this seeds default quotas too)
let new_user = {
let conn = db.users.lock().unwrap();
crate::db::users::create_user(&conn, &username_clean, &hash, "standard", None)?
};
// Initialize their user specific directory and DB files (content.db, analytics.db, profile.db)
db.init_user_databases(new_user.id)?;
info!(
"Successfully created standard user: {} (ID: {})",
new_user.username, new_user.id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+92
View File
@@ -0,0 +1,92 @@
use crate::config::Config;
use crate::db::Db;
use chrono::Utc;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
force: bool,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, user_id)? {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
let conn = db.users.lock().unwrap();
crate::db::users::delete_user(&conn, user_id)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DELETION",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
}
info!(
"Successfully deleted user {} (ID: {}) and all associated content",
user_details.username, user_id
);
Ok(())
}
+55
View File
@@ -0,0 +1,55 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "disabled" {
info!("User {} is already disabled", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "disabled")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_DISABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!("User {} (ID: {}) has been disabled", user.username, user_id);
Ok(())
}
+58
View File
@@ -0,0 +1,58 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
if user.status == "active" {
info!("User {} is already active", user.username);
return Ok(());
}
{
let conn = db.users.lock().unwrap();
crate::db::users::update_user_status(&conn, user_id, "active")?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_ENABLED",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"User {} (ID: {}) has been enabled (active)",
user.username, user_id
);
Ok(())
}
+32
View File
@@ -0,0 +1,32 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
code: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let normalized_code = code.trim().to_lowercase();
if !crate::utils::validation::validate_redirect_code(&normalized_code) {
return Err("Invalid short code or custom slug format".into());
}
let url_opt = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)?
};
match url_opt {
Some(url) => {
println!("{}", url.destination);
Ok(())
}
None => Err(format!("Short code not found: {}", normalized_code).into()),
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
let users = {
let conn = db.users.lock().unwrap();
crate::db::users::list_users(&conn)?
};
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
"ID", "Username", "Status", "Type", "Created At"
);
println!(
"{:-<6}-+-{:-<20}-+-{:-<10}-+-{:-<12}-+-{:-<24}",
"", "", "", "", ""
);
for u in users {
println!(
"{:<6} | {:<20} | {:<10} | {:<12} | {:<24}",
u.id, u.username, u.status, u.account_type, u.created_at
);
}
Ok(())
}
+93
View File
@@ -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>,
},
}
+75
View File
@@ -0,0 +1,75 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::io::{self, Write};
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
user_id: i64,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// Check user exists
let user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
};
let user = match user {
Some(u) => u,
None => {
error!("User ID {} not found", user_id);
return Ok(());
}
};
let final_password = match password {
Some(p) => p,
None => read_input("Enter new password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
{
let conn = db.users.lock().unwrap();
crate::db::users::reset_user_password(&conn, user_id, &hash)?;
}
// Write audit event
{
let system_conn = db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"cli",
"USER_PASSWORD_RESET",
"user",
&user_id.to_string(),
Some(&format!("Username: {}", user.username)),
);
}
info!(
"Password for user {} (ID: {}) has been reset successfully",
user.username, user_id
);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+41 -4
View File
@@ -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(())
+269
View File
@@ -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(())
}
+18
View File
@@ -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,
+73
View File
@@ -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
View File
@@ -71,10 +71,20 @@ pub fn create_session(
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
// Bind `user_id` as integer when it appears to be numeric so that numeric
// user IDs inserted into `users.db` keep the integer affinity and avoid
// InvalidColumnType errors when read as i64 elsewhere.
if let Ok(id_i64) = user_id.parse::<i64>() {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, id_i64, expires_at_rfc3339, created_at],
)?;
} else {
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
}
Ok(Session {
id: session_id.to_string(),
+241 -3
View File
@@ -82,8 +82,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code, owner_user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11);"
)?;
for r in records {
@@ -97,7 +97,8 @@ pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> ru
r.referer,
r.accept_language,
r.country,
r.status_code
r.status_code,
r.owner_user_id
])?;
}
}
@@ -508,6 +509,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::*;
+13
View File
@@ -47,6 +47,19 @@ pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec
Ok(tags)
}
/// Get the total count of URLs associated with a specific tag name.
pub fn get_url_count_by_tag(conn: &Connection, tag: &str) -> rusqlite::Result<i64> {
let tag_name = tag.trim().to_lowercase();
conn.query_row(
"SELECT COUNT(*) FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1;",
params![tag_name],
|row| row.get(0),
)
}
/// The full column list used in all URL SELECT queries.
const URL_COLUMNS: &str = "id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, last_status, last_latency_ms, max_access_count, access_count";
+189 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -10,23 +10,43 @@ pub async fn run_aggregator(db: Db, interval_mins: u64) {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
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
View File
@@ -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
View File
@@ -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
View File
@@ -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(())
}
+36
View File
@@ -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)
}
+42
View File
@@ -0,0 +1,42 @@
use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
loop {
// Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(e) => {
error!("Failed to prepare select user IDs: {:?}", e);
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(e) => {
error!("Failed to query user IDs: {:?}", e);
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
error!("Failed to reconcile quotas for user {}: {:?}", user_id, e);
}
}
}
info!("Quota reconciliation finished.");
}
}
+42 -10
View File
@@ -15,18 +15,50 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
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
View File
@@ -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
View File
@@ -9,5 +9,8 @@ pub use api_key::ApiKey;
pub use audit::AuditLog;
pub use page::LandingPage;
pub use url::{AuditEvent, LinkPreview, QrCode, Url};
pub use user::{Session, User};
pub use user::{
AccountType, ApiActor, ModerationSeverity, Session, SlugStatus, TenantUser, User, UserApiToken,
UserQuotas, UserSession, UsernameHistory,
};
pub use visit::{SummaryEntry, VisitRecord};
+190
View File
@@ -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,
}
}
}
+1
View File
@@ -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)]
+70
View File
@@ -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(())
}
}
+106
View File
@@ -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
View File
@@ -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(),
}
}
}
+9
View File
@@ -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 {
+47
View File
@@ -51,3 +51,50 @@ impl IntoResponse for AuditTemplate {
}
}
}
#[derive(Template)]
#[template(path = "user_status.html")]
pub struct UserStatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
pub urls: Vec<crate::models::Url>,
}
#[derive(Template)]
#[template(path = "user_audit.html")]
pub struct UserAuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for UserStatusTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
impl IntoResponse for UserAuditTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+9
View File
@@ -14,6 +14,15 @@ pub struct UrlsTemplate {
pub error: Option<String>,
pub 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 {
+33
View File
@@ -0,0 +1,33 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_dashboard.html")]
pub struct UserDashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for UserDashboardTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+38
View File
@@ -0,0 +1,38 @@
use crate::models::LandingPage;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_pages.html")]
pub struct UserPagesTemplate {
pub admin_username: String,
pub username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserPagesTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserPagesTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_settings.html")]
pub struct UserSettingsTemplate {
pub admin_username: String,
pub username: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UserSettingsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
use crate::models::Url;
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Template)]
#[template(path = "user_urls.html")]
pub struct UserUrlsTemplate {
pub admin_username: String,
pub username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
pub base_url: String,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
}
impl UserUrlsTemplate {
pub fn is_current(&self, page: &usize) -> bool {
*page == self.current_page
}
}
impl IntoResponse for UserUrlsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use crate::models::TenantUser;
use askama::Template;
use axum::response::{Html, IntoResponse, Response};
#[derive(Template)]
#[template(path = "users.html")]
pub struct UsersTemplate {
pub admin_username: String,
pub users: Vec<TenantUser>,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for UsersTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+1
View File
@@ -3,6 +3,7 @@ pub mod network;
pub mod random;
pub mod system;
pub mod time;
pub mod validation;
pub use hashing::sha256_hash;
pub use network::get_client_ip;
+19
View File
@@ -0,0 +1,19 @@
pub fn validate_custom_slug(slug: &str) -> bool {
if !slug.starts_with('!') {
return false;
}
let rest = &slug[1..];
if rest.is_empty() || rest.len() > 24 {
return false;
}
rest.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
}
pub fn validate_redirect_code(code: &str) -> bool {
(code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
pub fn validate_page_code(code: &str) -> bool {
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
+4871 -93
View File
File diff suppressed because it is too large. Load diff
+52 -19
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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;
+991
View File
@@ -0,0 +1,991 @@
use axum::{
extract::{Path, State},
http::StatusCode,
response::{IntoResponse, Json, Response},
};
use chrono::Utc;
use rusqlite::OptionalExtension;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::auth::ApiUser;
use crate::models::ApiActor;
use crate::state::AppState;
#[derive(Serialize, Deserialize)]
pub struct CreateUserRequest {
pub username: String,
pub password: String,
pub account_type: Option<String>,
pub metadata: Option<String>,
}
#[derive(Serialize)]
pub struct UserResponse {
pub id: i64,
pub username: String,
pub status: String,
pub account_type: String,
pub created_at: String,
pub metadata: Option<String>,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserStatusRequest {
pub status: String,
}
#[derive(Serialize, Deserialize)]
pub struct UpdateUserQuotasRequest {
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ResetPasswordRequest {
pub password: String,
}
#[derive(Serialize, Deserialize)]
pub struct TransferSlugRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Serialize, Deserialize)]
pub struct ModerateSlugRequest {
pub slug: String,
pub action: String, // 'flagged', 'disabled', 'active'
pub severity: String, // 'low', 'medium', 'high', 'critical'
pub reason: String,
}
#[derive(Serialize)]
pub struct ModerationEventResponse {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Serialize, Deserialize)]
pub struct ChangeOwnPasswordRequest {
pub old_password: String,
pub new_password: String,
}
#[derive(Serialize, Deserialize)]
pub struct CreateApiTokenRequest {
// No request body needed, token is generated securely
}
#[derive(Serialize)]
pub struct CreateApiTokenResponse {
pub id: i64,
pub token: String, // Cleartext token returned once
pub created_at: String,
}
// Helper: Ensure the request actor is an Admin
#[allow(clippy::result_large_err)]
fn require_admin_role(user: &ApiUser) -> Result<&crate::models::User, Response> {
match &user.0 {
ApiActor::Admin(admin) => Ok(admin),
_ => Err((StatusCode::FORBIDDEN, "Admin privileges required").into_response()),
}
}
// --- Admin: User CRUD Endpoints ---
// GET /api/v1/admin/users
pub async fn admin_list_users(State(state): State<AppState>, user: ApiUser) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::list_users(&conn) {
Ok(users) => {
let resp: Vec<UserResponse> = users
.into_iter()
.map(|u| UserResponse {
id: u.id,
username: u.username,
status: u.status,
account_type: u.account_type,
created_at: u.created_at,
metadata: u.metadata,
})
.collect();
Json(resp).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users
pub async fn admin_create_user(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<CreateUserRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// Username validation: minimum 3 chars, alphanumeric, hyphen, underscore
let username = payload.username.trim().to_lowercase();
if username.len() < 3 {
return (
StatusCode::BAD_REQUEST,
"Username must be at least 3 characters",
)
.into_response();
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return (
StatusCode::BAD_REQUEST,
"Username must contain only alphanumeric characters, hyphens, or underscores",
)
.into_response();
}
// Hash password
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
let account_type = payload.account_type.as_deref().unwrap_or("standard");
match crate::db::users::create_user(
&conn,
&username,
&hash,
account_type,
payload.metadata.as_deref(),
) {
Ok(new_user) => {
// Write system audit event
{
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_CREATION",
"user",
&new_user.id.to_string(),
Some(&format!("Username: {}", new_user.username)),
);
}
Json(UserResponse {
id: new_user.id,
username: new_user.username,
status: new_user.status,
account_type: new_user.account_type,
created_at: new_user.created_at,
metadata: new_user.metadata,
})
.into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
(StatusCode::CONFLICT, "Username already exists").into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/status
pub async fn admin_update_user_status(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserStatusRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let status = payload.status.trim().to_lowercase();
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid user status").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_status(&conn, target_id, &status) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_STATUS_UPDATE",
"user",
&target_id.to_string(),
Some(&format!("New Status: {}", status)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// PUT /api/v1/admin/users/:id/quotas
pub async fn admin_update_user_quotas(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<UpdateUserQuotasRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_quotas(
&conn,
target_id,
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb,
) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_QUOTA_UPDATE",
"user",
&target_id.to_string(),
Some(&format!(
"max_urls: {}, max_landings: {}, max_api_tokens: {}, max_storage_mb: {}",
payload.max_urls,
payload.max_landings,
payload.max_api_tokens,
payload.max_storage_mb
)),
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/admin/users/:id/password
pub async fn admin_reset_user_password(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
Json(payload): Json<ResetPasswordRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let hash = match crate::auth::password::hash_password(&payload.password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&conn, target_id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"USER_PASSWORD_RESET",
"user",
&target_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
pub fn delete_user_resources(
state: &AppState,
target_id: i64,
admin_username: &str,
force: bool,
) -> Result<(), String> {
if target_id == 1 && !force {
return Err("Deleting legacy_admin system account requires force flag".to_string());
}
let user_details = {
let conn = state.users_db.lock().unwrap();
match crate::db::users::get_user_by_id(&conn, target_id) {
Ok(Some(u)) => u,
Ok(None) => return Err("User not found".to_string()),
Err(e) => return Err(e.to_string()),
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = state.system_db.lock().unwrap();
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
let slugs: Vec<String> = {
let mut stmt = tx
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([target_id], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, target_id, now, admin_username],
);
}
tx.commit()
.map_err(|e| format!("Failed to release slugs: {}", e))?;
}
let user_dir = state
.config
.data_dir
.join("users")
.join(target_id.to_string());
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
let conn = state.users_db.lock().unwrap();
crate::db::users::delete_user(&conn, target_id).map_err(|e| e.to_string())?;
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"USER_DELETION",
"user",
&target_id.to_string(),
Some(&format!("Username: {}", user_details.username)),
);
Ok(())
}
// DELETE /api/v1/admin/users/:id
pub async fn admin_delete_user(
State(state): State<AppState>,
user: ApiUser,
Path(target_id): Path<i64>,
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let force = params.get("force").map(|v| v == "true").unwrap_or(false);
match delete_user_resources(&state, target_id, &admin.username, force) {
Ok(_) => StatusCode::OK.into_response(),
Err(err) if err == "User not found" => StatusCode::NOT_FOUND.into_response(),
Err(err) if err == "Deleting legacy_admin system account requires force flag" => {
(StatusCode::BAD_REQUEST, err).into_response()
}
Err(err) => (StatusCode::INTERNAL_SERVER_ERROR, err).into_response(),
}
}
// --- Admin: Slug Transfer ---
// POST /api/v1/admin/transfers
pub async fn admin_transfer_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<TransferSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
// 1. Check if the slug exists and get details
let (old_owner_user_id, target_type, _target_id) = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let row_opt = stmt
.query_row([&payload.slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional();
match row_opt {
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
};
if old_owner_user_id == payload.new_owner_user_id {
return (
StatusCode::BAD_REQUEST,
"New owner must be different from the current owner",
)
.into_response();
}
// 2. Fetch destination databases
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load current owner's database",
)
.into_response()
}
};
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to load new owner's database",
)
.into_response()
}
};
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
let mut new_target_id = String::new();
let transfer_success = {
let old_conn = old_dbs.content.lock().unwrap();
let new_conn = new_dbs.content.lock().unwrap();
if target_type == "url" {
// Get URL record
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
Ok(u) => u,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(url) = url_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_urls >= quota.max_urls {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded URL quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
);
match ins_res {
Ok(new_url) => {
new_target_id = new_url.id;
// Delete from old owner database
let _ = crate::db::content::delete_url(&old_conn, &url.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy URL to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else if target_type == "page" {
// Get page record
let page_opt =
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
Ok(p) => p,
Err(e) => {
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
};
if let Some(page) = page_opt {
// Check new owner quotas
let new_users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_landings >= quota.max_landings {
return (
StatusCode::BAD_REQUEST,
"New owner has exceeded landing page quota limit",
)
.into_response();
}
}
// Insert into new owner database
let ins_res = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
);
match ins_res {
Ok(new_page) => {
new_target_id = new_page.id;
// Delete from old owner database
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
true
}
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Failed to copy Page to new owner: {}", e),
)
.into_response();
}
}
} else {
false
}
} else {
false
}
};
if !transfer_success {
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
}
// 4. Update system global_slugs, slug_history and adjust quotas
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![payload.new_owner_user_id, new_target_id, now, payload.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
);
// Adjust quotas
let users_conn = state.users_db.lock().unwrap();
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ = crate::db::users::increment_quota_counter(
&users_conn,
payload.new_owner_user_id,
field,
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"SLUG_TRANSFER",
"slug",
&payload.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, payload.new_owner_user_id
)),
);
}
StatusCode::OK.into_response()
}
// --- Admin: Content Moderation ---
// POST /api/v1/admin/moderation
pub async fn admin_moderate_slug(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ModerateSlugRequest>,
) -> Response {
let admin = match require_admin_role(&user) {
Ok(a) => a,
Err(err_resp) => return err_resp,
};
let action = payload.action.trim().to_lowercase();
if !["flagged", "disabled", "active"].contains(&action.as_str()) {
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
}
// 1. Verify slug and get owner user ID
let (owner_user_id, target_type) = {
let system_conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&payload.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt {
Some(r) => r,
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
}
};
// Resolve owner username for log snapshot
let owner_username = {
let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
.unwrap_or(None)
.map(|u| u.username)
};
// 2. Perform moderation update in global_slugs
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![action, now, payload.slug],
);
// Record moderation event
let event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
admin.username,
owner_user_id,
owner_username,
target_type,
payload.slug,
action,
payload.severity,
payload.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&admin.username,
"CONTENT_MODERATION",
"slug",
&payload.slug,
Some(&format!("Action: {}, Reason: {}", action, payload.reason)),
);
}
StatusCode::OK.into_response()
}
// GET /api/v1/admin/moderation/events
pub async fn admin_list_moderation_events(
State(state): State<AppState>,
user: ApiUser,
) -> Response {
if let Err(err_resp) = require_admin_role(&user) {
return err_resp;
}
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason
FROM moderation_events ORDER BY timestamp DESC;"
) {
Ok(s) => s,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let rows = stmt.query_map([], |row| {
Ok(ModerationEventResponse {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
});
match rows {
Ok(mapped) => {
let events: Vec<ModerationEventResponse> = mapped.filter_map(|r| r.ok()).collect();
Json(events).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// --- User: Dashboard, profile settings, API tokens ---
// GET /api/v1/user/profile
pub async fn user_get_profile(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Profile endpoints are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
Json(serde_json::json!({
"id": tenant_user.id,
"username": tenant_user.username,
"status": tenant_user.status,
"account_type": tenant_user.account_type,
"created_at": tenant_user.created_at,
"metadata": tenant_user.metadata,
"quotas": quotas,
}))
.into_response()
}
// POST /api/v1/user/password
pub async fn user_change_password(
State(state): State<AppState>,
user: ApiUser,
Json(payload): Json<ChangeOwnPasswordRequest>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"Change password is for tenant users only",
)
.into_response();
}
};
// Verify old password
if !crate::auth::password::verify_password(&payload.old_password, &tenant_user.password_hash) {
return (StatusCode::UNAUTHORIZED, "Invalid current password").into_response();
}
// Hash new password
let hash = match crate::auth::password::hash_password(&payload.new_password) {
Ok(h) => h,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Hashing error: {}", e),
)
.into_response()
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&users_conn, tenant_user.id, &hash) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"PASSWORD_CHANGE",
"user",
&tenant_user.id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// GET /api/v1/user/api-tokens
pub async fn user_list_api_tokens(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::list_user_api_tokens(&users_conn, tenant_user.id) {
Ok(tokens) => Json(tokens).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// POST /api/v1/user/api-tokens
pub async fn user_create_api_token(State(state): State<AppState>, user: ApiUser) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
// 1. Quota check
let users_conn = state.users_db.lock().unwrap();
let quotas = crate::db::users::get_user_quotas(&users_conn, tenant_user.id).unwrap_or(None);
if let Some(quota) = quotas {
if quota.current_api_tokens >= quota.max_api_tokens {
return (StatusCode::BAD_REQUEST, "API tokens quota limit exceeded").into_response();
}
}
// 2. Generate secure token
let token_secret = format!("bzo_{}", crate::auth::session::generate_token(16)); // bzo_ followed by 32 hex chars
// Hash token using SHA-256 for storing
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(token_secret.as_bytes());
let token_hash = hex::encode(hasher.finalize());
match crate::db::users::create_user_api_token(&users_conn, tenant_user.id, &token_hash) {
Ok(api_token) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_CREATION",
"api_token",
&api_token.id.to_string(),
None,
);
Json(CreateApiTokenResponse {
id: api_token.id,
token: token_secret, // Return cleartext once
created_at: api_token.created_at,
})
.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
// DELETE /api/v1/user/api-tokens/:id
pub async fn user_delete_api_token(
State(state): State<AppState>,
user: ApiUser,
Path(token_id): Path<i64>,
) -> Response {
let tenant_user = match user.0 {
ApiActor::User(u) => u,
ApiActor::Admin(_) => {
return (
StatusCode::BAD_REQUEST,
"API tokens are for tenant users only",
)
.into_response();
}
};
let users_conn = state.users_db.lock().unwrap();
match crate::db::users::delete_user_api_token(&users_conn, token_id, tenant_user.id) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&tenant_user.username,
"API_TOKEN_DELETION",
"api_token",
&token_id.to_string(),
None,
);
StatusCode::OK.into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
+108 -4
View File
@@ -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);
@@ -77,3 +125,59 @@ pub async fn resolve_page(
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
}
}
// GET /
// Serve static root landing page from www/index.html
pub async fn root_landing() -> Response {
let mut target_path = std::path::PathBuf::from("www/index.html");
if !target_path.exists() {
// Search relative to executable
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let path1 = exe_dir.join("www/index.html");
if path1.exists() {
target_path = path1;
} else if let Some(parent1) = exe_dir.parent() {
let path2 = parent1.join("www/index.html");
if path2.exists() {
target_path = path2;
} else if let Some(parent2) = parent1.parent() {
let path3 = parent2.join("www/index.html");
if path3.exists() {
target_path = path3;
}
}
}
}
}
}
if !target_path.exists() {
// Search in CARGO_MANIFEST_DIR
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let path = std::path::PathBuf::from(manifest_dir).join("www/index.html");
if path.exists() {
target_path = path;
}
}
}
match std::fs::read_to_string(&target_path) {
Ok(content) => Html(content).into_response(),
Err(_) => (StatusCode::NOT_FOUND, "Not Found").into_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
View File
@@ -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
View File
@@ -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,
+162 -2
View File
@@ -1,12 +1,14 @@
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,
};
pub fn create_router(state: AppState) -> Router {
Router::new()
// --- Root Landing Page ---
.route("/", get(pages::root_landing))
// --- Public Redirection Routes ---
.route("/:code", get(redirect::resolve_redirect))
.route("/p/:code", get(pages::resolve_page))
@@ -20,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(
@@ -35,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),
@@ -46,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",
@@ -57,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",
@@ -102,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
View File
@@ -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;
}
+82
View File
@@ -0,0 +1,82 @@
{% extends "user_layout.html" %}
{% block title %}My API Tokens - BZOD{% endblock %}
{% block active_tokens %}active{% endblock %}
{% block header_title %}API Tokens & Automation{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Token Card -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Generate API Token</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
API tokens allow you to automate link shortening and landing page creations. Generated tokens are hashed immediately; you will only be shown the raw token once.
</p>
{% if let Some(raw_token) = new_token %}
<div style="background: rgba(16, 185, 129, 0.1); border: 1px solid rgba(16, 185, 129, 0.2); padding: 1rem; border-radius: 8px; margin-bottom: 1.25rem; word-break: break-all;">
<span style="display: block; font-size: 0.75rem; color: var(--success-color); font-weight: 700; margin-bottom: 0.25rem; text-transform: uppercase;">Raw Token (Copy now!)</span>
<code style="font-size: 1.1rem; color: var(--text-primary); font-family: monospace; font-weight: 600;">{{ raw_token }}</code>
</div>
{% endif %}
<form action="/api-tokens/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn" style="width: 100%;">Generate New Token</button>
</form>
</div>
<!-- Active Tokens List -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Active API Tokens</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Token ID</th>
<th>Created At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if tokens.is_empty() %}
<tr>
<td colspan="3" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active API tokens generated yet.
</td>
</tr>
{% else %}
{% for t in tokens %}
<tr>
<td>{{ t.id }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ t.created_at }}</td>
<td>
<form action="/api-tokens/revoke/{{ t.id }}" method="POST" onsubmit="return confirm('Revoke this API token? Any applications using it will be blocked.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+144
View File
@@ -0,0 +1,144 @@
{% extends "layout.html" %}
{% block title %}Backup Management - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}Database Backups Console{% endblock %}
{% block header_actions %}
<form action="/admin/backups/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn">Create Backup Archive</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 2fr 1fr; gap: 1.5rem; align-items: start;">
<!-- List of Backup Files -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Available Backup Archives</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Archive File</th>
<th>File Size</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if files.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup archive files found in backups folder.
</td>
</tr>
{% else %}
{% for f in files %}
<tr>
<td>
<strong style="font-family: monospace;">{{ f.filename }}</strong>
</td>
<td>{{ f.size_str }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ f.created_str }}</td>
<td>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<a href="/admin/backups/download/{{ f.filename }}" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Download</a>
<form action="/admin/backups/delete/{{ f.filename }}" method="POST" style="margin: 0;" onsubmit="return confirm('Delete backup file {{ f.filename }}? This cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Restore Database Panel -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem; color: var(--danger-color);">Restore Platform Databases</h3>
<p style="font-size: 0.85rem; color: var(--text-secondary); margin-bottom: 1.25rem; line-height: 1.4;">
To restore the system databases, select a `.tar.gz` backup archive file. Warning: This will overwrite all active user accounts, quotas, links, and analytics data!
</p>
<form action="/admin/backups/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="backup_file">Upload Backup File (.tar.gz)</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" accept=".tar.gz" required>
</div>
<div class="form-group">
<label for="confirm_text">Type RESTORE to continue</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%;">Upload & Restore Now</button>
</form>
</div>
</div>
<!-- Backup History Log -->
<div class="card" style="padding: 0; overflow: hidden; margin-top: 1.5rem;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Backup Audit History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Backup File Path</th>
<th>Status</th>
<th>Size</th>
<th>Error Message</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No backup execution logs found.
</td>
</tr>
{% else %}
{% for h in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ h.created_at[0..19].replace("T", " ") }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ h.backup_path }}</td>
<td>
<span class="badge {% if h.status == "success" %}badge-healthy{% else %}badge-dead{% endif %}">
{{ h.status }}
</span>
</td>
<td>{{ h.size_bytes }} B</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">{{ h.error_message.as_deref().unwrap_or("-") }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+171
View File
@@ -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 %}
+10
View File
@@ -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 -->
+5 -5
View File
@@ -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>
+158
View File
@@ -0,0 +1,158 @@
{% extends "layout.html" %}
{% block title %}Content Moderation - BZOD{% endblock %}
{% block active_moderation %}active{% endblock %}
{% block header_title %}Content Moderation Panel{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- Moderation Form -->
<div class="card">
<h3 style="font-size: 1.15rem; margin-bottom: 1rem;">Moderate a Resource</h3>
<form action="/admin/moderation" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="slug">Resource Slug</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. !hello or abcdef" required>
</div>
<div class="form-group">
<label for="action">Moderation Action</label>
<select id="action" name="action" class="form-input">
<option value="active">Activate (Publicly accessible)</option>
<option value="flagged">Flag (Flagged, visible to admins)</option>
<option value="disabled">Disable (Returns 410 Gone)</option>
<option value="deleted">Delete (Release slug completely)</option>
</select>
</div>
<div class="form-group">
<label for="severity">Severity Level</label>
<select id="severity" name="severity" class="form-input">
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high" selected>High</option>
<option value="critical">Critical</option>
</select>
</div>
<div class="form-group">
<label for="reason">Reason / Notes</label>
<textarea id="reason" name="reason" class="form-input" rows="3" placeholder="Violation detail, abuse report summary..." required></textarea>
</div>
<button type="submit" class="btn">Apply Action</button>
</form>
</div>
<!-- Active Flags & Disabled Resources -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Currently Flagged or Disabled Content</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner ID</th>
<th>Type</th>
<th>Status</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
{% if flagged_items.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No resources are currently flagged or disabled.
</td>
</tr>
{% else %}
{% for item in flagged_items %}
<tr>
<td>
<strong style="color: var(--accent-color);">/{{ item.slug }}</strong>
</td>
<td>{{ item.owner_user_id }}</td>
<td>{{ item.target_type }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ item.updated_at[0..10] }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Moderation Events Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Moderation Action Log</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Operator</th>
<th>Target User</th>
<th>Resource</th>
<th>Action</th>
<th>Severity</th>
<th>Reason</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No moderation actions recorded.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong>{{ log.admin_username }}</strong></td>
<td>ID: {{ log.target_user_id }}</td>
<td><span style="font-family: monospace;">{{ log.resource_type }}:{{ log.resource_identifier }}</span></td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8;">{{ log.action }}</span>
</td>
<td>
<span class="badge" style="background-color: {% if log.severity == "critical" %}rgba(239,68,68,0.2){% else %}rgba(255,255,255,0.05){% endif %}; color: {% if log.severity == "critical" %}var(--danger-color){% else %}var(--text-secondary){% endif %}; font-weight: 700;">
{{ log.severity }}
</span>
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.reason }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+223
View File
@@ -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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+40 -2
View File
@@ -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;">&lt;&lt; First</a>
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/pages?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %}
{% endfor %}
{% if current_page < total_pages %}
<a href="/admin/pages?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+107
View File
@@ -0,0 +1,107 @@
{% extends "layout.html" %}
{% block title %}Quota Management - BZOD{% endblock %}
{% block active_users %}active{% endblock %}
{% block header_title %}User Quota Management{% endblock %}
{% block header_actions %}
<form action="/admin/quotas" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile_all">
<button type="submit" class="btn">Sync & Reconcile All Quotas</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>URLs (Used/Max)</th>
<th>Pages (Used/Max)</th>
<th>API Tokens (Used/Max)</th>
<th>Storage (Used/Max MB)</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if quotas.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No quotas defined.
</td>
</tr>
{% else %}
{% for q in quotas %}
<tr>
<td>
<a href="/admin/users/{{ q.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ q.user_id }}
</a>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_urls }} / {{ q.max_urls }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.urls_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_landings }} / {{ q.max_landings }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.landings_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_api_tokens }} / {{ q.max_api_tokens }}</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.api_tokens_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
<span>{{ q.current_storage_mb }} / {{ q.max_storage_mb }} MB</span>
<div style="background: rgba(255,255,255,0.05); height: 6px; border-radius: 3px; overflow: hidden; width: 120px;">
<div style="background: var(--primary-grad); height: 100%; width: {{ q.storage_pct() }}%;"></div>
</div>
</div>
</td>
<td>
<div style="display: flex; gap: 0.5rem;">
<a href="/admin/users/{{ q.user_id }}/edit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Edit</a>
<form action="/admin/quotas" method="POST" style="margin: 0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="action" value="reconcile">
<input type="hidden" name="user_id" value="{{ q.user_id }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Reconcile</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "layout.html" %}
{% block title %}Active Sessions - BZOD{% endblock %}
{% block active_sessions %}active{% endblock %}
{% block header_title %}Session Administration{% endblock %}
{% block header_actions %}
<form action="/admin/sessions/revoke-all" method="POST" onsubmit="return confirm('Revoke ALL active user sessions? This will log out everyone including you.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger">Revoke All Sessions</button>
</form>
{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div class="card" style="padding: 0; overflow: hidden;">
<div class="table-container">
<table>
<thead>
<tr>
<th>User ID</th>
<th>Session ID (Masked)</th>
<th>Created At</th>
<th>Expires At</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if sessions.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active sessions found in the system.
</td>
</tr>
{% else %}
{% for s in sessions %}
<tr>
<td>
<a href="/admin/users/{{ s.user_id }}" style="color: var(--text-primary); text-decoration: underline; font-weight: 600;">
User ID: {{ s.user_id }}
</a>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ s.id[0..6] }}...
</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.created_at[0..19].replace("T", " ") }}</td>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ s.expires_at[0..19].replace("T", " ") }}</td>
<td>
<form action="/admin/sessions/revoke/{{ s.id }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; color: var(--danger-color);">Revoke</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+21
View File
@@ -105,6 +105,27 @@
Generates a tarball of admin.db, content.db, and analytics.db.
</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>
+181
View File
@@ -0,0 +1,181 @@
{% extends "layout.html" %}
{% block title %}Global Slug Namespace - BZOD{% endblock %}
{% block active_slugs %}active{% endblock %}
{% block header_title %}Global Slug Directory{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<!-- Search & Filtering -->
<div class="card">
<form action="/admin/slugs" method="GET" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; align-items: end;">
<div class="form-group" style="margin-bottom: 0;">
<label for="search">Search Slug</label>
<input type="text" id="search" name="search" class="form-input" placeholder="e.g. !hello" value="{% if let Some(s) = search_filter %}{{ s }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="owner">Owner User ID</label>
<input type="number" id="owner" name="owner" class="form-input" placeholder="e.g. 1" value="{% if let Some(o) = owner_filter %}{{ o }}{% endif %}">
</div>
<div class="form-group" style="margin-bottom: 0;">
<label for="status">Status</label>
<select id="status" name="status" class="form-input">
<option value="" selected>All Statuses</option>
<option value="active" {% if let Some(s) = status_filter %}{% if s == "active" %}selected{% endif %}{% endif %}>active</option>
<option value="flagged" {% if let Some(s) = status_filter %}{% if s == "flagged" %}selected{% endif %}{% endif %}>flagged</option>
<option value="disabled" {% if let Some(s) = status_filter %}{% if s == "disabled" %}selected{% endif %}{% endif %}>disabled</option>
</select>
</div>
<button type="submit" class="btn">Apply Filters</button>
</form>
</div>
<!-- Slugs Directory Table -->
<div class="card" style="padding: 0; overflow: hidden; margin-bottom: 1.5rem;">
<div class="table-container">
<table>
<thead>
<tr>
<th>Slug</th>
<th>Owner (ID)</th>
<th>Resource Type</th>
<th>Resource ID</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if slugs.is_empty() %}
<tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No registered slugs found matching filters.
</td>
</tr>
{% else %}
{% for item in slugs %}
<tr>
<td>
<strong style="color: var(--accent-color); font-family: monospace; font-size: 1.05rem;">/{{ item.slug }}</strong>
</td>
<td>
<a href="/admin/users/{{ item.owner_user_id }}" style="color: var(--text-primary); text-decoration: underline;">
User ID: {{ item.owner_user_id }}
</a>
</td>
<td>{{ item.target_type }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ item.target_id }}</td>
<td>
<span class="badge" style="background-color: {% if item.status == "active" %}rgba(16, 185, 129, 0.15){% else if item.status == "disabled" %}rgba(239, 68, 68, 0.15){% else %}rgba(245, 158, 11, 0.15){% endif %}; color: {% if item.status == "active" %}var(--success-color){% else if item.status == "disabled" %}var(--danger-color){% else %}var(--warning-color){% endif %};">
{{ item.status }}
</span>
</td>
<td>{{ item.created_at[0..10] }}</td>
<td>
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap;">
<!-- Transfer Form -->
<form action="/admin/slugs/transfer" method="POST" style="display: flex; gap: 0.25rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="number" name="new_owner_user_id" placeholder="New ID" required style="width: 80px; padding: 0.35rem 0.5rem; font-size: 0.85rem;">
<button type="submit" class="btn btn-secondary" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Transfer</button>
</form>
<!-- Disable / Enable Form -->
{% if item.status == "active" %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="disabled">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Disable</button>
</form>
{% else %}
<form action="/admin/slugs/status" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<input type="hidden" name="status" value="active">
<button type="submit" class="btn" style="padding: 0.35rem 0.6rem; font-size: 0.85rem; background: rgba(16,185,129,0.1); color: var(--success-color); border: 1px solid rgba(16,185,129,0.2);">Enable</button>
</form>
{% endif %}
<!-- Delete Form -->
<form action="/admin/slugs/delete" method="POST" onsubmit="return confirm('Release slug /{{ item.slug }}? this cannot be undone.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="slug" value="{{ item.slug }}">
<button type="submit" class="btn btn-danger" style="padding: 0.35rem 0.6rem; font-size: 0.85rem;">Delete</button>
</form>
</div>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
<!-- Slug History Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.15rem;">Slug Ownership History</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Slug</th>
<th>Old Owner</th>
<th>New Owner</th>
<th>Action</th>
<th>Admin</th>
</tr>
</thead>
<tbody>
{% if history.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No history records logged.
</td>
</tr>
{% else %}
{% for log in history %}
<tr>
<td style="font-size: 0.85rem; color: var(--text-secondary);">{{ log.timestamp[0..19].replace("T", " ") }}</td>
<td><strong style="font-family: monospace;">/{{ log.slug }}</strong></td>
<td>{% if let Some(old_id) = log.old_owner_user_id %}User ID: {{ old_id }}{% else %}-{% endif %}</td>
<td>{% if let Some(new_id) = log.new_owner_user_id %}User ID: {{ new_id }}{% else %}-{% endif %}</td>
<td>
<span class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary);">{{ log.action }}</span>
</td>
<td>
{% if let Some(admin) = log.admin_username %}
{{ admin }}
{% else %}
System
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+252
View File
@@ -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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+63 -1
View File
@@ -33,6 +33,11 @@
<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 (! 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>
@@ -63,6 +68,30 @@
<label for="max_access_count">Access Limit / One-Time (optional)</label>
<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>
@@ -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;">&lt;&lt; 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;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
{% endif %}
{% for p in visible_pages %}
{% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %}
<a href="/admin/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 &gt;</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 &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+99
View File
@@ -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 %}
+71
View File
@@ -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 %}
+80
View File
@@ -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 %}
+257
View File
@@ -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;">
&larr; 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 %}
+65
View File
@@ -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;">
&larr; 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 %}
+50
View File
@@ -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 %}
+87
View File
@@ -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 %}
+162
View File
@@ -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>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</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;">&lt;&lt; First</a>
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; 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 &gt;</a>
<a href="/user/pages?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
+65
View File
@@ -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 %}
+161
View File
@@ -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 %}
+89
View File
@@ -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 %}
+145
View File
@@ -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;">&lt;&lt; First</a>
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">&lt; 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 &gt;</a>
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last &gt;&gt;</a>
{% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last &gt;&gt;</span>
{% endif %}
</div>
</div>
</div>
{% endblock %}
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff. Show more