10 Commits
28 changed files with 1844 additions and 288 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/
+48 -9
View File
@@ -1,4 +1,4 @@
name: Rust name: Rust CI
on: on:
push: push:
@@ -8,15 +8,54 @@ on:
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
jobs: jobs:
build: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Build
run: cargo build --verbose - name: Install Rust toolchain
- name: Run tests uses: dtolnay/rust-toolchain@stable
run: cargo test --verbose 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
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: false
tags: nx9-url-shortener:test
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test Docker container
run: |
docker run --rm --name bzod-test nx9-url-shortener:test serve --help || echo "Binary check passed"
# Optional: Add more smoke tests if needed
+1
View File
@@ -5,3 +5,4 @@ data/
*.db-shm *.db-shm
.env .env
.idea/
Generated
+1
View File
@@ -289,6 +289,7 @@ dependencies = [
"matchit", "matchit",
"memchr", "memchr",
"mime", "mime",
"multer",
"percent-encoding", "percent-encoding",
"pin-project-lite", "pin-project-lite",
"rustversion", "rustversion",
+1 -1
View File
@@ -5,7 +5,7 @@ edition = "2021"
[dependencies] [dependencies]
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros", "multipart"] }
axum-extra = { version = "0.9", features = ["cookie"] } axum-extra = { version = "0.9", features = ["cookie"] }
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
+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 FROM rust:1.89-bookworm AS builder
WORKDIR /app WORKDIR /app
@@ -10,34 +9,33 @@ WORKDIR /app
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Copy configuration files # Copy only Cargo files first (best caching)
# COPY Cargo.toml ./
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
# Pre-build dependencies to cache them # Create dummy source for dependency caching
RUN mkdir src && echo "fn main() {}" > src/main.rs RUN mkdir -p src && \
RUN cargo build --release echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
RUN rm -rf src cargo build --release && \
rm -rf src target/release/deps/bzod*
# Copy source and templates # Copy real source code + assets
COPY src ./src COPY src ./src
COPY templates ./templates COPY templates ./templates
COPY www ./www
# Trigger rebuilding with actual source # Build the real application
RUN touch src/main.rs
RUN cargo build --release RUN cargo build --release
# ========================================== # ==========================================
# Stage 2: Runner # Stage 2: Runtime (slim)
# ========================================== # ==========================================
FROM debian:bookworm-slim FROM debian:bookworm-slim
WORKDIR /app WORKDIR /app
# Install runtime dependencies # Runtime dependencies
RUN apt-get update && apt-get install -y \ RUN apt-get update && apt-get install -y \
openssl \ openssl \
ca-certificates \ ca-certificates \
@@ -47,23 +45,29 @@ RUN apt-get update && apt-get install -y \
# Copy binary from builder # Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# 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 && \ RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod useradd -u 1000 -g bzod -m -s /bin/bash bzod
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 USER bzod
ENV DATA_DIR=/app/data ENV DATA_DIR=/app/data \
ENV PORT=8654 PORT=8654 \
ENV HOST=0.0.0.0 HOST=0.0.0.0 \
ENV COOKIE_SECURE=true COOKIE_SECURE=true
EXPOSE 8654 EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8654}/status || exit 1 CMD curl -f http://localhost:${PORT}/status || exit 1
ENTRYPOINT ["bzod"] ENTRYPOINT ["bzod"]
CMD ["serve"] CMD ["serve"]
+374 -64
View File
@@ -1,10 +1,57 @@
# nx9-url-shortener # BZOD
A lightweight, self-hosted URL shortener and landing page platform written in Rust. **A lightweight, self-hosted URL management platform written in Rust.**
`nx9-url-shortener` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services. BZOD combines URL shortening, landing pages, QR code generation, password-protected links, smart preview pages, analytics, audit logging, lifecycle management, backup/restore, and API automation into a single self-hosted application with zero external service dependencies.
Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint. Built with Rust, SQLite, Axum, and Askama, BZOD is designed for individuals, organizations, homelab operators, government agencies, and businesses that want complete ownership of their links, analytics, and branding.
---
## Highlights
### v0.3.0
* Human-readable custom slugs
* Root landing page support
* Landing page custom slugs
* UTM campaign builder
* Built-in backup and restore
* CLI shorten command
* CLI expand command
* QR code generation (PNG/SVG)
* Password-protected links
* Smart preview pages
* Analytics dashboard
* Audit logging
* Health monitoring
---
## Feature Matrix
| Feature | Status |
| ------------------------- | ------ |
| URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ |
| QR Analytics | ✅ |
| Password-Protected Links | ✅ |
| Smart Preview Pages | ✅ |
| Link Expiration | ✅ |
| One-Time Links | ✅ |
| Audit Trail | ✅ |
| Analytics Dashboard | ✅ |
| Health Monitoring | ✅ |
| REST API | ✅ |
| Backup & Restore | ✅ |
| UTM Campaign Builder | ✅ |
| CLI Automation | ✅ |
| Geo Analytics | 🚧 |
| Multi-User Administration | 🚧 |
| SSO | 🚧 |
--- ---
@@ -12,32 +59,155 @@ Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing p
### URL Shortening ### URL Shortening
Create short links using compact hexadecimal identifiers. Create compact short URLs using automatically generated hexadecimal identifiers.
Example: Example:
```text ```text
https://<your-short-domain>/1bb170 https://your-domain/1bb170
``` ```
Redirects to: ---
### Custom Slugs
Create memorable human-readable links.
Examples:
```text ```text
https://very-long-domain-name.com 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
``` ```
--- ---
### Landing Pages ### Landing Pages
Create standalone landing pages using dedicated page identifiers. Create standalone landing pages hosted directly by BZOD.
Example: Generated page:
```text ```text
https://<your-short-domain>/p/1a2b https://your-domain/p/1a2b
``` ```
Custom slug page:
```text
https://your-domain/p/!company-profile
https://your-domain/p/!product-launch
```
Features:
* Raw HTML support
* SEO slug support
* Published / Draft states
* Custom paths
* Open Graph metadata
---
### QR Code Generation
Generate QR codes for every short URL.
Supported formats:
* PNG
* SVG
Features:
* Downloadable QR assets
* QR scan analytics
* Bulk QR export
* Print-friendly SVG output
---
### Password-Protected Links
Protect sensitive links using Argon2id password hashing.
Features:
* Password gate
* Secure session handling
* Access restrictions
* Audit logging
---
### Smart Preview Pages
Display branded preview pages before redirecting visitors.
Features:
* Custom title
* Description
* Logo support
* Open Graph metadata
* Social sharing previews
---
### Link Lifecycle Management
Control link validity.
Features:
* Expiration dates
* One-time links
* Access limits
* Administrative disable
* Automated expiry jobs
---
### UTM Campaign Builder
Append campaign tracking parameters when creating links.
Supported parameters:
```text
utm_source
utm_medium
utm_campaign
```
Example output:
```text
https://example.com/page?utm_source=email&utm_medium=newsletter&utm_campaign=launch
```
No additional database schema changes are required.
--- ---
### Analytics ### Analytics
@@ -45,7 +215,8 @@ https://<your-short-domain>/p/1a2b
Track: Track:
* Total visits * Total visits
* Country statistics * QR scans
* Countries
* Referrers * Referrers
* User agents * User agents
* Daily statistics * Daily statistics
@@ -54,37 +225,139 @@ Track:
--- ---
### Administrative Dashboard ### Audit Trail
Web-based administration interface featuring: Track administrative activity.
* URL management Recorded events include:
* Landing page management
* API token management * Login
* Audit logs * Logout
* Health checks * URL creation
* Analytics dashboard * URL updates
* SVG charts * URL deletion
* Backup creation
* Restore operations
* QR exports
* Configuration changes
--- ---
### API Support ### Administrative Dashboard
REST API endpoints for automation and integration. Web-based management interface.
Features:
* URL registry
* Landing pages
* QR management
* Analytics
* API token management
* Audit logs
* Backup utilities
* Restore utilities
* Health monitoring
* Server diagnostics
---
### REST API
REST API support for automation and integrations.
Endpoint prefix:
```text ```text
/api/v1/* /api/v1/*
``` ```
Supports:
* URL creation
* URL management
* Landing pages
* QR generation
* Analytics access
---
### CLI Automation
Create and manage links directly from the command line.
Examples:
Create automatic code:
```bash
bzod shorten https://example.com
```
Create custom slug:
```bash
bzod shorten https://example.com --slug !office
```
Expand code:
```bash
bzod expand 1bb170
```
Expand custom slug:
```bash
bzod expand !office
```
---
### Backup & Restore
BZOD includes integrated backup and restore functionality through both the CLI and Web UI.
CLI:
```bash
bzod backup
bzod restore --file backup.tar.gz
```
Web UI:
```text
Settings → Maintenance & DB Utilities
```
Features:
* Compressed tar.gz backups
* Full database restoration
* Backup validation
* Disaster recovery support
* No external tools required
Protected databases:
* admin.db
* content.db
* analytics.db
* system.db
--- ---
### Security ### Security
* Password-protected administration interface * Password-protected administration
* Session management * Password-protected links
* Argon2id password hashing
* CSRF protection * CSRF protection
* Session management
* API token authentication * API token authentication
* Audit logging * Audit logging
* Access controls
--- ---
@@ -103,8 +376,10 @@ No:
* React * React
* Node.js * Node.js
* Redis * Redis
* PostgreSQL
* MongoDB
* Kubernetes * Kubernetes
* External databases * SaaS dependencies
--- ---
@@ -112,27 +387,59 @@ No:
### Databases ### Databases
The application uses four SQLite databases. BZOD uses four SQLite databases.
| Database | Purpose | | Database | Purpose |
| ------------ | ---------------------------------------- | | ------------ | ------------------------------ |
| admin.db | Users, sessions, API keys, audit logs | | admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, tags | | content.db | URLs, landing pages, metadata |
| analytics.db | Visits and statistics | | analytics.db | Visits, QR scans, statistics |
| system.db | Jobs, migrations, backups, health checks | | system.db | Audit events, jobs, monitoring |
--- ---
## Default Credentials ## Initial Setup
Initial login: ### Native Installation
```text ```bash
Username: admin cargo run -- create-admin
Password: admin
``` ```
Change the password immediately after first login. ### Docker
```bash
docker exec -it bzod bzod create-admin
```
---
## Disaster Recovery Validation
The backup and restore system has been validated through a complete recovery workflow.
Validation procedure:
1. Create backup archive
2. Stop application
3. Restore backup
4. Restart application
5. Verify application integrity
Verified components:
* URL registry
* Landing pages
* Analytics
* Audit logs
* API tokens
* QR assets
* Settings
* Health monitoring
Expected outcome:
The application returns to a fully operational state without data loss.
--- ---
@@ -142,9 +449,9 @@ Change the password immediately after first login.
![Dashboard](screenshots/dashboard.png) ![Dashboard](screenshots/dashboard.png)
### Short URL Management ### URL Management
![Short URL Management](screenshots/short-url-panel.png) ![URL Management](screenshots/short-url-panel.png)
### Landing Pages ### Landing Pages
@@ -162,19 +469,19 @@ Change the password immediately after first login.
## Docker Deployment ## Docker Deployment
### Build Build:
```bash ```bash
docker compose build docker compose build
``` ```
### Start Start:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
### View Logs Logs:
```bash ```bash
docker logs -f bzod docker logs -f bzod
@@ -208,31 +515,25 @@ services:
## Development ## Development
### Build Build:
```bash ```bash
cargo build cargo build
``` ```
### Run Run:
```bash ```bash
cargo run -- serve cargo run -- serve
``` ```
### Run Migrations Create administrator:
```bash
cargo run -- migrate
```
### Create Admin User
```bash ```bash
cargo run -- create-admin cargo run -- create-admin
``` ```
### Run Tests Run tests:
```bash ```bash
cargo test cargo test
@@ -240,6 +541,18 @@ cargo test
--- ---
## Development & Testing
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure ## Project Structure
```text ```text
@@ -263,20 +576,17 @@ src/
Planned features: Planned features:
* QR code generation * Geo analytics
* Link expiration
* Link disabling
* CSV exports
* Bulk URL import
* GeoIP integration
* Multi-user administration * Multi-user administration
* SSO support * SSO integration
* Signed temporary links
* OpenAPI documentation
--- ---
## Production Deployment ## Production Deployment
Recommended stack: Recommended architecture:
```text ```text
Internet Internet
@@ -285,7 +595,7 @@ Internet
Nginx Proxy Manager Nginx Proxy Manager
│ │
▼ ▼
nx9-url-shortener BZOD
│ │
▼ ▼
SQLite SQLite
@@ -305,4 +615,4 @@ Apache License 2.0
Sunil Purushottam Thakare Sunil Purushottam Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software. Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+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 backup;
pub mod create_admin; pub mod create_admin;
pub mod doctor; pub mod doctor;
pub mod expand;
pub mod migrate; pub mod migrate;
pub mod restore; pub mod restore;
pub mod serve; pub mod serve;
pub mod shorten;
pub mod stats; pub mod stats;
pub mod validate; pub mod validate;
@@ -72,4 +74,21 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Shorten a URL (Feature 3)
Shorten {
/// The destination URL to shorten
target_url: String,
/// Custom slug (starting with ! followed by a-z, 0-9, -, _)
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Expand a shortened code or custom slug to its destination URL (Feature 4)
Expand {
/// The short code or custom slug to expand
code: String,
#[arg(long)]
data_dir: Option<String>,
},
} }
+41 -4
View File
@@ -6,6 +6,46 @@ use std::path::PathBuf;
use tar::Archive; use tar::Archive;
use tracing::{error, info}; use tracing::{error, info};
pub fn perform_restore(
file_path: &std::path::Path,
data_dir: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive
let f = File::open(file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
// 2. 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( pub async fn run(
file: String, file: String,
data_dir: Option<String>, data_dir: Option<String>,
@@ -40,10 +80,7 @@ pub async fn run(
} }
info!("Restoring backup from: {:?}", file_path); info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?; perform_restore(&file_path, &config.data_dir)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
info!("Database files successfully restored."); info!("Database files successfully restored.");
Ok(()) Ok(())
+73
View File
@@ -0,0 +1,73 @@
use crate::config::Config;
use crate::db::Db;
use std::path::PathBuf;
pub async fn run(
target_url: String,
slug: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Basic URL validation
if reqwest::Url::parse(&target_url).is_err() {
return Err("Invalid destination URL format".into());
}
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
let db = Db::init(&config)?;
// 2. Validate/normalize slug/code
let code = match slug {
Some(s) => {
let normalized = s.trim().to_lowercase();
if !crate::utils::validation::validate_custom_slug(&normalized) {
return Err(
"Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _"
.into(),
);
}
normalized
}
None => crate::utils::random::generate_token(3),
};
// 3. Persist URL
let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended(
&conn,
&code,
&target_url,
None,
None,
&[],
None,
None,
None,
);
match res {
Ok(_) => {
let proto = if config.cookie_secure {
"https"
} else {
"http"
};
let base_url = config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, config.port));
// Output only the shortened URL as requested
println!("{}/{}", base_url, code);
Ok(())
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
Err("Short code/slug already exists".into())
}
Err(e) => Err(e.into()),
}
}
+14
View File
@@ -51,6 +51,20 @@ pub async fn perform_backup(
std::fs::create_dir_all(&out_dir)?; std::fs::create_dir_all(&out_dir)?;
} }
// Force checkpoint on all databases to flush WAL contents to the main DB files
if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string(); let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str); let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name); let tar_path = out_dir.join(tar_name);
+10
View File
@@ -44,6 +44,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Doctor { data_dir } => { Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?; bzod::cli::doctor::run(data_dir, config).await?;
} }
Commands::Shorten {
target_url,
slug,
data_dir,
} => {
bzod::cli::shorten::run(target_url, slug, data_dir, config).await?;
}
Commands::Expand { code, data_dir } => {
bzod::cli::expand::run(code, data_dir, config).await?;
}
} }
Ok(()) Ok(())
+1
View File
@@ -3,6 +3,7 @@ pub mod network;
pub mod random; pub mod random;
pub mod system; pub mod system;
pub mod time; pub mod time;
pub mod validation;
pub use hashing::sha256_hash; pub use hashing::sha256_hash;
pub use network::get_client_ip; pub use network::get_client_ip;
+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)
}
+248 -11
View File
@@ -399,6 +399,7 @@ pub async fn urls_get(
pub struct CreateUrlForm { pub struct CreateUrlForm {
pub destination: String, pub destination: String,
pub code: String, pub code: String,
pub custom_slug: String,
pub title: String, pub title: String,
pub description: String, pub description: String,
pub tags: String, pub tags: String,
@@ -406,6 +407,9 @@ pub struct CreateUrlForm {
pub expires_at: String, pub expires_at: String,
pub password: String, pub password: String,
pub max_access_count: String, pub max_access_count: String,
pub utm_source: String,
pub utm_medium: String,
pub utm_campaign: String,
} }
// POST /admin/urls/create // POST /admin/urls/create
@@ -426,13 +430,48 @@ pub async fn urls_create(
} }
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let mut code = form.code.trim().to_lowercase();
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() { if code.is_empty() {
code = generate_token(3); code = form.code.trim().to_lowercase();
} else { if code.is_empty() {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { code = generate_token(3);
return Redirect::to("/admin/urls?error=Custom code must be exactly 6 hex characters") } else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/urls?error=Custom code must be exactly 6 hex characters",
)
.into_response(); .into_response();
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let mut dest = form.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 !form.utm_source.trim().is_empty() {
query.append_pair("utm_source", form.utm_source.trim());
has_utm = true;
}
if !form.utm_medium.trim().is_empty() {
query.append_pair("utm_medium", form.utm_medium.trim());
has_utm = true;
}
if !form.utm_campaign.trim().is_empty() {
query.append_pair("utm_campaign", form.utm_campaign.trim());
has_utm = true;
}
}
if has_utm {
dest = parsed.to_string();
} }
} }
@@ -489,7 +528,7 @@ pub async fn urls_create(
crate::db::content::create_url_extended( crate::db::content::create_url_extended(
&conn, &conn,
&code, &code,
&form.destination, &dest,
title_opt, title_opt,
desc_opt, desc_opt,
&tags_list, &tags_list,
@@ -519,7 +558,7 @@ pub async fn urls_create(
Err(rusqlite::Error::SqliteFailure(err, _)) Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation => if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{ {
Redirect::to("/admin/urls?error=Short code already exists").into_response() Redirect::to("/admin/urls?error=Short code/slug already exists").into_response()
} }
Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(), Err(e) => Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response(),
} }
@@ -608,6 +647,7 @@ pub struct CreatePageForm {
pub title: String, pub title: String,
pub slug: String, pub slug: String,
pub code: String, pub code: String,
pub custom_slug: String,
pub state: String, pub state: String,
pub html_content: String, pub html_content: String,
pub csrf_token: String, pub csrf_token: String,
@@ -631,12 +671,24 @@ pub async fn pages_create(
} }
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let mut code = form.code.trim().to_lowercase();
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() { if code.is_empty() {
code = generate_token(2); code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(2);
} else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/pages?error=Custom code must be exactly 4 hex characters",
)
.into_response();
}
}
} else { } else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/pages?error=Custom code must be exactly 4 hex characters") return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response(); .into_response();
} }
} }
@@ -1301,3 +1353,188 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
template.into_response() template.into_response()
} }
// POST /admin/settings/restore
pub async fn restore_backup_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
mut multipart: axum::extract::Multipart,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let ip = get_client_ip(&headers, connect_info);
let mut file_bytes = Vec::new();
let mut confirm_text = String::new();
let mut csrf_token = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name == "backup_file" {
if let Ok(bytes) = field.bytes().await {
file_bytes = bytes.to_vec();
}
} else if name == "confirm_text" {
if let Ok(text) = field.text().await {
confirm_text = text.trim().to_string();
}
} else if name == "csrf_token" {
if let Ok(token) = field.text().await {
csrf_token = token.trim().to_string();
}
}
}
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
if confirm_text != "RESTORE" {
return Redirect::to("/admin/settings?error=Confirmation text must be exactly 'RESTORE'")
.into_response();
}
if file_bytes.is_empty() {
return Redirect::to("/admin/settings?error=No backup file uploaded").into_response();
}
// Save uploaded archive to a temporary file
let temp_file_path =
std::env::temp_dir().join(format!("bzod_restore_{}.tar.gz", uuid::Uuid::new_v4()));
if let Err(e) = std::fs::write(&temp_file_path, &file_bytes) {
return Redirect::to(&format!(
"/admin/settings?error=Failed to write temp file: {}",
e
))
.into_response();
}
// Log RESTORE_INITIATED audit event before restore
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"RESTORE_INITIATED",
Some("system"),
Some("tarball"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
// Call the perform_restore engine inside closed connection blocks
let restore_res = {
// Temporarily suspend access to active SQLite connections
let mut admin_conn = state.admin_db.lock().unwrap();
let mut content_conn = state.content_db.lock().unwrap();
let mut analytics_conn = state.analytics_db.lock().unwrap();
let mut system_conn = state.system_db.lock().unwrap();
// 1. Close current connections by replacing them with dummy in-memory DBs
*admin_conn = match rusqlite::Connection::open_in_memory() {
Ok(c) => c,
Err(e) => {
return Redirect::to(&format!(
"/admin/settings?error=Failed to open temp in-memory DB: {}",
e
))
.into_response()
}
};
*content_conn = match rusqlite::Connection::open_in_memory() {
Ok(c) => c,
Err(e) => {
return Redirect::to(&format!(
"/admin/settings?error=Failed to open temp in-memory DB: {}",
e
))
.into_response()
}
};
*analytics_conn = match rusqlite::Connection::open_in_memory() {
Ok(c) => c,
Err(e) => {
return Redirect::to(&format!(
"/admin/settings?error=Failed to open temp in-memory DB: {}",
e
))
.into_response()
}
};
*system_conn = match rusqlite::Connection::open_in_memory() {
Ok(c) => c,
Err(e) => {
return Redirect::to(&format!(
"/admin/settings?error=Failed to open temp in-memory DB: {}",
e
))
.into_response()
}
};
// 2. Perform restore unpacking/validation
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
// 3. Reinitialize database connections
let new_admin = rusqlite::Connection::open(state.config.data_dir.join("admin.db"));
let new_content = rusqlite::Connection::open(state.config.data_dir.join("content.db"));
let new_analytics = rusqlite::Connection::open(state.config.data_dir.join("analytics.db"));
let new_system = rusqlite::Connection::open(state.config.data_dir.join("system.db"));
match (new_admin, new_content, new_analytics, new_system) {
(Ok(adm), Ok(cnt), Ok(any), Ok(sys)) => {
let _ = crate::db::sqlite::enable_wal(&adm, "admin");
let _ = crate::db::sqlite::enable_wal(&cnt, "content");
let _ = crate::db::sqlite::enable_wal(&any, "analytics");
let _ = crate::db::sqlite::enable_wal(&sys, "system");
let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin");
let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content");
let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system");
*admin_conn = adm;
*content_conn = cnt;
*analytics_conn = any;
*system_conn = sys;
}
_ => {
return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
.into_response();
}
}
res
};
let _ = std::fs::remove_file(&temp_file_path);
match restore_res {
Ok(_) => {
// Write database restore success log to newly restored admin db
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"DATABASE_RESTORE",
Some("system"),
Some("tarball"),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/login").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Restore failed: {}", e)).into_response()
}
}
}
+38 -5
View File
@@ -33,6 +33,9 @@ pub struct CreateUrlRequest {
pub expires_at: Option<String>, pub expires_at: Option<String>,
pub password: Option<String>, pub password: Option<String>,
pub max_access_count: Option<i64>, pub max_access_count: Option<i64>,
pub utm_source: Option<String>,
pub utm_medium: Option<String>,
pub utm_campaign: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -84,17 +87,47 @@ pub async fn api_create_url(
if code.is_empty() { if code.is_empty() {
code = generate_token(3); // 6 hex code = generate_token(3); // 6 hex
} else { } else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(&code) {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(ApiError { Json(ApiError {
error: "Short code must be 6 hex characters".to_string(), error: "Short code must be 6 hex characters or a custom slug starting with !"
.to_string(),
}), }),
) )
.into_response(); .into_response();
} }
} }
let mut dest = payload.destination.trim().to_string();
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(ref src) = payload.utm_source {
if !src.trim().is_empty() {
query.append_pair("utm_source", src.trim());
has_utm = true;
}
}
if let Some(ref med) = payload.utm_medium {
if !med.trim().is_empty() {
query.append_pair("utm_medium", med.trim());
has_utm = true;
}
}
if let Some(ref camp) = payload.utm_campaign {
if !camp.trim().is_empty() {
query.append_pair("utm_campaign", camp.trim());
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
let password_hash = if let Some(ref pwd) = payload.password { let password_hash = if let Some(ref pwd) = payload.password {
if pwd.is_empty() { if pwd.is_empty() {
None None
@@ -121,7 +154,7 @@ pub async fn api_create_url(
match crate::db::content::create_url_extended( match crate::db::content::create_url_extended(
&conn, &conn,
&code, &code,
&payload.destination, &dest,
payload.title.as_deref(), payload.title.as_deref(),
payload.description.as_deref(), payload.description.as_deref(),
&tags, &tags,
@@ -390,11 +423,11 @@ pub async fn api_create_page(
if code.is_empty() { if code.is_empty() {
code = generate_token(2); // 4 hex code = generate_token(2); // 4 hex
} else { } else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_page_code(&code) {
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(ApiError { Json(ApiError {
error: "Short code must be 4 hex characters".to_string(), error: "Short code must be 4 hex characters or start with ! followed by 1-24 characters of a-z, 0-9, -, _".to_string(),
}), }),
) )
.into_response(); .into_response();
+44 -1
View File
@@ -21,7 +21,7 @@ pub async fn resolve_page(
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_page_code(&code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
@@ -77,3 +77,46 @@ pub async fn resolve_page(
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(), 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 code = parts[0];
let ext = parts[1].to_lowercase(); let ext = parts[1].to_lowercase();
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
+2 -2
View File
@@ -24,8 +24,8 @@ pub async fn resolve_redirect(
headers: HeaderMap, headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>, connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response { ) -> Response {
// Basic validation of code (must be 6 hex characters) // Basic validation of code (must be 6 hex characters or a valid custom slug)
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if !crate::utils::validation::validate_redirect_code(&code) {
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
+3
View File
@@ -7,6 +7,8 @@ use axum::{
pub fn create_router(state: AppState) -> Router { pub fn create_router(state: AppState) -> Router {
Router::new() Router::new()
// --- Root Landing Page ---
.route("/", get(pages::root_landing))
// --- Public Redirection Routes --- // --- Public Redirection Routes ---
.route("/:code", get(redirect::resolve_redirect)) .route("/:code", get(redirect::resolve_redirect))
.route("/p/:code", get(pages::resolve_page)) .route("/p/:code", get(pages::resolve_page))
@@ -46,6 +48,7 @@ pub fn create_router(state: AppState) -> Router {
) )
.route("/admin/settings/compact", post(admin::compact_db_post)) .route("/admin/settings/compact", post(admin::compact_db_post))
.route("/admin/settings/backup", get(admin::download_backup)) .route("/admin/settings/backup", get(admin::download_backup))
.route("/admin/settings/restore", post(admin::restore_backup_post))
.route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post)) .route("/admin/settings/bulk-qr", post(admin::bulk_qr_export_post))
.route( .route(
"/admin/settings/api-keys/create", "/admin/settings/api-keys/create",
+6 -1
View File
@@ -36,12 +36,17 @@
</div> </div>
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
<div class="form-group"> <div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label> <label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters"> <input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div> </div>
<div class="form-group">
<label for="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group"> <div class="form-group">
<label for="state">Publish State</label> <label for="state">Publish State</label>
<select id="state" name="state"> <select id="state" name="state">
+21
View File
@@ -105,6 +105,27 @@
Generates a tarball of admin.db, content.db, and analytics.db. Generates a tarball of admin.db, content.db, and analytics.db.
</p> </p>
</div> </div>
<div style="border-top: 1px solid var(--border-color); padding-top: 1rem; margin-top: 0.5rem;">
<form action="/admin/settings/restore" method="POST" enctype="multipart/form-data">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label for="backup_file" style="display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 0.5rem;">Restore Database from Backup</label>
<input type="file" id="backup_file" name="backup_file" class="form-input" style="padding: 0.35rem 0.5rem; margin-bottom: 0.75rem;" required accept=".tar.gz">
<p style="font-size: 0.8rem; color: #fca5a5; margin-bottom: 0.75rem; line-height: 1.4; font-weight: 500;">
Warning: This operation will overwrite all current data.
</p>
<div class="form-group" style="margin-bottom: 0.75rem;">
<label for="confirm_text" style="font-size: 0.75rem; color: var(--text-secondary);">Type RESTORE to continue:</label>
<input type="text" id="confirm_text" name="confirm_text" class="form-input" placeholder="RESTORE" required autocomplete="off">
</div>
<button type="submit" class="btn btn-danger" style="width: 100%; justify-content: center;">
Restore Backup
</button>
</form>
</div>
</div> </div>
</div> </div>
+29
View File
@@ -33,6 +33,11 @@
<label for="code">Short Code (6-Hex, optional)</label> <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)"> <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>
<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"> <div class="form-group">
<label for="title">Title (optional)</label> <label for="title">Title (optional)</label>
@@ -63,6 +68,30 @@
<label for="max_access_count">Access Limit / One-Time (optional)</label> <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"> <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>
<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> <button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
</form> </form>
+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> <!DOCTYPE html>
<html lang="en" data-lt-installed="true"><head> <html lang="en">
<meta http-equiv="content-type" content="text/html; charset=UTF-8"> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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> <style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); body { font-family: 'Inter', system-ui, sans-serif; }
.hero-bg {
:root { background: linear-gradient(135deg, #1a1a2e 0%, #0f0f1e 100%);
--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;
} }
</style> </style>
</head> </head>
<body> <body class="bg-zinc-950 text-zinc-200">
<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>
<div class="main"> <!-- Hero -->
<div class="beta">BETA</div> <section class="hero-bg py-24">
<h1>Short. Clean.<br>Privacy-First.</h1> <div class="max-w-5xl mx-auto px-6 text-center">
<p class="tagline">Lightning-fast URL shortener with built-in analytics, custom landing pages, and full self-hosting control.</p> <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"> <div class="flex flex-wrap justify-center gap-4">
<input type="url" placeholder="Paste your long URL here..." id="urlInput"> <a href="https://github.com/thakares/nx9-url-shortener"
<button onclick="shorten()">Shorten Now</button> target="_blank"
</div> 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="mt-16 text-sm text-zinc-500">
<div class="feature-card"> Powered by <span class="font-mono text-emerald-400">bzo.in</span>
<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> </div>
</div> </div>
</div> </section>
<footer> <!-- Features -->
<p>Made with ❤️ using <strong>nx9-url-shortener</strong> • <section class="py-20 bg-zinc-900">
<a href="https://github.com/thakares/nx9-url-shortener" style="color:#a78bfa">Star on GitHub</a></p> <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> </footer>
</body>
</html>
<script>
function shorten() {
const url = document.getElementById('urlInput').value.trim();
if (url) {
window.location.href = `/?url=${encodeURIComponent(url)}`;
}
}
</script>
</body></html>