Author SHA1 Message Date
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
38 changed files with 4311 additions and 317 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/
+65 -8
View File
@@ -1,4 +1,4 @@
name: Rust
name: Rust CI
on:
push:
@@ -8,15 +8,72 @@ on:
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt, clippy
- name: Cache Cargo
uses: Swatinem/rust-cache@v2
- name: Check formatting
run: cargo fmt -- --check
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
docker:
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write # Required for GHCR push
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (tags, labels)
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: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: ${{ github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Generated
+14
View File
@@ -289,6 +289,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
@@ -462,6 +463,7 @@ dependencies = [
"clap",
"dotenvy",
"flate2",
"futures-util",
"hex",
"image",
"qrcode",
@@ -850,6 +852,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 +876,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
+3 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
[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"] }
@@ -29,3 +29,5 @@ toml = "0.8"
qrcode = "0.14"
image = "0.25"
zip = { version = "2.1", default-features = false, features = ["deflate"] }
futures-util = "0.3"
+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"]
+323 -86
View File
@@ -1,47 +1,54 @@
# nx9-url-shortener
# nx9-url-shortener (the BZOD daemon)
**A lightweight, self-hosted URL management platform written in Rust.**
nx9-url-shortener combines URL shortening, QR code generation, password-protected links, smart preview pages, analytics, audit logging, and lifecycle management into a single self-hosted application with zero external dependencies.
BZOD combines URL shortening, landing pages, QR code generation, password-protected links, smart preview pages, analytics, audit logging, lifecycle management, backup/restore, and API automation into a single self-hosted application with zero external service dependencies.
Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for individuals, organizations, homelab operators, and businesses that want complete control over their links, analytics, and branding.
Built with Rust, SQLite, Axum, and Askama, BZOD is designed for individuals, organizations, homelab operators, government agencies, and businesses that want complete ownership of their links, analytics, and branding.
---
## Highlights
### v0.2.0
### v0.3.0
* QR code generation (PNG and SVG)
* QR scan analytics
* 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
* Link expiration
* Audit trail
* Bulk operations
* Expanded test coverage
* Improved health monitoring
* Analytics dashboard
* Audit logging
* Health monitoring
---
## Feature Matrix
| Feature | Status |
| ------------------------- | ------ |
|---------------------------| ------ |
| URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ |
| QR Analytics | ✅ |
| Password-Protected Links | ✅ |
| Smart Preview Pages | ✅ |
| Link Expiration | ✅ |
| One-Time Links | ✅ |
| One/Multi-Time Links | ✅ |
| Audit Trail | ✅ |
| Bulk Operations | ✅ |
| Analytics Dashboard | ✅ |
| Health Monitoring | ✅ |
| REST API | ✅ |
| CSV Import/Export | 🚧 |
| Backup & Restore | ✅ |
| UTM Campaign Builder | ✅ |
| CLI Automation | ✅ |
| Geo Analytics | 🚧 |
| Multi-User Administration | 🚧 |
| SSO | 🚧 |
@@ -52,22 +59,128 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
### 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/!office
```
Landing Page
```
https://bzo.in/p/!company-profile
```
---
## CLI
```
bzod shorten https://example.com
bzod shorten https://example.com --slug !office
bzod expand !office
```
---
### Landing Pages
Create standalone landing pages hosted directly by BZOD.
Generated page:
```text
https://your-domain/p/1a2b
```
Custom slug page:
```text
https://your-domain/p/!company-profile
https://your-domain/p/!product-launch
```
Features:
* Raw HTML support
* SEO slug support
* Published / Draft states
* Custom paths
* Open Graph metadata
---
### CLI Commands
Core commands
```bash
bzod serve
bzod create-admin
bzod doctor
bzod stats
```
URL management
```bash
bzod shorten https://example.com
bzod shorten https://example.com --slug !campaign
bzod expand 1bb170
bzod expand !campaign
```
Maintenance
```bash
bzod backup
bzod restore --file backup_20250614.tar.gz
bzod migrate
```
### QR Code Generation
Generate QR codes for every short URL.
@@ -88,20 +201,20 @@ Features:
### Password-Protected Links
Protect sensitive links using Argon2id-hashed passwords.
Protect sensitive links using Argon2id password hashing.
Features:
* Password gate
* Secure session handling
* Configurable protection
* Access restrictions
* Audit logging
---
### Smart Preview Pages
Display branded preview pages before redirecting.
Display branded preview pages before redirecting visitors.
Features:
@@ -113,18 +226,6 @@ Features:
---
### Landing Pages
Create standalone landing pages using dedicated page identifiers.
Example:
```text
https://your-short-domain/p/1a2b
```
---
### Link Lifecycle Management
Control link validity.
@@ -132,10 +233,32 @@ Control link validity.
Features:
* Expiration dates
* Automatic expiry jobs
* One-time links
* Maximum access limits
* Administrative disabling
* Access limits
* Administrative disable
* Automated expiry jobs
---
### UTM Campaign Builder
Append campaign tracking parameters when creating links.
Supported parameters:
```text
utm_source
utm_medium
utm_campaign
```
Example output:
```text
https://example.com/page?utm_source=email&utm_medium=newsletter&utm_campaign=launch
```
No additional database schema changes are required.
---
@@ -145,7 +268,7 @@ Track:
* Total visits
* QR scans
* Country statistics
* Countries
* Referrers
* User agents
* Daily statistics
@@ -156,39 +279,46 @@ Track:
### Audit Trail
Track administrative actions including:
Track administrative activity.
Recorded events include:
* Login
* Logout
* URL creation
* URL updates
* URL deletion
* QR operations
* Backup creation
* Restore operations
* QR exports
* Configuration changes
---
### Administrative Dashboard
Web-based administration interface featuring:
Web-based management interface.
* URL management
* QR code management
* Landing page management
* Preview page management
Features:
* URL registry
* Landing pages
* QR management
* Analytics
* API token management
* Audit logs
* Link expiration controls
* Backup utilities
* Restore utilities
* Health monitoring
* Analytics dashboard
* SVG charts
* Bulk operations
* Server diagnostics
---
### API Support
### REST API
REST API endpoints for automation and integration.
REST API support for automation and integrations.
Endpoint prefix:
```text
/api/v1/*
@@ -198,22 +328,88 @@ Supports:
* URL creation
* URL management
* Landing pages
* QR generation
* Analytics access
* Bulk operations
---
### CLI Automation
Create and manage links directly from the command line.
Examples:
Create automatic code:
```bash
bzod shorten https://example.com
```
Create custom slug:
```bash
bzod shorten https://example.com --slug !office
```
Expand code:
```bash
bzod expand 1bb170
```
Expand custom slug:
```bash
bzod expand !office
```
---
### Backup & Restore
BZOD includes integrated backup and restore functionality through both the CLI and Web UI.
CLI:
```bash
bzod backup
bzod restore --file backup.tar.gz
```
Web UI:
```text
Settings → Maintenance & DB Utilities
```
Features:
* Compressed tar.gz backups
* Full database restoration
* Backup validation
* Disaster recovery support
* No external tools required
Protected databases:
* admin.db
* content.db
* analytics.db
* system.db
---
### Security
* Password-protected administration interface
* Password-protected administration
* Password-protected links
* Argon2id password hashing
* Session management
* CSRF protection
* Session management
* API token authentication
* Audit logging
* Link access controls
* Access controls
---
@@ -235,7 +431,7 @@ No:
* PostgreSQL
* MongoDB
* Kubernetes
* External SaaS
* SaaS dependencies
---
@@ -243,21 +439,19 @@ No:
### Databases
nx9-url-shortener uses four SQLite databases.
BZOD uses four SQLite databases.
| Database | Purpose |
| ------------ | ------------------------------------------------- |
| admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, preview pages, tags |
| analytics.db | Visits, QR scans, statistics |
| system.db | Audit events, jobs, migrations, health monitoring |
| Database | Purpose |
| ------------ | ------------------------------ |
| admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, metadata |
| analytics.db | Visits, QR scans, statistics |
| system.db | Audit events, jobs, monitoring |
---
## Initial Setup
Create an administrator account:
### Native Installation
```bash
@@ -267,11 +461,40 @@ cargo run -- create-admin
### Docker
```bash
docker exec -it nx9-url-shortener nx9-url-shortener create-admin
docker exec -it bzod bzod create-admin
```
---
## Disaster Recovery Validation
The backup and restore system has been validated through a complete recovery workflow.
Validation procedure:
1. Create backup archive
2. Stop application
3. Restore backup
4. Restart application
5. Verify application integrity
Verified components:
* URL registry
* Landing pages
* Analytics
* Audit logs
* API tokens
* QR assets
* Settings
* Health monitoring
Expected outcome:
The application returns to a fully operational state without data loss.
---
## Screenshots
### Dashboard
@@ -298,22 +521,22 @@ docker exec -it nx9-url-shortener nx9-url-shortener create-admin
## Docker Deployment
### Build
Build:
```bash
docker compose build
```
### Start
Start:
```bash
docker compose up -d
```
### Logs
Logs:
```bash
docker logs -f nx9-url-shortener
docker logs -f bzod
```
---
@@ -322,9 +545,9 @@ docker logs -f nx9-url-shortener
```yaml
services:
nx9-url-shortener:
bzod:
build: .
container_name: nx9-url-shortener
container_name: bzod
restart: unless-stopped
ports:
@@ -344,25 +567,25 @@ services:
## Development
### Build
Build:
```bash
cargo build
```
### Run
Run:
```bash
cargo run -- serve
```
### Create Administrator
Create administrator:
```bash
cargo run -- create-admin
```
### Run Tests
Run tests:
```bash
cargo test
@@ -370,6 +593,18 @@ cargo test
---
## Development & Testing
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure
```text
@@ -393,20 +628,17 @@ src/
Planned features:
* Vanity URLs
* CSV import/export
* Geo analytics
* Multi-user administration
* SSO integration
* Signed temporary links
* OpenAPI documentation
* Webhook support
---
## Production Deployment
Recommended stack:
Recommended architecture:
```text
Internet
@@ -415,7 +647,7 @@ Internet
Nginx Proxy Manager
│
▼
nx9-url-shortener
BZOD
│
▼
SQLite
@@ -423,6 +655,12 @@ SQLite
HTTPS is strongly recommended.
---
## Documentation
- [Testing Guide](docs/TESTING.md)
- [Docker Deployment Guide](docs/DOCKER-Deploy.md)
---
## License
@@ -436,4 +674,3 @@ Apache License 2.0
Sunil Purushottam Thakare
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+520
View File
@@ -0,0 +1,520 @@
# 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.
---
# 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
```
---
# 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
```
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.
+421
View File
@@ -0,0 +1,421 @@
# 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
---
# 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.
+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()),
}
}
+19
View File
@@ -3,9 +3,11 @@ 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;
@@ -72,4 +74,21 @@ 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>,
},
}
+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(())
+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()),
}
}
+221
View File
@@ -508,6 +508,227 @@ 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 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")?,
})
})?;
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 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")?,
})
})?;
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";
+14
View File
@@ -51,6 +51,20 @@ 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);", []);
}
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);
+10
View File
@@ -44,6 +44,16 @@ 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?;
}
}
Ok(())
+108
View File
@@ -0,0 +1,108 @@
use crate::models::{LandingPage, Url};
use askama::Template;
use axum::{
http::StatusCode,
response::{Html, IntoResponse, Response},
};
#[derive(Clone, Debug)]
pub struct VisitorLogEntry {
pub sr: usize,
pub timestamp: String,
pub ip_address: String,
pub country: String,
pub referrer: String,
pub browser: String,
pub user_agent: String,
pub utm_source: String,
pub utm_campaign: String,
}
#[derive(Template)]
#[template(path = "url_analytics.html")]
pub struct UrlAnalyticsTemplate {
pub admin_username: String,
pub url: Url,
pub total_clicks: i64,
pub unique_visitors: i64,
pub qr_scans: i64,
pub direct_clicks: i64,
pub traffic_chart: String,
pub monthly_chart: String,
pub countries_chart: String,
pub referrers_chart: String,
pub browsers_chart: String,
// Paginated visitor logs
pub visits: Vec<VisitorLogEntry>,
pub current_page: usize,
pub total_pages: usize,
pub visible_pages: Vec<usize>,
pub total_records: i64,
pub page_start: usize,
pub page_end: usize,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
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(),
}
}
}
+2
View File
@@ -1,9 +1,11 @@
pub mod analytics;
pub mod dashboard;
pub mod pages;
pub mod settings;
pub mod stats;
pub mod urls;
pub use analytics::{PageAnalyticsTemplate, UrlAnalyticsTemplate, VisitorLogEntry};
pub use dashboard::DashboardTemplate;
pub use pages::PagesTemplate;
pub use settings::SettingsTemplate;
+10
View File
@@ -12,8 +12,18 @@ 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 {
fn into_response(self) -> Response {
match self.render() {
+10
View File
@@ -14,8 +14,18 @@ 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 {
fn into_response(self) -> Response {
match self.render() {
+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)
}
+991 -17
View File
File diff suppressed because it is too large. Load diff
+38 -5
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,
@@ -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();
+44 -1
View File
@@ -21,7 +21,7 @@ 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();
}
@@ -77,3 +77,46 @@ 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(),
}
}
+1 -1
View File
@@ -72,7 +72,7 @@ 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();
}
+2 -2
View File
@@ -24,8 +24,8 @@ 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();
}
+9
View File
@@ -7,6 +7,8 @@ use axum::{
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))
@@ -35,6 +37,12 @@ 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("/admin/analytics/url/:id/export/csv", get(admin::url_analytics_csv_export))
.route("/admin/analytics/url/:id/export/json", get(admin::url_analytics_json_export))
.route("/admin/analytics/page/:id", get(admin::page_analytics_get))
.route("/admin/analytics/page/:id/export/csv", get(admin::page_analytics_csv_export))
.route("/admin/analytics/page/:id/export/json", get(admin::page_analytics_json_export))
.route("/admin/settings", get(admin::settings_get))
.route(
"/admin/settings/password",
@@ -46,6 +54,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",
+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 %}
+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>
+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 %}
+156
View File
@@ -0,0 +1,156 @@
use rusqlite::Connection;
use bzod::db::migrations::{run_migrations, ANALYTICS_MIGRATIONS};
use bzod::db::analytics::{
get_visits_schema_columns, get_target_visits_paginated,
get_target_visits_all_in_memory, get_target_visit_total_filtered,
parse_ua, clean_referrer, insert_visits_batch
};
use bzod::models::VisitRecord;
fn setup_analytics_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_advanced_analytics_schema_columns() {
let conn = setup_analytics_db();
let columns = get_visits_schema_columns(&conn).unwrap();
assert!(columns.contains("id"));
assert!(columns.contains("target_type"));
assert!(columns.contains("target_id"));
assert!(columns.contains("timestamp"));
assert!(columns.contains("ip_address"));
assert!(columns.contains("user_agent"));
assert!(columns.contains("referer"));
assert!(columns.contains("country"));
assert!(columns.contains("status_code"));
// Verify UTM columns don't exist by default
assert!(!columns.contains("utm_source"));
assert!(!columns.contains("utm_campaign"));
}
#[test]
fn test_advanced_analytics_pagination_and_sorting() {
let mut conn = setup_analytics_db();
let target_uuid = "target-123";
// Insert 55 visits to test pagination bounds (ANALYTICS_PAGE_SIZE = 50)
let mut records = Vec::new();
for i in 1..=55 {
// Stagger timestamps so they are ordered deterministically
let time_str = format!("2026-06-17T12:00:{:02}Z", i);
records.push(VisitRecord {
id: format!("visit-{}", i),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: time_str,
ip_address: "1.1.1.1".to_string(),
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
});
}
insert_visits_batch(&mut conn, &records).unwrap();
// Verify total count
let total = get_target_visit_total_filtered(&conn, "url", target_uuid, None, None).unwrap();
assert_eq!(total, 55);
// Page 1 (limit 50, offset 0)
let page1 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 0, None, None).unwrap();
assert_eq!(page1.len(), 50);
// Ordered DESC: first record returned should be the latest (visit-55)
assert_eq!(page1[0].id, "visit-55");
assert_eq!(page1[49].id, "visit-6");
// Page 2 (limit 50, offset 50)
let page2 = get_target_visits_paginated(&conn, "url", target_uuid, 50, 50, None, None).unwrap();
assert_eq!(page2.len(), 5);
assert_eq!(page2[0].id, "visit-5");
assert_eq!(page2[4].id, "visit-1");
}
#[test]
fn test_advanced_analytics_date_filtering() {
let mut conn = setup_analytics_db();
let target_uuid = "target-456";
// Insert records across different days
let records = vec![
VisitRecord {
id: "v1".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: "2026-06-01T10:00:00Z".to_string(),
ip_address: "1.1.1.1".to_string(),
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
VisitRecord {
id: "v2".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: "2026-06-15T10:00:00Z".to_string(),
ip_address: "1.1.1.1".to_string(),
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
VisitRecord {
id: "v3".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: "2026-06-30T10:00:00Z".to_string(),
ip_address: "1.1.1.1".to_string(),
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
];
insert_visits_batch(&mut conn, &records).unwrap();
// 1. Filter with date_from = "2026-06-15" (inclusive) -> should get v2 and v3
let count1 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap();
assert_eq!(count1, 2);
let results1 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), None).unwrap();
assert_eq!(results1.len(), 2);
assert_eq!(results1[0].id, "v3"); // DESC sorting
assert_eq!(results1[1].id, "v2");
// 2. Filter with date_to = "2026-06-15" (inclusive) -> should get v1 and v2
let count2 = get_target_visit_total_filtered(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap();
assert_eq!(count2, 2);
let results2 = get_target_visits_all_in_memory(&conn, "url", target_uuid, None, Some("2026-06-15")).unwrap();
assert_eq!(results2.len(), 2);
assert_eq!(results2[0].id, "v2");
assert_eq!(results2[1].id, "v1");
// 3. Filter with both date_from and date_to = "2026-06-15"
let count3 = get_target_visit_total_filtered(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap();
assert_eq!(count3, 1);
let results3 = get_target_visits_all_in_memory(&conn, "url", target_uuid, Some("2026-06-15"), Some("2026-06-15")).unwrap();
assert_eq!(results3.len(), 1);
assert_eq!(results3[0].id, "v2");
}
#[test]
fn test_parse_ua_and_referrer_helpers() {
let (browser, os, device) = parse_ua("Mozilla/5.0 (iPhone; CPU iPhone OS 16_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Mobile/15E148 Safari/605.1.15");
assert_eq!(browser, "Safari");
assert_eq!(os, "iOS");
assert_eq!(device, "Mobile");
let referrer = clean_referrer("https://news.ycombinator.com/item?id=12345");
assert_eq!(referrer, "news.ycombinator.com");
}
+155
View File
@@ -0,0 +1,155 @@
use rusqlite::Connection;
use chrono::Utc;
use bzod::db::migrations::{run_migrations, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS};
use bzod::db::content::{create_url_extended, get_url_count_by_tag, list_urls, get_url_counts};
use bzod::db::analytics::{
get_target_visit_count, get_target_unique_visitors, get_monthly_clicks_trend,
insert_visits_batch,
};
use bzod::models::VisitRecord;
fn setup_content_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "content", CONTENT_MIGRATIONS, None).unwrap();
conn
}
fn setup_analytics_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "analytics", ANALYTICS_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_get_url_count_by_tag() {
let conn = setup_content_db();
// Verify tag count on empty db
let count = get_url_count_by_tag(&conn, "promo").unwrap();
assert_eq!(count, 0);
// Create a URL and associate tags
create_url_extended(
&conn,
"code1",
"https://example.com/1",
None,
None,
&["promo".to_string(), "tech".to_string()],
None,
None,
None,
)
.unwrap();
create_url_extended(
&conn,
"code2",
"https://example.com/2",
None,
None,
&["promo".to_string()],
None,
None,
None,
)
.unwrap();
assert_eq!(get_url_count_by_tag(&conn, "promo").unwrap(), 2);
assert_eq!(get_url_count_by_tag(&conn, "tech").unwrap(), 1);
assert_eq!(get_url_count_by_tag(&conn, "other").unwrap(), 0);
}
#[test]
fn test_pagination_limits_and_offset() {
let conn = setup_content_db();
// Create 27 URLs to test page pagination limits of PAGE_SIZE = 25
for i in 0..27 {
create_url_extended(
&conn,
&format!("cd{:03}", i),
&format!("https://example.com/{}", i),
None,
None,
&[],
None,
None,
None,
)
.unwrap();
}
let (total, _, _) = get_url_counts(&conn).unwrap();
assert_eq!(total, 27);
// Page 1 (limit 25, offset 0)
let page1_urls = list_urls(&conn, 25, 0, None).unwrap();
assert_eq!(page1_urls.len(), 25);
// Page 2 (limit 25, offset 25)
let page2_urls = list_urls(&conn, 25, 25, None).unwrap();
assert_eq!(page2_urls.len(), 2);
}
#[test]
fn test_target_analytics_queries() {
let mut conn = setup_analytics_db();
let target_uuid = "target-uuid-123456";
// 1. Visit Count and Unique Visitors check on empty db
assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 0);
assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 0);
// 2. Insert some visit records
let now = Utc::now();
let records = vec![
VisitRecord {
id: "visit-1".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: now.to_rfc3339(),
ip_address: "1.1.1.1".to_string(),
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
VisitRecord {
id: "visit-2".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: now.to_rfc3339(),
ip_address: "1.1.1.1".to_string(), // same visitor
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
VisitRecord {
id: "visit-3".to_string(),
target_type: "url".to_string(),
target_id: target_uuid.to_string(),
timestamp: now.to_rfc3339(),
ip_address: "2.2.2.2".to_string(), // different visitor
user_agent: "Mozilla".to_string(),
referer: "direct".to_string(),
accept_language: "en".to_string(),
country: "US".to_string(),
status_code: 200,
},
];
insert_visits_batch(&mut conn, &records).unwrap();
// 3. Verify counts
assert_eq!(get_target_visit_count(&conn, "url", target_uuid).unwrap(), 3);
assert_eq!(get_target_unique_visitors(&conn, "url", target_uuid).unwrap(), 2);
// 4. Verify monthly clicks trend fallback
let monthly_trend = get_monthly_clicks_trend(&conn, "url", target_uuid, 12).unwrap();
assert_eq!(monthly_trend.len(), 1);
assert_eq!(monthly_trend[0].1, 3); // 3 clicks total
}
+159
View File
@@ -0,0 +1,159 @@
use bzod::config::Config;
use bzod::db::Db;
use bzod::utils::validation::{validate_custom_slug, validate_page_code, validate_redirect_code};
use std::fs;
use std::path::PathBuf;
#[test]
fn test_custom_slug_validation() {
// Valid slugs
assert!(validate_custom_slug("!a"));
assert!(validate_custom_slug("!home"));
assert!(validate_custom_slug("!office"));
assert!(validate_custom_slug("!project-ae06"));
assert!(validate_custom_slug("!customer_01"));
// Invalid slugs
assert!(!validate_custom_slug("!"));
assert!(!validate_custom_slug("!home page"));
assert!(!validate_custom_slug("!home/page"));
assert!(!validate_custom_slug("!home?"));
assert!(!validate_custom_slug("!home&"));
assert!(!validate_custom_slug("!!"));
assert!(!validate_custom_slug(
"!this-is-a-very-long-slug-which-exceeds-the-maximum-allowed-length-limit"
));
// Redirect & page validation
assert!(validate_redirect_code("abcdef")); // 6-hex
assert!(validate_redirect_code("!home")); // custom slug
assert!(!validate_redirect_code("abcde")); // invalid redirect code
assert!(validate_page_code("abcd")); // 4-hex
assert!(validate_page_code("!home")); // custom slug
assert!(!validate_page_code("abc")); // invalid page code
}
fn create_temp_config(temp_dir: PathBuf) -> Config {
let mut config = Config::load();
config.data_dir = temp_dir.clone();
config.backup_dir = temp_dir.clone();
config.base_url = Some("http://bzo.in".to_string());
config
}
#[tokio::test]
async fn test_cli_shorten_and_expand() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_cli_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
// 1. Shorten with generated code
let res = bzod::cli::shorten::run(
"https://example.com/one".to_string(),
None,
None,
config.clone(),
)
.await;
assert!(res.is_ok());
// 2. Shorten with custom slug
let res = bzod::cli::shorten::run(
"https://example.com/two".to_string(),
Some("!office".to_string()),
None,
config.clone(),
)
.await;
assert!(res.is_ok());
// 3. Shorten duplicate slug (should fail)
let res_dup = bzod::cli::shorten::run(
"https://example.com/three".to_string(),
Some("!OFFICE".to_string()), // case-insensitive
None,
config.clone(),
)
.await;
assert!(res_dup.is_err());
assert!(res_dup.unwrap_err().to_string().contains("already exists"));
// 4. Expand custom slug
{
let db = Db::init(&config).unwrap();
let conn = db.content.lock().unwrap();
let url_opt = bzod::db::content::get_url_by_code(&conn, "!office").unwrap();
assert!(url_opt.is_some());
assert_eq!(url_opt.unwrap().destination, "https://example.com/two");
}
// 5. CLI expand round-trip validation
let expand_res = bzod::cli::expand::run("!office".to_string(), None, config.clone()).await;
assert!(expand_res.is_ok());
// 6. Case-insensitive CLI expand validation
let expand_res_upper =
bzod::cli::expand::run("!OFFICE".to_string(), None, config.clone()).await;
assert!(expand_res_upper.is_ok());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_perform_restore_and_validation() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_restore_{}", uuid::Uuid::new_v4()));
let restore_dir =
std::env::temp_dir().join(format!("bzod_test_restore_dest_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
fs::create_dir_all(&restore_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).unwrap();
// 1. Create a mock database record
{
let conn = db.content.lock().unwrap();
bzod::db::content::create_url_extended(
&conn,
"!home",
"https://my-home.com",
None,
None,
&[],
None,
None,
None,
)
.unwrap();
}
// 2. Perform a backup
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
.await
.unwrap();
assert!(PathBuf::from(&backup_path).exists());
// 3. Validate backup archive structure
let validation_res =
bzod::cli::restore::perform_restore(&PathBuf::from(&backup_path), &restore_dir);
assert!(validation_res.is_ok());
// Verify database files were extracted
assert!(restore_dir.join("admin.db").exists());
assert!(restore_dir.join("content.db").exists());
assert!(restore_dir.join("analytics.db").exists());
assert!(restore_dir.join("system.db").exists());
// Verify custom slug was preserved in the restored DB
let restore_config = create_temp_config(restore_dir.clone());
let restore_db = Db::init(&restore_config).unwrap();
{
let conn = restore_db.content.lock().unwrap();
let url = bzod::db::content::get_url_by_code(&conn, "!home").unwrap();
assert!(url.is_some());
assert_eq!(url.unwrap().destination, "https://my-home.com");
}
let _ = fs::remove_dir_all(&temp_dir);
let _ = fs::remove_dir_all(&restore_dir);
}
+46
View File
@@ -0,0 +1,46 @@
use axum::http::StatusCode;
use bzod::web::pages::root_landing;
use std::fs;
#[tokio::test]
async fn test_root_landing_page() {
// --- Test case 1: Successful serving ---
// Read expected content
let expected_content =
fs::read_to_string("www/index.html").expect("www/index.html must exist for test");
let response = root_landing().await;
assert_eq!(response.status(), StatusCode::OK);
assert!(response
.headers()
.get("content-type")
.unwrap()
.to_str()
.unwrap()
.contains("text/html"));
// Convert response body to bytes using axum::body::to_bytes
let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let body_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert_eq!(body_str, expected_content);
// --- Test case 2: File Not Found fallback ---
// Temporarily rename www/index.html to simulate file not found
let orig_path = "www/index.html";
let temp_path = "www/index.html.tmp_test_bak";
fs::rename(orig_path, temp_path).unwrap();
let response_res = tokio::spawn(async move { root_landing().await }).await;
// Restore index.html immediately in case of panic/error
let rename_res = fs::rename(temp_path, orig_path);
// Now verify the response status
let response = response_res.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
rename_res.unwrap();
}
+114 -167
View File
@@ -1,182 +1,129 @@
<!DOCTYPE html>
<html lang="en" data-lt-installed="true"><head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>bzo.in - Simple &amp; Private URL Shortener</title>
<title>BZOD — Private • Fast • Beautiful URL Shortener</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.6.0/css/all.min.css">
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
:root {
--bg: #0f1321;
--card: #1a2337;
--accent: #8b5cf6;
--text: #e0e7ff;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: 'Inter', system-ui, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
min-height: 100vh;
}
header {
background: rgba(26, 35, 55, 0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid #2a3a5a;
padding: 1.25rem 0;
}
.nav {
max-width: 1200px;
margin: 0 auto;
padding: 0 2rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.logo { font-size: 1.75rem; font-weight: 700; color: var(--accent); }
.main { padding: 6rem 2rem 4rem; text-align: center; }
.beta {
display: inline-block;
background: #eab308;
color: #1e2937;
font-size: 0.85rem;
padding: 5px 14px;
border-radius: 9999px;
font-weight: 600;
margin-bottom: 1rem;
}
h1 {
font-size: 3.2rem;
line-height: 1.1;
margin-bottom: 1rem;
background: linear-gradient(90deg, #c4b5fd, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.tagline {
font-size: 1.35rem;
color: #94a3c0;
max-width: 700px;
margin: 0 auto 3rem;
}
.shorten-box {
background: var(--card);
border-radius: 16px;
padding: 1.25rem;
max-width: 620px;
margin: 0 auto 4rem;
border: 1px solid #3b4a6b;
display: flex;
gap: 12px;
}
input {
flex: 1;
padding: 16px 20px;
font-size: 1.1rem;
background: #111827;
border: 1px solid #475569;
border-radius: 12px;
color: white;
}
button {
padding: 16px 36px;
background: var(--accent);
color: white;
border: none;
border-radius: 12px;
font-weight: 600;
cursor: pointer;
}
button:hover { background: #a78bfa; }
.features {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1.5rem;
max-width: 1100px;
margin: 0 auto;
}
.feature-card {
background: var(--card);
padding: 2rem 1.75rem;
border-radius: 16px;
border: 1px solid #3b4a6b;
text-align: left;
}
footer {
text-align: center;
padding: 3rem 1rem 2rem;
color: #64748b;
font-size: 0.95rem;
body { font-family: 'Inter', system-ui, sans-serif; }
.hero-bg {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%);
}
</style>
</head>
<body>
<header>
<div class="nav">
<div class="logo">BZOD • bzo.in</div>
<a href="https://bzo.in/admin/dashboard" style="color:#a5b4fc; text-decoration:none;">Admin</a>
</div>
</header>
<body class="bg-zinc-950 text-zinc-200">
<div class="main">
<div class="beta">BETA</div>
<h1>Short. Clean.<br>Privacy-First.</h1>
<p class="tagline">Lightning-fast URL shortener with built-in analytics, custom landing pages, and full self-hosting control.</p>
<!-- Hero -->
<section class="hero-bg py-24">
<div class="max-w-5xl mx-auto px-6 text-center">
<div class="inline-flex items-center gap-2 bg-zinc-900 border border-zinc-700 rounded-full px-4 py-1.5 mb-6">
<span class="text-emerald-400">●</span>
<span class="text-sm font-medium">Self-hosted • Rust • Single Binary</span>
</div>
<h1 class="text-6xl md:text-7xl font-bold tracking-tighter mb-6">
Short links.<br>
<span class="bg-gradient-to-r from-violet-400 to-fuchsia-400 bg-clip-text text-transparent">Your domain.</span>
</h1>
<p class="text-2xl text-zinc-400 max-w-2xl mx-auto mb-10">
Beautiful, private, and powerful URL shortener with rich landing pages, QR codes, analytics, and full CLI control.
</p>
<div class="shorten-box">
<input type="url" placeholder="Paste your long URL here..." id="urlInput">
<button onclick="shorten()">Shorten Now</button>
</div>
<div class="flex flex-wrap justify-center gap-4">
<a href="https://github.com/thakares/nx9-url-shortener"
target="_blank"
class="bg-white text-black px-8 py-4 rounded-2xl font-semibold flex items-center gap-3 hover:scale-105 transition">
<i class="fab fa-github text-xl"></i>
View on GitHub
</a>
<a href="/admin"
class="bg-violet-600 hover:bg-violet-700 px-8 py-4 rounded-2xl font-semibold transition">
Admin Dashboard →
</a>
</div>
<div class="features">
<div class="feature-card">
<h3>🔗 Compact Hex Codes</h3>
<p>Short, memorable 4-6 character links that look professional.</p>
</div>
<div class="feature-card">
<h3>📊 Real-time Analytics</h3>
<p>Track clicks, countries, referrers with beautiful charts.</p>
</div>
<div class="feature-card">
<h3>🎨 Custom Landing Pages</h3>
<p>Beautiful branded pages before redirect (like this one!).</p>
</div>
<div class="feature-card">
<h3>🔒 Self-Hosted &amp; Private</h3>
<p>Your data. Your server. No third-party tracking.</p>
<div class="mt-16 text-sm text-zinc-500">
Powered by <span class="font-mono text-emerald-400">bzo.in</span>
</div>
</div>
</div>
</section>
<footer>
<p>Made with ❤️ using <strong>nx9-url-shortener</strong> •
<a href="https://github.com/thakares/nx9-url-shortener" style="color:#a78bfa">Star on GitHub</a></p>
<!-- Features -->
<section class="py-20 bg-zinc-900">
<div class="max-w-5xl mx-auto px-6">
<h2 class="text-4xl font-bold text-center mb-16">Why people love BZOD</h2>
<div class="grid md:grid-cols-3 gap-8">
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">⚡</div>
<h3 class="text-2xl font-semibold mb-3">Lightning Fast</h3>
<p class="text-zinc-400">~18 MB single Rust binary. Starts instantly. Uses SQLite with WAL mode. Minimal resource usage.</p>
</div>
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">🔒</div>
<h3 class="text-2xl font-semibold mb-3">Private by Design</h3>
<p class="text-zinc-400">No telemetry. No third-party services. Everything runs on your server. Strong password hashing & audit logs.</p>
</div>
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">🎨</div>
<h3 class="text-2xl font-semibold mb-3">Beautiful Links</h3>
<p class="text-zinc-400">Rich custom landing pages with title, description, OG metadata, and branded preview.</p>
</div>
</div>
<div class="grid md:grid-cols-3 gap-8 mt-8">
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">📱</div>
<h3 class="text-2xl font-semibold mb-3">QR Codes Built-in</h3>
<p class="text-zinc-400">Generate PNG & SVG QR codes. Track scans separately in analytics.</p>
</div>
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">🛠️</div>
<h3 class="text-2xl font-semibold mb-3">CLI First</h3>
<p class="text-zinc-400">backup, restore, doctor, stats, validate, create-admin — everything from terminal.</p>
</div>
<div class="bg-zinc-950 border border-zinc-800 rounded-3xl p-8">
<div class="text-4xl mb-6">🔑</div>
<h3 class="text-2xl font-semibold mb-3">Powerful Features</h3>
<p class="text-zinc-400">Password protection • Expiry • Access limits • Tags • REST API • Bulk QR export</p>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="py-20 bg-black border-t border-zinc-800">
<div class="max-w-2xl mx-auto text-center px-6">
<h2 class="text-4xl font-bold mb-6">Ready to own your short links?</h2>
<p class="text-zinc-400 mb-10">Self-host BZOD in under 5 minutes on any VPS, homelab, or even a Raspberry Pi.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="https://github.com/thakares/nx9-url-shortener"
target="_blank"
class="bg-white text-black px-10 py-4 rounded-2xl font-semibold text-lg">
Get BZOD Now
</a>
<a href="/admin"
class="border border-zinc-700 hover:bg-zinc-900 px-10 py-4 rounded-2xl font-semibold text-lg transition">
Open Dashboard
</a>
</div>
</div>
</section>
<footer class="bg-zinc-950 py-12 border-t border-zinc-800">
<div class="max-w-5xl mx-auto px-6 text-center text-zinc-500 text-sm">
© 2026 BZOD • Made with ❤️ in Rust • Running on <span class="font-mono">bzo.in</span>
</div>
</footer>
</body>
</html>
<script>
function shorten() {
const url = document.getElementById('urlInput').value.trim();
if (url) {
window.location.href = `/?url=${encodeURIComponent(url)}`;
}
}
</script>
</body></html>