Add custom slugs, restore UI, UTM builder, and CLI link tools

This commit is contained in:
thakares committed 2026-06-14 19:11:21 +05:30
1 parent 02a26cfd94
commit c6486763e3
22 files changed
+999 -113

No files matched your search

Generated
+1
View File
@@ -289,6 +289,7 @@ dependencies = [
"matchit",
"memchr",
"mime",
"multer",
"percent-encoding",
"pin-project-lite",
"rustversion",
+1 -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"] }
+259 -81
View File
@@ -1,26 +1,30 @@
# nx9-url-shortener
# BZOD
**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
---
@@ -29,7 +33,9 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
| Feature | Status |
| ------------------------- | ------ |
| URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ |
| QR Analytics | ✅ |
| Password-Protected Links | ✅ |
@@ -37,11 +43,12 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
| Link Expiration | ✅ |
| One-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,20 +59,74 @@ 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
https://your-domain/1bb170
```
Redirects to:
---
### Custom Slugs
Create memorable human-readable links.
Examples:
```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
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
---
### QR Code Generation
@@ -88,20 +149,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 +174,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 +181,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 +216,7 @@ Track:
* Total visits
* QR scans
* Country statistics
* Countries
* Referrers
* User agents
* Daily statistics
@@ -156,39 +227,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 +276,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 +379,7 @@ No:
* PostgreSQL
* MongoDB
* Kubernetes
* External SaaS
* SaaS dependencies
---
@@ -243,21 +387,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 +409,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 +469,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 +493,9 @@ docker logs -f nx9-url-shortener
```yaml
services:
nx9-url-shortener:
bzod:
build: .
container_name: nx9-url-shortener
container_name: bzod
restart: unless-stopped
ports:
@@ -344,33 +515,44 @@ services:
## Development
### Build
Build:
```bash
cargo build
```
### Run
Run:
```bash
cargo run -- serve
```
### Create Administrator
Create administrator:
```bash
cargo run -- create-admin
```
### Run Tests
Run tests:
```bash
cargo test
```
---
## Development & Testing
See [docs/TESTING.md](docs/TESTING.md) for comprehensive testing, validation, backup, restore, disaster recovery, and release procedures.
See:
```text
docs/TESTING.md
```
for testing, validation, backup, restore, disaster recovery, and release procedures.
---
## Project Structure
```text
@@ -394,20 +576,17 @@ src/
Planned features:
* Vanity URLs
* CSV import/export
* Geo analytics
* Multi-user administration
* SSO integration
* Signed temporary links
* OpenAPI documentation
* Webhook support
---
## Production Deployment
Recommended stack:
Recommended architecture:
```text
Internet
@@ -416,7 +595,7 @@ Internet
Nginx Proxy Manager
│
▼
nx9-url-shortener
BZOD
│
▼
SQLite
@@ -437,4 +616,3 @@ Apache License 2.0
Sunil Purushottam Thakare
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+23 -6
View File
@@ -223,8 +223,25 @@ Overall status: HEALTHY
and original record counts preserved.
---
## 9. Disaster Recovery Scenario
## 9. Disaster Recovery Test
1. Create backup
2. Stop container
3. Delete databases
4. Restore from backup
5. Fix permissions
6. Restart container
7. Validate:
- URLs
- Landing pages
- Audit logs
- Settings
- Analytics
- Status page
Expected Result:
System fully restored without data loss.
## 10. Disaster Recovery Test
This is the most important test.
@@ -255,7 +272,7 @@ Expected Result:
---
## 10. Database Health Verification
## 11. Database Health Verification
Run:
@@ -281,7 +298,7 @@ Overall status: HEALTHY
---
## 11. SQLite Integrity Checks
## 12. SQLite Integrity Checks
Manual verification.
@@ -302,7 +319,7 @@ for all databases.
---
## 12. Web Interface Verification
## 13. Web Interface Verification
Start server.
@@ -321,7 +338,7 @@ Verify:
---
## 13. Docker Verification
## 14. Docker Verification
Build image.
@@ -357,7 +374,7 @@ inside container.
---
## 14. Upgrade Verification
## 15. Upgrade Verification
1. Create backup.
2. Upgrade binary.
+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()),
}
}
+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(())
+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)
}
+248 -11
View File
@@ -399,6 +399,7 @@ pub async fn urls_get(
pub struct CreateUrlForm {
pub destination: String,
pub code: String,
pub custom_slug: String,
pub title: String,
pub description: String,
pub tags: String,
@@ -406,6 +407,9 @@ pub struct CreateUrlForm {
pub expires_at: String,
pub password: String,
pub max_access_count: String,
pub utm_source: String,
pub utm_medium: String,
pub utm_campaign: String,
}
// POST /admin/urls/create
@@ -426,13 +430,48 @@ pub async fn urls_create(
}
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() {
code = generate_token(3);
} 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")
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} 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();
}
}
} 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(
&conn,
&code,
&form.destination,
&dest,
title_opt,
desc_opt,
&tags_list,
@@ -519,7 +558,7 @@ pub async fn urls_create(
Err(rusqlite::Error::SqliteFailure(err, _))
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(),
}
@@ -608,6 +647,7 @@ pub struct CreatePageForm {
pub title: String,
pub slug: String,
pub code: String,
pub custom_slug: String,
pub state: String,
pub html_content: String,
pub csrf_token: String,
@@ -631,12 +671,24 @@ pub async fn pages_create(
}
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() {
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 {
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")
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
@@ -1301,3 +1353,188 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
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 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();
+1 -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();
}
+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();
}
+1
View File
@@ -48,6 +48,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",
+6 -1
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">
+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>
+29
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>
+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);
}