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", "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"] }
+259 -81
View File
@@ -1,26 +1,30 @@
# nx9-url-shortener # BZOD
**A lightweight, self-hosted URL management platform written in Rust.** **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 ## Highlights
### v0.2.0 ### v0.3.0
* QR code generation (PNG and SVG) * Human-readable custom slugs
* QR scan analytics * 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 * Password-protected links
* Smart preview pages * Smart preview pages
* Link expiration * Analytics dashboard
* Audit trail * Audit logging
* Bulk operations * Health monitoring
* Expanded test coverage
* Improved health monitoring
--- ---
@@ -29,7 +33,9 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
| Feature | Status | | Feature | Status |
| ------------------------- | ------ | | ------------------------- | ------ |
| URL Shortening | ✅ | | URL Shortening | ✅ |
| Custom Slugs | ✅ |
| Landing Pages | ✅ | | Landing Pages | ✅ |
| Landing Page Custom Slugs | ✅ |
| QR Code Generation | ✅ | | QR Code Generation | ✅ |
| QR Analytics | ✅ | | QR Analytics | ✅ |
| Password-Protected Links | ✅ | | Password-Protected Links | ✅ |
@@ -37,11 +43,12 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
| Link Expiration | ✅ | | Link Expiration | ✅ |
| One-Time Links | ✅ | | One-Time Links | ✅ |
| Audit Trail | ✅ | | Audit Trail | ✅ |
| Bulk Operations | ✅ |
| Analytics Dashboard | ✅ | | Analytics Dashboard | ✅ |
| Health Monitoring | ✅ | | Health Monitoring | ✅ |
| REST API | ✅ | | REST API | ✅ |
| CSV Import/Export | 🚧 | | Backup & Restore | ✅ |
| UTM Campaign Builder | ✅ |
| CLI Automation | ✅ |
| Geo Analytics | 🚧 | | Geo Analytics | 🚧 |
| Multi-User Administration | 🚧 | | Multi-User Administration | 🚧 |
| SSO | 🚧 | | SSO | 🚧 |
@@ -52,20 +59,74 @@ Built with Rust, SQLite, Axum, and Askama, nx9-url-shortener is designed for ind
### 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
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 ### QR Code Generation
@@ -88,20 +149,20 @@ Features:
### Password-Protected Links ### Password-Protected Links
Protect sensitive links using Argon2id-hashed passwords. Protect sensitive links using Argon2id password hashing.
Features: Features:
* Password gate * Password gate
* Secure session handling * Secure session handling
* Configurable protection * Access restrictions
* Audit logging * Audit logging
--- ---
### Smart Preview Pages ### Smart Preview Pages
Display branded preview pages before redirecting. Display branded preview pages before redirecting visitors.
Features: 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 ### Link Lifecycle Management
Control link validity. Control link validity.
@@ -132,10 +181,32 @@ Control link validity.
Features: Features:
* Expiration dates * Expiration dates
* Automatic expiry jobs
* One-time links * One-time links
* Maximum access limits * Access limits
* Administrative disabling * 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 * Total visits
* QR scans * QR scans
* Country statistics * Countries
* Referrers * Referrers
* User agents * User agents
* Daily statistics * Daily statistics
@@ -156,39 +227,46 @@ Track:
### Audit Trail ### Audit Trail
Track administrative actions including: Track administrative activity.
Recorded events include:
* Login * Login
* Logout * Logout
* URL creation * URL creation
* URL updates * URL updates
* URL deletion * URL deletion
* QR operations * Backup creation
* Restore operations
* QR exports
* Configuration changes * Configuration changes
--- ---
### Administrative Dashboard ### Administrative Dashboard
Web-based administration interface featuring: Web-based management interface.
* URL management Features:
* QR code management
* Landing page management * URL registry
* Preview page management * Landing pages
* QR management
* Analytics
* API token management * API token management
* Audit logs * Audit logs
* Link expiration controls * Backup utilities
* Restore utilities
* Health monitoring * Health monitoring
* Analytics dashboard * Server diagnostics
* SVG charts
* Bulk operations
--- ---
### API Support ### REST API
REST API endpoints for automation and integration. REST API support for automation and integrations.
Endpoint prefix:
```text ```text
/api/v1/* /api/v1/*
@@ -198,22 +276,88 @@ Supports:
* URL creation * URL creation
* URL management * URL management
* Landing pages
* QR generation * QR generation
* Analytics access * 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 ### Security
* Password-protected administration interface * Password-protected administration
* Password-protected links * Password-protected links
* Argon2id password hashing * Argon2id password hashing
* Session management
* CSRF protection * CSRF protection
* Session management
* API token authentication * API token authentication
* Audit logging * Audit logging
* Link access controls * Access controls
--- ---
@@ -235,7 +379,7 @@ No:
* PostgreSQL * PostgreSQL
* MongoDB * MongoDB
* Kubernetes * Kubernetes
* External SaaS * SaaS dependencies
--- ---
@@ -243,21 +387,19 @@ No:
### Databases ### Databases
nx9-url-shortener uses four SQLite databases. BZOD uses four SQLite databases.
| Database | Purpose | | Database | Purpose |
| ------------ | ------------------------------------------------- | | ------------ | ------------------------------ |
| admin.db | Users, sessions, API keys | | admin.db | Users, sessions, API keys |
| content.db | URLs, landing pages, preview pages, tags | | content.db | URLs, landing pages, metadata |
| analytics.db | Visits, QR scans, statistics | | analytics.db | Visits, QR scans, statistics |
| system.db | Audit events, jobs, migrations, health monitoring | | system.db | Audit events, jobs, monitoring |
--- ---
## Initial Setup ## Initial Setup
Create an administrator account:
### Native Installation ### Native Installation
```bash ```bash
@@ -267,11 +409,40 @@ cargo run -- create-admin
### Docker ### Docker
```bash ```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 ## Screenshots
### Dashboard ### Dashboard
@@ -298,22 +469,22 @@ docker exec -it nx9-url-shortener nx9-url-shortener create-admin
## 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
``` ```
### Logs Logs:
```bash ```bash
docker logs -f nx9-url-shortener docker logs -f bzod
``` ```
--- ---
@@ -322,9 +493,9 @@ docker logs -f nx9-url-shortener
```yaml ```yaml
services: services:
nx9-url-shortener: bzod:
build: . build: .
container_name: nx9-url-shortener container_name: bzod
restart: unless-stopped restart: unless-stopped
ports: ports:
@@ -344,33 +515,44 @@ services:
## Development ## Development
### Build Build:
```bash ```bash
cargo build cargo build
``` ```
### Run Run:
```bash ```bash
cargo run -- serve cargo run -- serve
``` ```
### Create Administrator Create administrator:
```bash ```bash
cargo run -- create-admin cargo run -- create-admin
``` ```
### Run Tests Run tests:
```bash ```bash
cargo test cargo test
``` ```
---
## Development & Testing ## 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 ## Project Structure
```text ```text
@@ -394,20 +576,17 @@ src/
Planned features: Planned features:
* Vanity URLs
* CSV import/export
* Geo analytics * Geo analytics
* Multi-user administration * Multi-user administration
* SSO integration * SSO integration
* Signed temporary links * Signed temporary links
* OpenAPI documentation * OpenAPI documentation
* Webhook support
--- ---
## Production Deployment ## Production Deployment
Recommended stack: Recommended architecture:
```text ```text
Internet Internet
@@ -416,7 +595,7 @@ Internet
Nginx Proxy Manager Nginx Proxy Manager
│ │
▼ ▼
nx9-url-shortener BZOD
│ │
▼ ▼
SQLite SQLite
@@ -437,4 +616,3 @@ Apache License 2.0
Sunil Purushottam Thakare Sunil Purushottam Thakare
Built with Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software. 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. 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. This is the most important test.
@@ -255,7 +272,7 @@ Expected Result:
--- ---
## 10. Database Health Verification ## 11. Database Health Verification
Run: Run:
@@ -281,7 +298,7 @@ Overall status: HEALTHY
--- ---
## 11. SQLite Integrity Checks ## 12. SQLite Integrity Checks
Manual verification. Manual verification.
@@ -302,7 +319,7 @@ for all databases.
--- ---
## 12. Web Interface Verification ## 13. Web Interface Verification
Start server. Start server.
@@ -321,7 +338,7 @@ Verify:
--- ---
## 13. Docker Verification ## 14. Docker Verification
Build image. Build image.
@@ -357,7 +374,7 @@ inside container.
--- ---
## 14. Upgrade Verification ## 15. Upgrade Verification
1. Create backup. 1. Create backup.
2. Upgrade binary. 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 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();
+1 -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();
} }
+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();
} }
+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/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);
}