Release v0.5.1: namespace integrity, dashboard parity and QR hardening

This commit is contained in:
thakares committed 2026-06-20 19:54:29 +05:30
1 parent 0295b4bd7c
commit 115f6e9a23
45 files changed
+5851 -1765

No files matched your search

Generated
+1 -1
View File
@@ -453,7 +453,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.5.0" version = "0.5.1"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "bzod" name = "bzod"
version = "0.5.0" version = "0.5.1"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+978 -255
View File
File diff suppressed because it is too large. Load diff
+257 -77
View File
@@ -1,93 +1,273 @@
# BZOD v0.5.0 — General Availability # BZOD v0.5.1 — Namespace Integrity & Platform Hardening
**Release Date:** 2026-06-19 **Release Date:** 2026-06-20
**BZOD v0.5.0** is the largest release in project history and marks the transition from a single-user URL shortener into a **production-ready multi-user platform** with tenant isolation, administration tools, analytics, moderation, backups, auditing, and comprehensive validation coverage. BZOD v0.5.1 focuses on platform integrity, multi-tenant safety, dashboard parity, QR reliability, and upgrade validation.
## Key Highlights While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foundations required for safe operation at scale.
- **Multi-User Architecture** with strong tenant isolation
- **Global Slug Namespace** with ownership tracking
- **New Administration Portal** for user management and moderation
- **User Self-Service Portal** with dedicated dashboards
- **Enhanced Analytics** with per-user and platform-wide views
- **Comprehensive Backup & Recovery** tooling
- **Upgrade Framework** with automated migration validation
- **90+ Automated Tests** covering multi-user scenarios, upgrades, and disaster recovery
--- ---
## Multi-User Architecture # Highlights
BZOD now supports multiple isolated users on a single deployment. ## Global Slug Registry
**Key capabilities:** Introduced a hardened global slug registry to guarantee namespace integrity across the entire platform.
- Per-user content and analytics databases
- User quotas and status management
- User sessions and API tokens
- Strong tenant isolation enforcement
Each user's data is strictly separated. The following resources can no longer share the same slug:
## Administration Portal * Administrator URLs
* Administrator Landing Pages
* User URLs
* User Landing Pages
New administrative dashboards include: Duplicate namespace conflicts are automatically detected and blocked.
**User Management**
- Create, delete, enable, disable users
- Password resets and quota management
**Moderation Tools**
- Flag, disable, or re-enable content
- Moderation history
**Session & Audit Management**
- View and revoke sessions
- Full audit viewer
**Backup Management**
- Create, restore, and download backups
## User Self-Service Portal
Standard users now have dedicated dashboards for:
- Profile & password management
- Link and landing page management
- QR code generation
- Personal analytics
- API token management
## Security & Reliability
- Improved authentication and authorization
- Enhanced CSRF, SQL injection, and path traversal protection
- SQLite WAL mode with better transaction handling
- Comprehensive backup & recovery with rollback protection
## Testing & Validation
**Passed:**
- 90+ automated tests
- Full multi-user validation
- Upgrade path verification
- Backup/Restore + Disaster recovery tests
- Security regression tests
- Concurrency and WAL recovery tests
## Breaking Changes
The internal storage model has changed significantly to support multi-user operation.
**Administrators upgrading from v0.4.x should review:**
- `UPGRADE.md`
- `MULTI_USER.md`
- `BACKUP_RESTORE.md`
--- ---
## Get Started ## Namespace Integrity Validation
**One-command installation:** New validation routines now verify:
```bash * Duplicate slug detection
curl -fsSL https://bzo.in/deploy.sh | sudo bash * Missing ownership records
* Invalid registry entries
* Invalid target types
* Orphaned slug references
Namespace conflicts now abort upgrades and restores before corruption can occur.
---
## Reservation-Based Slug Allocation
BZOD now reserves slugs before content creation.
Creation workflow:
```text
Quota Check
↓
Reserve Global Slug
↓
Create Content
↓
Activate Slug
↓
Increment Quota
↓
Audit Log
```
Benefits:
* Prevents race conditions
* Prevents duplicate creation under concurrency
* Enables safer rollback handling
---
## Stale Reservation Recovery
Added automatic cleanup of abandoned slug reservations.
Scenarios covered:
* Server crash during creation
* Interrupted writes
* Failed transactions
BZOD now automatically recovers stale reservations during startup.
---
## Dashboard Parity
Administrator and Standard User dashboards now provide equivalent functionality where appropriate.
Added parity validation for:
* URL management
* Landing page management
* Analytics
* QR code previews
* Export functionality
Differences remain only for administrator-specific operations.
---
## Unified Analytics Templates
Removed duplicated analytics templates.
Benefits:
* Consistent rendering
* Reduced maintenance burden
* Improved reliability
Administrator and user analytics now share the same rendering logic.
---
## QR Code Improvements
QR functionality was substantially improved.
### Added
* Inline QR previews
* PNG downloads
* SVG downloads
* Shared QR rendering component
### Fixed
* Landing page QR generation
* Multi-user QR ownership handling
* QR routing consistency
* Content-type validation
---
## Canonical Landing Page Routing
Landing page slugs now redirect permanently to canonical page URLs.
Example:
```text
/landing-page
```
redirects to:
```text
/p/landing-page
```
using:
```http
301 Moved Permanently
```
This improves consistency and SEO behavior.
---
## Ownership Isolation Hardening
Additional protections ensure:
* Users cannot access another user's analytics
* Users cannot export another user's data
* Users cannot manage another user's resources
New ownership validation tests were added.
---
## Backup & Restore Improvements
Restore operations now validate namespace integrity before importing data.
Benefits:
* No silent slug collisions
* No partial restores
* No hidden ownership conflicts
Restore operations fail safely when conflicts are detected.
---
## Upgrade Validation Enhancements
Upgrade workflows now verify:
* Global namespace consistency
* Duplicate slug conflicts
* Registry integrity
* Tenant ownership correctness
Unsafe upgrades are blocked automatically.
---
## Health & Diagnostics
The system health subsystem now validates:
* Global slug registry integrity
* Namespace conflicts
* Ownership consistency
* Stale reservations
This improves operational visibility and troubleshooting.
---
# Testing & Validation
BZOD v0.5.1 passed:
* Formatting validation (`cargo fmt --check`)
* Static analysis (`cargo clippy --all-targets -- -D warnings`)
* Full automated test suite
* Namespace integrity tests
* Ownership isolation tests
* QR endpoint tests
* Dashboard parity tests
* Upgrade validation tests
* Backup & restore tests
* Disaster recovery tests
* Security tests
* Concurrency tests
All automated tests pass successfully.
---
# Upgrade Notes
Administrators upgrading from v0.5.0 should review:
* UPGRADE.md
* MULTI_USER.md
* BACKUP_RESTORE.md
* DATABASES.md
* TESTING.md
BZOD will automatically validate namespace integrity before completing upgrades.
Duplicate slugs that previously existed across users or resource types must be resolved before migration can proceed.
---
# Breaking Changes
## Global Namespace Enforcement
Slugs are now globally unique across the entire platform.
Configurations that previously relied on duplicate slugs across users or resource types will be rejected during upgrade.
This behavior is intentional and protects routing integrity.
---
# Summary
BZOD v0.5.1 is an integrity-focused release that significantly strengthens:
* Namespace safety
* Multi-tenant isolation
* Dashboard consistency
* QR reliability
* Restore safety
* Upgrade safety
* Operational diagnostics
The result is a more predictable, recoverable, and production-ready platform.
+362 -533
View File
File diff suppressed because it is too large. Load diff
+495 -173
View File
@@ -1,25 +1,24 @@
# Upgrade Guide # Upgrade Guide
Version: v0.5.0 Version: v0.5.1
This document describes the upgrade process from previous BZOD releases to BZOD v0.5.0. This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
--- ---
# Overview # Overview
BZOD v0.5.0 introduces the largest architectural change in project history: BZOD v0.5.1 is a platform hardening release focused on:
* Multi-user architecture * Global namespace integrity
* Tenant isolation * Multi-tenant safety
* Global slug namespace * Dashboard parity
* Centralized authentication * QR reliability
* User quotas * Upgrade validation
* User-specific analytics * Restore collision protection
* Administrative user management * Ownership isolation
* Backup and restore framework
Existing v0.4.x deployments can be upgraded without data loss. While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the operational and data integrity guarantees required for production deployments.
--- ---
@@ -28,80 +27,175 @@ Existing v0.4.x deployments can be upgraded without data loss.
Supported: Supported:
```text ```text
v0.4.0 → v0.5.0 v0.5.0 → v0.5.1
v0.4.x → v0.5.0 v0.4.x → v0.5.1
```
Recommended:
```text
v0.4.x → v0.5.0 → v0.5.1
``` ```
Unsupported: Unsupported:
```text ```text
v0.3.x → v0.5.0 v0.3.x → v0.5.1
``` ```
Older installations should first upgrade to v0.4.x. Older installations should first upgrade to v0.4.x.
--- ---
# Major Changes in v0.5.1
## Global Namespace Enforcement
BZOD now enforces a single platform-wide slug namespace.
The following resources can no longer share the same slug:
* Administrator URLs
* Administrator Landing Pages
* User URLs
* User Landing Pages
Example:
```text
Admin URL:
hello
User URL:
hello
```
Result:
```text
Upgrade aborted.
Namespace conflict detected.
```
---
## Global Slug Registry
BZOD now treats the slug registry as the authoritative source of truth.
All slugs are registered in:
```text
system.db
```
Table:
```text
global_slugs
```
The registry tracks:
```text
slug
owner_user_id
target_type
target_id
status
```
---
## Reservation-Based Slug Allocation
Slug creation now follows:
```text
Quota Validation
↓
Reserve Global Slug
↓
Create Resource
↓
Activate Slug
↓
Update Quotas
↓
Audit Log
```
Benefits:
* Prevents race conditions
* Prevents duplicate allocations
* Improves rollback safety
* Improves multi-user integrity
---
## Stale Reservation Recovery
BZOD automatically cleans abandoned reservations created by:
* Server crashes
* Interrupted requests
* Failed transactions
Stale reservations are validated and cleaned during startup.
---
# Breaking Changes # Breaking Changes
## Database Layout ## Global Slug Uniqueness
### v0.4.x Deployments containing duplicate slugs will not upgrade.
Example:
```text ```text
data/ User 1:
├── admin.db !nx9-dns-server
├── content.db
└── analytics.db User 3:
!nx9-dns-server
``` ```
### v0.5.0 Result:
```text ```text
data/ Upgrade aborted.
├── users.db
├── system.db Database upgrade aborted due to slug conflicts.
└── users/
└── 1/
├── content.db
└── analytics.db
``` ```
Conflicts must be resolved before migration can continue.
--- ---
## Authentication ## Restore Collision Protection
Authentication is now centralized. Restore operations now validate namespace integrity.
Old: Example:
```text ```text
admin.db Existing slug:
company
Backup slug:
company
``` ```
New: Result:
```text ```text
users.db Restore aborted.
Slug conflict detected.
``` ```
Sessions are managed globally. No partial restore occurs.
---
## Global Slug Namespace
Slugs are now unique platform-wide.
Examples:
```text
/example
/company
/docs
```
cannot exist twice.
--- ---
@@ -109,22 +203,23 @@ cannot exist twice.
Before upgrading: Before upgrading:
* Verify current version
* Stop active traffic
* Create backup * Create backup
* Verify backup integrity * Verify backup integrity
* Stop active traffic
* Run diagnostics
* Resolve namespace conflicts
--- ---
## Step 1: Create Backup # Step 1: Create Backup
CLI: Full backup:
```bash ```bash
bzod backup bzod backup
``` ```
or manually archive: Manual backup:
```bash ```bash
tar czf bzod-backup.tar.gz data/ tar czf bzod-backup.tar.gz data/
@@ -132,9 +227,18 @@ tar czf bzod-backup.tar.gz data/
--- ---
## Step 2: Verify Backup # Step 2: Verify Backup
Confirm archive contains: Verify archive contents:
```text
users.db
system.db
users/
```
If upgrading from legacy versions:
```text ```text
admin.db admin.db
@@ -142,9 +246,35 @@ content.db
analytics.db analytics.db
``` ```
should also be present.
--- ---
## Step 3: Stop Service # Step 3: Run Diagnostics
Execute:
```bash
bzod doctor
```
Expected:
```text
Overall Status: HEALTHY
```
Verify:
```text
No namespace conflicts detected
No ownership violations detected
No registry corruption detected
```
---
# Step 4: Stop Service
Systemd: Systemd:
@@ -162,15 +292,15 @@ docker compose down
# Upgrade Procedure # Upgrade Procedure
## Replace Binary ## Install New Version
Install new release: Build:
```bash ```bash
cargo build --release cargo build --release
``` ```
or download release binary. Or install official release binary.
--- ---
@@ -180,93 +310,136 @@ or download release binary.
bzod serve bzod serve
``` ```
On first startup BZOD automatically: or:
1. Detects legacy databases. ```bash
2. Creates users.db. docker compose up -d
3. Creates system.db.
4. Creates administrator tenant.
5. Moves content.db.
6. Moves analytics.db.
7. Creates global slug registry.
8. Runs migrations.
---
# Automatic Migration
Migration performs:
## Administrator Creation
Legacy administrator becomes:
```text
User ID: 1
Type: admin
``` ```
--- ---
## Content Migration # Automatic Upgrade Actions
All URLs migrate into: During startup BZOD automatically performs:
1. Database migration checks
2. Namespace integrity validation
3. Registry validation
4. Stale reservation cleanup
5. Global slug verification
6. Schema migration execution
---
# Namespace Validation
BZOD scans:
```text ```text
users/1/content.db legacy databases
administrator databases
tenant databases
```
for duplicate slugs.
Example:
```text
Owner 1:
hello
Owner 3:
hello
```
Result:
```text
Namespace conflict detected.
Upgrade aborted.
``` ```
--- ---
## Analytics Migration # Registry Validation
All analytics migrate into: BZOD validates:
* Duplicate slug entries
* Missing owners
* Missing targets
* Invalid target types
* Invalid status values
Allowed target types:
```text ```text
users/1/analytics.db url
page
``` ```
--- Allowed statuses:
## Global Slug Registration
All existing slugs are inserted into:
```text ```text
system.db.global_slugs reserving
active
disabled
``` ```
--- ---
# Post-Upgrade Validation # Post-Upgrade Validation
## Login Run:
Verify: ```bash
bzod doctor
```
Expected:
```text ```text
Admin login succeeds Namespace Integrity: PASS
Registry Integrity: PASS
Ownership Integrity: PASS
Database Integrity: PASS
``` ```
--- ---
## URLs # Login Validation
Verify: Verify:
```text ```text
Short URLs redirect Administrator login succeeds
User login succeeds
``` ```
Example: ---
# URL Validation
Verify:
```text ```text
https://example.com/abc123 https://example.com/abc123
``` ```
redirects correctly.
Expected:
```http
302 Found
```
or configured redirect behavior.
--- ---
## Landing Pages # Landing Page Validation
Verify: Verify:
@@ -274,90 +447,166 @@ Verify:
https://example.com/p/demo https://example.com/p/demo
``` ```
renders correctly. renders successfully.
---
## Analytics
Verify:
* Visits visible
* Reports load
* Charts render
---
## User Management
Verify: Verify:
```text ```text
Admin → Users https://example.com/demo
``` ```
loads correctly. redirects permanently:
```http
301 Moved Permanently
```
to:
```text
/p/demo
```
--- ---
# Upgrade Validation Tests # QR Validation
BZOD v0.5.0 includes automated migration tests. Verify:
Validated: ```text
/api/qr/demo.png
/api/qr/demo.svg
```
* Legacy admin migration Expected:
* Legacy content migration
* Legacy analytics migration
* Slug registration
* Redirect preservation
* Analytics preservation
Test suite: ```http
200 OK
```
Content types:
```text
image/png
image/svg+xml
```
Disabled resources:
```http
410 Gone
```
Missing resources:
```http
404 Not Found
```
---
# Dashboard Validation
Verify Administrator Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Verify Standard User Dashboards:
* URLs
* Landing Pages
* Analytics
* QR Preview
* PNG Download
* SVG Download
Both should provide equivalent functionality except for administrator-only operations.
---
# Ownership Isolation Validation
Verify:
```text
User A
```
cannot access:
```text
User B Analytics
User B URLs
User B Landing Pages
User B Exports
```
Expected:
```http
403 Forbidden
```
---
# Backup & Restore Validation
Create backup:
```bash ```bash
cargo test --test upgrade_validation_tests bzod backup
``` ```
Restore backup:
```bash
bzod restore backup.tar.gz
```
Expected:
* No namespace conflicts
* No ownership conflicts
* No partial restores
--- ---
# Rollback Procedure # Rollback Procedure
If upgrade validation fails: If upgrade validation fails:
## Stop Server Stop service:
```bash ```bash
sudo systemctl stop bzod sudo systemctl stop bzod
``` ```
or or:
```bash ```bash
docker compose down docker compose down
``` ```
--- Restore backup:
## Restore Backup
```bash ```bash
bzod restore backup.zip bzod restore backup.tar.gz
``` ```
or restore archived data directory. or restore archived data directory.
--- Reinstall previous release.
## Reinstall Previous Release
Deploy previous v0.4.x binary.
--- ---
# Docker Upgrade # Docker Upgrade
Pull new image: Pull image:
```bash ```bash
docker compose pull docker compose pull
@@ -369,13 +618,19 @@ Restart:
docker compose up -d docker compose up -d
``` ```
Monitor logs: Monitor:
```bash ```bash
docker compose logs -f docker compose logs -f
``` ```
Verify migrations complete successfully. Expected:
```text
Namespace validation passed
Registry validation passed
Server started successfully
```
--- ---
@@ -399,75 +654,142 @@ Verify:
sudo systemctl status bzod sudo systemctl status bzod
``` ```
Expected:
```text
active (running)
```
---
# Automated Upgrade Validation
Execute:
```bash
cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test --all-targets -- --nocapture
```
Particularly validate:
```text
upgrade_validation_tests
backup_restore_tests
slug_registry_tests
ownership_tests
analytics_parity_tests
transaction_tests
```
--- ---
# Recommended Upgrade Workflow # Recommended Upgrade Workflow
```text ```text
1. Create backup 1. Create Backup
2. Stop service 2. Verify Backup
3. Install v0.5.0 3. Run bzod doctor
4. Start service 4. Resolve Namespace Conflicts
5. Run migrations 5. Stop Service
6. Validate login 6. Install v0.5.1
7. Validate URLs 7. Start Service
8. Validate analytics 8. Validate Registry
9. Validate admin dashboard 9. Validate URLs
10. Return to production 10. Validate Landing Pages
11. Validate QR Endpoints
12. Validate Dashboards
13. Validate Ownership Isolation
14. Return To Production
``` ```
--- ---
# Troubleshooting # Troubleshooting
## Login Fails ## Upgrade Aborted Due To Slug Conflicts
Check: Example:
```text ```text
users.db Slug '!nx9-dns-server'
is defined in multiple content databases
by owners [1,3]
``` ```
Verify administrator account exists. Cause:
```text
Duplicate slug detected.
```
Resolution:
```text
Rename or remove conflicting resources.
Restart upgrade.
```
--- ---
## URLs Missing ## QR Codes Return 404
Verify: Verify:
```text ```text
users/1/content.db global_slugs
``` ```
contains migrated records. contains the slug.
Verify slug status:
```text
active
```
--- ---
## Analytics Missing ## Landing Page Redirect Fails
Verify: Verify:
```text ```text
users/1/analytics.db target_type = page
``` ```
contains visit data. in:
```text
global_slugs
```
--- ---
## Slug Resolution Fails ## Ownership Errors
Verify: Run:
```sql ```bash
SELECT * FROM global_slugs; bzod doctor
``` ```
returns expected entries. Verify ownership integrity passes.
--- ---
# Upgrade Status # Upgrade Status
BZOD v0.5.0 upgrade path has been validated through automated migration and integration testing and is considered production-ready for upgrades from v0.4.x deployments. BZOD v0.5.1 upgrade path has been validated through:
* Migration Tests
* Upgrade Validation Tests
* Namespace Integrity Tests
* Ownership Isolation Tests
* Backup & Restore Tests
* Dashboard Parity Tests
* QR Endpoint Tests
* Routing Tests
The v0.5.1 upgrade path is considered production-ready.
+67 -3
View File
@@ -22,11 +22,23 @@ pub async fn run(
println!("Data directory: {:?}", config.data_dir); println!("Data directory: {:?}", config.data_dir);
println!(); println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true; let mut all_healthy = true;
for db_name in &databases { // Define target databases in the new layout
let db_path = config.data_dir.join(format!("{}.db", db_name)); let admin_dir = config.data_dir.join("admin");
let dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", config.data_dir.join("content.db")),
("legacy analytics", config.data_dir.join("analytics.db")),
];
for (db_name, db_path) in dbs {
// Skip legacy databases if they don't exist
if db_name.starts_with("legacy") && !db_path.exists() {
continue;
}
if !db_path.exists() { if !db_path.exists() {
println!("Database: {}", db_name); println!("Database: {}", db_name);
@@ -75,6 +87,58 @@ pub async fn run(
println!(); println!();
} }
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if system_db_path.exists() && users_db_path.exists() {
match (
Connection::open(&system_db_path),
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::db::users::verify_global_slug_registry_integrity(
&sys_conn,
&usr_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
}
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", e);
all_healthy = false;
}
}
}
_ => {
println!(" Status: ERROR opening system.db or users.db for integrity check");
all_healthy = false;
}
}
} else {
println!(" Status: SKIPPED (system.db/users.db not found)");
}
println!();
println!("--------------------"); println!("--------------------");
if all_healthy { if all_healthy {
println!("Overall status: HEALTHY"); println!("Overall status: HEALTHY");
+83 -22
View File
@@ -15,34 +15,95 @@ pub fn perform_restore(
let tar_gz = GzDecoder::new(f); let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz); let mut archive = Archive::new(tar_gz);
// 2. Validate that the archive contains the expected BZOD database files // 2. Unpack to temporary directory first
let mut has_admin = false; let temp_dir =
let mut has_content = false; std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
let mut has_analytics = false; std::fs::create_dir_all(&temp_dir)?;
let mut has_system = false;
for entry_res in archive.entries()? { if let Err(e) = archive.unpack(&temp_dir) {
let entry = entry_res?; let _ = std::fs::remove_dir_all(&temp_dir);
let path = entry.path()?; return Err(e.into());
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); }
match file_name {
"admin.db" => has_admin = true, // 3. Run validation on temp_dir
"content.db" => has_content = true, let mut temp_config = Config::load();
"analytics.db" => has_analytics = true, temp_config.data_dir = temp_dir.clone();
"system.db" => has_system = true,
_ => {} // Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
Ok(report) => {
if !report.duplicates.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(
format!("Slug conflicts detected in backup: {:?}", report.duplicates).into(),
);
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to audit slug namespace in backup: {}", e).into());
} }
} }
if !has_admin || !has_content || !has_analytics || !has_system { // Registry integrity check
return Err("Archive is missing one or more required database files (admin.db, content.db, analytics.db, system.db)".into()); let system_db_path = if temp_dir.join("admin/system.db").exists() {
temp_dir.join("admin/system.db")
} else {
temp_dir.join("system.db")
};
let users_db_path = if temp_dir.join("admin/users.db").exists() {
temp_dir.join("admin/users.db")
} else {
temp_dir.join("users.db")
};
if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let users_conn = rusqlite::Connection::open(&users_db_path)?;
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&temp_dir,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
}
}
Err(e) => {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to verify registry integrity in backup: {}", e).into());
}
}
} }
// 3. Unpack archive to data_dir // 4. If validation succeeds, copy temp_dir contents to data_dir
let f2 = File::open(file_path)?; if data_dir.exists() {
let tar_gz2 = GzDecoder::new(f2); let _ = std::fs::remove_dir_all(data_dir);
let mut archive2 = Archive::new(tar_gz2); }
archive2.unpack(data_dir)?; std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst.join(entry.file_name()))?;
} else {
std::fs::copy(entry.path(), dst.join(entry.file_name()))?;
}
}
Ok(())
}
if let Err(e) = copy_dir_all(&temp_dir, data_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to copy restored files: {}", e).into());
}
let _ = std::fs::remove_dir_all(&temp_dir);
Ok(()) Ok(())
} }
+7 -94
View File
@@ -1,7 +1,5 @@
use crate::config::Config; use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File; use std::fs::File;
use std::path::PathBuf; use std::path::PathBuf;
use tar::Archive; use tar::Archive;
@@ -158,103 +156,18 @@ pub async fn run(
} }
} }
// 4. Register slugs in global_slugs // 4. Register slugs in global_slugs using the shared helper
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
{ {
let mut system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?; crate::db::users::register_restored_user_slugs(
&system_conn,
// Delete any existing global slugs owned by this user target_user_id,
tx.execute( &dest_dir.join("content.db"),
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
)?; )?;
// Register URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
// Register Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let existing_owner: Option<i64> = tx
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
error!(
"Conflict: Slug '{}' is already owned by user ID {}. Skipping.",
slug, owner
);
continue;
}
}
tx.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
}
tx.commit()?;
} }
// 5. Reconcile quotas for restored user // 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas( crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(), &db.users.lock().unwrap(),
target_user_id, target_user_id,
+29 -7
View File
@@ -33,7 +33,16 @@ pub async fn run(
None => crate::utils::random::generate_token(3), None => crate::utils::random::generate_token(3),
}; };
// 3. Persist URL // 3. Register slug in system.db with status 'reserving' and check availability
{
let system_conn = db.system.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? {
return Err("Short code/slug already exists".into());
}
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
}
// 4. Persist URL
let conn = db.content.lock().unwrap(); let conn = db.content.lock().unwrap();
let res = crate::db::content::create_url_extended( let res = crate::db::content::create_url_extended(
&conn, &conn,
@@ -48,7 +57,21 @@ pub async fn run(
); );
match res { match res {
Ok(_) => { Ok(url) => {
// Activate slug in system.db
{
let system_conn = db.system.lock().unwrap();
system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
}
// Increment quota for user ID 1
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
}
let proto = if config.cookie_secure { let proto = if config.cookie_secure {
"https" "https"
} else { } else {
@@ -63,11 +86,10 @@ pub async fn run(
println!("{}/{}", base_url, code); println!("{}/{}", base_url, code);
Ok(()) Ok(())
} }
Err(rusqlite::Error::SqliteFailure(err, _)) Err(e) => {
if err.code == rusqlite::ErrorCode::ConstraintViolation => let system_conn = db.system.lock().unwrap();
{ let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Err("Short code/slug already exists".into()) Err(e.into())
} }
Err(e) => Err(e.into()),
} }
} }
+58
View File
@@ -68,6 +68,26 @@ impl Db {
} }
} }
// Pre-migration safety net: audit slug namespace for duplicates / format errors
match crate::db::users::audit_slug_namespace(config) {
Ok(report) => {
if !report.duplicates.is_empty() {
tracing::error!(
"Namespace conflicts detected before database migration: {:?}",
report.duplicates
);
return Err(format!(
"Database upgrade aborted due to slug conflicts: {:?}",
report.duplicates
)
.into());
}
}
Err(e) => {
tracing::warn!("Failed to audit slug namespace before migration: {}", e);
}
}
let admin_path = admin_dir.join("admin.db"); let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db"); let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db"); let users_db_path = admin_dir.join("users.db");
@@ -317,6 +337,44 @@ impl Db {
let _ = db.reconcile_global_slugs(config); let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
}
}
Err(e) => {
tracing::error!("Failed to clean up stale reservations: {}", e);
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db) Ok(db)
} }
+469 -2
View File
@@ -303,6 +303,19 @@ pub fn get_user_quotas(conn: &Connection, user_id: i64) -> rusqlite::Result<Opti
.optional() .optional()
} }
pub fn check_quota_limit(conn: &Connection, user_id: i64, field: &str) -> rusqlite::Result<bool> {
if let Some(quotas) = get_user_quotas(conn, user_id)? {
match field {
"urls" => Ok(quotas.current_urls < quotas.max_urls),
"landings" => Ok(quotas.current_landings < quotas.max_landings),
"api_tokens" => Ok(quotas.current_api_tokens < quotas.max_api_tokens),
_ => Ok(false),
}
} else {
Ok(false)
}
}
pub fn update_user_quotas( pub fn update_user_quotas(
conn: &Connection, conn: &Connection,
user_id: i64, user_id: i64,
@@ -458,12 +471,13 @@ pub fn register_global_slug(
owner_user_id: i64, owner_user_id: i64,
target_type: &str, target_type: &str,
target_id: &str, target_id: &str,
status: &str,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
system_conn.execute( system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status) "INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, "active"], rusqlite::params![slug, owner_user_id, target_type, target_id, now, now, status],
)?; )?;
// Insert history // Insert history
@@ -501,7 +515,7 @@ pub fn soft_delete_global_slug(
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339(); let now = Utc::now().to_rfc3339();
system_conn.execute( system_conn.execute(
"UPDATE global_slugs SET status = 'soft_deleted', deleted_at = ?1 WHERE slug = ?2;", "UPDATE global_slugs SET status = 'disabled', deleted_at = ?1 WHERE slug = ?2;",
rusqlite::params![now, slug], rusqlite::params![now, slug],
)?; )?;
@@ -515,6 +529,459 @@ pub fn soft_delete_global_slug(
Ok(()) Ok(())
} }
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugAuditReport {
pub duplicates: Vec<String>,
pub invalid_entries: Vec<String>,
pub warnings: Vec<String>,
}
pub fn audit_slug_namespace(
config: &crate::config::Config,
) -> Result<SlugAuditReport, Box<dyn std::error::Error>> {
use std::collections::HashMap;
let mut duplicates = Vec::new();
let mut invalid_entries = Vec::new();
let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db");
if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
}
}
}
}
// Landing Pages
if let Ok(mut stmt) = conn.prepare("SELECT code FROM landing_pages;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1);
}
}
}
}
}
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users");
if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() {
let content_db_path = path.join("content.db");
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
// URLs
if let Ok(mut stmt) = conn.prepare("SELECT code FROM urls;") {
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
// Landing pages
if let Ok(mut stmt) =
conn.prepare("SELECT code FROM landing_pages;")
{
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
}
}
}
}
}
}
}
}
}
}
}
// 3. Populate report
for (slug, owners) in slug_owners {
if owners.len() > 1 {
duplicates.push(format!(
"Slug '{}' is defined in multiple content databases by owners {:?}",
slug, owners
));
}
// Validate slug format
let valid_url = crate::utils::validation::validate_redirect_code(&slug);
let valid_page = crate::utils::validation::validate_page_code(&slug);
if !valid_url && !valid_page {
invalid_entries.push(format!("Slug '{}' is format-invalid", slug));
}
}
Ok(SlugAuditReport {
duplicates,
invalid_entries,
warnings,
})
}
pub fn cleanup_stale_reservations(
system_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<usize, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut cleaned_count = 0;
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, created_at FROM global_slugs WHERE status = 'reserving';"
)?;
let mut rows = stmt.query([])?;
let mut stale_slugs = Vec::new();
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let created_at_str: String = row.get(3)?;
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
let mut target_exists = false;
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
if target_type == "url" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
} else if target_type == "page" {
target_exists = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE code = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
}
}
}
if !target_exists {
stale_slugs.push((slug, owner_user_id));
}
}
}
}
drop(rows);
drop(stmt);
for (slug, owner_user_id) in stale_slugs {
system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug])?;
let now = Utc::now().to_rfc3339();
system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp)
VALUES (?1, ?2, NULL, 'released', ?3);",
rusqlite::params![slug, owner_user_id, now],
)?;
cleaned_count += 1;
}
Ok(cleaned_count)
}
pub fn verify_global_slug_registry_integrity(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. Check duplicate slugs
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 =
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
r.get(0)
})?;
if total_count != distinct_count {
errors.push(format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
));
}
// 2. Scan all global slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
// Target type check
if target_type != "url" && target_type != "page" {
errors.push(format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
));
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
errors.push(format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
));
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
warnings.push(format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
));
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
if !content_db_path.exists() {
errors.push(format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
));
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
}
}
Err(e) => {
errors.push(format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
));
}
}
}
}
}
Ok((errors, warnings))
}
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
restored_content_db_path: &std::path::Path,
) -> Result<(), Box<dyn std::error::Error>> {
let restored_content_conn = Connection::open(restored_content_db_path)?;
let mut urls = Vec::new();
let mut landing_pages = Vec::new();
// 1. Read URLs
{
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
urls.push((code, id, created_at, status));
}
}
// 2. Read Landing Pages
{
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
landing_pages.push((code, id, created_at, state));
}
}
// 3. Check for collisions across all URLs and landing pages
let mut conflicting_slugs = Vec::new();
for (slug, _, _, _) in &urls {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
for (slug, _, _, _) in &landing_pages {
let existing_owner: Option<i64> = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[slug],
|r| r.get(0),
)
.optional()?;
if let Some(owner) = existing_owner {
if owner != target_user_id {
conflicting_slugs.push(slug.clone());
}
}
}
if !conflicting_slugs.is_empty() {
return Err(format!(
"Restore failed. Conflicting slugs: {}",
conflicting_slugs.join(", ")
)
.into());
}
// 4. Perform registration
system_conn.execute(
"DELETE FROM global_slugs WHERE owner_user_id = ?1;",
[target_user_id],
)?;
for (slug, target_id, created_at, status) in urls {
let now = Utc::now().to_rfc3339();
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'url', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, global_status],
)?;
}
for (slug, target_id, created_at, state) in landing_pages {
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
system_conn.execute(
"INSERT OR REPLACE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, 'page', ?3, ?4, ?5, ?6);",
rusqlite::params![slug, target_user_id, target_id, created_at, now, status],
)?;
}
Ok(())
}
pub fn reconcile_user_quotas( pub fn reconcile_user_quotas(
users_conn: &Connection, users_conn: &Connection,
user_id: i64, user_id: i64,
+2
View File
@@ -42,6 +42,7 @@ pub struct UrlAnalyticsTemplate {
pub page_end: usize, pub page_end: usize,
pub date_from: Option<String>, pub date_from: Option<String>,
pub date_to: Option<String>, pub date_to: Option<String>,
pub is_admin: bool,
} }
impl UrlAnalyticsTemplate { impl UrlAnalyticsTemplate {
@@ -84,6 +85,7 @@ pub struct PageAnalyticsTemplate {
pub page_end: usize, pub page_end: usize,
pub date_from: Option<String>, pub date_from: Option<String>,
pub date_to: Option<String>, pub date_to: Option<String>,
pub is_admin: bool,
} }
impl PageAnalyticsTemplate { impl PageAnalyticsTemplate {
+2 -46
View File
@@ -276,6 +276,8 @@ pub struct HealthTemplate {
pub tenants_db_size: String, pub tenants_db_size: String,
pub job_history: Vec<crate::web::admin::JobHistoryRow>, pub job_history: Vec<crate::web::admin::JobHistoryRow>,
pub health_checks: Vec<crate::web::admin::HealthCheckRow>, pub health_checks: Vec<crate::web::admin::HealthCheckRow>,
pub registry_errors: Vec<String>,
pub registry_warnings: Vec<String>,
pub csrf_token: String, pub csrf_token: String,
pub success: Option<String>, pub success: Option<String>,
pub error: Option<String>, pub error: Option<String>,
@@ -372,49 +374,3 @@ impl IntoResponse for UserAnalyticsTemplate {
} }
} }
} }
#[derive(Template)]
#[template(path = "user_url_analytics.html")]
pub struct UserUrlAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub url_code: String,
pub destination: String,
pub visits: Vec<VisitorLogEntry>,
}
impl IntoResponse for UserUrlAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
#[derive(Template)]
#[template(path = "user_page_analytics.html")]
pub struct UserPageAnalyticsTemplate {
pub admin_username: String,
pub username: String,
pub page_code: String,
pub title: String,
pub visits: Vec<VisitorLogEntry>,
}
impl IntoResponse for UserPageAnalyticsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Render error: {}", e),
)
.into_response(),
}
}
}
+1046 -162
View File
File diff suppressed because it is too large. Load diff
+209 -45
View File
@@ -149,20 +149,105 @@ pub async fn api_create_url(
None None
}; };
// Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "urls")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let tags = payload.tags.unwrap_or_default(); let tags = payload.tags.unwrap_or_default();
let conn = state.content_db.lock().unwrap(); let res = {
match crate::db::content::create_url_extended( let conn = content_db.lock().unwrap();
&conn, crate::db::content::create_url_extended(
&code, &conn,
&dest, &code,
payload.title.as_deref(), &dest,
payload.description.as_deref(), payload.title.as_deref(),
&tags, payload.description.as_deref(),
payload.expires_at.as_deref(), &tags,
password_hash.as_deref(), payload.expires_at.as_deref(),
payload.max_access_count, password_hash.as_deref(),
) { payload.max_access_count,
)
};
match res {
Ok(url) => { Ok(url) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
@@ -189,24 +274,17 @@ pub async fn api_create_url(
} }
(StatusCode::CREATED, Json(url)).into_response() (StatusCode::CREATED, Json(url)).into_response()
} }
Err(rusqlite::Error::SqliteFailure(err, _)) Err(e) => {
if err.code == rusqlite::ErrorCode::ConstraintViolation => let system_conn = state.system_db.lock().unwrap();
{ let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
( (
StatusCode::CONFLICT, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: e.to_string(),
}), }),
) )
.into_response() .into_response()
} }
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
} }
} }
@@ -434,16 +512,109 @@ pub async fn api_create_page(
} }
} }
let conn = state.content_db.lock().unwrap(); // Dynamically resolve target user ID and content DB
match create_landing_page( let (target_user_id, content_db) = match user.0 {
&conn, crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
&code, crate::models::ApiActor::User(ref u) => {
&payload.slug, let user_dbs = match state.get_user_dbs(u.id) {
&payload.title, Ok(dbs) => dbs,
&payload.html_content, Err(_) => {
&payload.state, return (
) { StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, target_user_id, "landings")
.unwrap_or(false)
{
return (
StatusCode::FORBIDDEN,
Json(ApiError {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
}
// Check availability
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return (
StatusCode::CONFLICT,
Json(ApiError {
error: "Short code already exists".to_string(),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"page",
"",
"reserving",
) {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: format!("Failed to reserve slug: {}", e),
}),
)
.into_response();
}
}
let res = {
let conn = content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&payload.slug,
&payload.title,
&payload.html_content,
&payload.state,
)
};
match res {
Ok(page) => { Ok(page) => {
// Activate slug
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if payload.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
// Increment quota
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
target_user_id,
"landings",
);
}
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log( let _ = write_audit_log(
@@ -457,24 +628,17 @@ pub async fn api_create_page(
); );
(StatusCode::CREATED, Json(page)).into_response() (StatusCode::CREATED, Json(page)).into_response()
} }
Err(rusqlite::Error::SqliteFailure(err, _)) Err(e) => {
if err.code == rusqlite::ErrorCode::ConstraintViolation => let system_conn = state.system_db.lock().unwrap();
{ let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
( (
StatusCode::CONFLICT, StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError { Json(ApiError {
error: "Short code already exists".to_string(), error: e.to_string(),
}), }),
) )
.into_response() .into_response()
} }
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(ApiError {
error: e.to_string(),
}),
)
.into_response(),
} }
} }
+137 -13
View File
@@ -165,7 +165,53 @@ pub async fn api_bulk_url(
.into_response(); .into_response();
} }
let mut conn = state.content_db.lock().unwrap(); // Dynamically resolve target user ID and content DB
let (target_user_id, content_db) = match user.0 {
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
crate::models::ApiActor::User(ref u) => {
let user_dbs = match state.get_user_dbs(u.id) {
Ok(dbs) => dbs,
Err(_) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: "Database error".to_string(),
}),
)
.into_response()
}
};
(u.id, user_dbs.content.clone())
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if let Some(quotas) =
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
{
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
} else {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "User quota not found".to_string(),
}),
)
.into_response();
}
}
let mut conn = content_db.lock().unwrap();
let tx = match conn.transaction() { let tx = match conn.transaction() {
Ok(t) => t, Ok(t) => t,
Err(e) => { Err(e) => {
@@ -180,6 +226,7 @@ pub async fn api_bulk_url(
}; };
let mut created_urls = Vec::new(); let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload { for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase(); let mut code = item.code.unwrap_or_default().trim().to_lowercase();
@@ -188,6 +235,12 @@ pub async fn api_bulk_url(
} else { } else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) { if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback(); let _ = tx.rollback();
// Release reserving slugs
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::BAD_REQUEST, StatusCode::BAD_REQUEST,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -198,11 +251,66 @@ pub async fn api_bulk_url(
} }
} }
// Reserve slug
{
let system_conn = state.system_db.lock().unwrap();
// Check availability in system.db and also check in our currently reserved slugs in this batch
let available = crate::db::users::is_slug_available(&system_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to reserve slug '{}': {}", code, e),
}),
)
.into_response();
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password { let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) { match hash_password(pwd) {
Ok(h) => Some(h), Ok(h) => Some(h),
Err(e) => { Err(e) => {
let _ = tx.rollback(); let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(
&system_conn,
slug,
target_user_id,
);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -229,20 +337,13 @@ pub async fn api_bulk_url(
item.max_access_count, item.max_access_count,
) { ) {
Ok(url) => created_urls.push(url), Ok(url) => created_urls.push(url),
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
let _ = tx.rollback();
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
Err(e) => { Err(e) => {
let _ = tx.rollback(); let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -255,6 +356,10 @@ pub async fn api_bulk_url(
} }
if let Err(e) = tx.commit() { if let Err(e) = tx.commit() {
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return ( return (
StatusCode::INTERNAL_SERVER_ERROR, StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse { Json(BulkErrorResponse {
@@ -264,6 +369,25 @@ pub async fn api_bulk_url(
.into_response(); .into_response();
} }
// Activate slugs
{
let system_conn = state.system_db.lock().unwrap();
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = state.users_db.lock().unwrap();
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
}
// Write Audit Log for the entire batch // Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info); let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok()); let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
+10 -5
View File
@@ -63,14 +63,19 @@ pub async fn resolve_page(
.into_response(); .into_response();
} }
// 2. Get user specific database connections // 2. Get content database connection - admin (user_id=1) uses legacy content_db,
let user_dbs = match state.get_user_dbs(owner_user_id) { // tenant users use per-user content databases
Ok(dbs) => dbs, let content_conn = if owner_user_id == 1 {
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
let page_opt = { let page_opt = {
let conn = user_dbs.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
match crate::db::content::get_landing_page_by_code(&conn, &code) { match crate::db::content::get_landing_page_by_code(&conn, &code) {
Ok(page) => page, Ok(page) => page,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
+59 -71
View File
@@ -10,7 +10,6 @@ use std::net::SocketAddr;
use serde_json::json; use serde_json::json;
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
// GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef) // GET /api/qr/:file (e.g. /api/qr/abcdef.png or /api/qr/abcdef.svg or JSON stats /api/qr/abcdef)
pub async fn qr_handler( pub async fn qr_handler(
State(state): State<AppState>, State(state): State<AppState>,
@@ -38,12 +37,12 @@ pub async fn qr_handler(
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(); return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
} }
// We need to look up owner_user_id and status from global_slugs // We need to look up owner_user_id, target_id, and status from global_slugs
let (owner_user_id, slug_status) = { let (owner_user_id, target_id, slug_status) = {
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn let mut stmt = match system_conn.prepare(
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") "SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
{ ) {
Ok(s) => s, Ok(s) => s,
Err(_) => { Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
@@ -52,19 +51,25 @@ pub async fn qr_handler(
use rusqlite::OptionalExtension; use rusqlite::OptionalExtension;
match stmt match stmt
.query_row(rusqlite::params![&file], |row| { .query_row(rusqlite::params![&file], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
}) })
.optional() .optional()
{ {
Ok(Some((uid, status))) => (uid, status), Ok(Some((uid, tid, status))) => (uid, tid, status),
Ok(None) => (1, "active".to_string()), // fallback to admin Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
Err(_) => { Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response() return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
} }
} }
}; };
if slug_status != "active" { if slug_status == "disabled" {
return (StatusCode::GONE, "This content has been disabled").into_response();
} else if slug_status != "active" {
return (StatusCode::NOT_FOUND, "URL not found").into_response(); return (StatusCode::NOT_FOUND, "URL not found").into_response();
} }
@@ -73,31 +78,16 @@ pub async fn qr_handler(
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}; };
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &file) {
Ok(u) => u,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
}
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
};
let qr_scans = { let qr_scans = {
let conn = user_dbs.analytics.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0) crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
}; };
let direct_clicks = { let direct_clicks = {
let conn = user_dbs.analytics.lock().unwrap(); let conn = user_dbs.analytics.lock().unwrap();
conn.query_row( conn.query_row(
"SELECT COUNT(*) FROM visits WHERE target_type = 'url' AND target_id = ?1;", "SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
rusqlite::params![url.id], rusqlite::params![target_id],
|row| row.get(0), |row| row.get(0),
) )
.unwrap_or(0) .unwrap_or(0)
@@ -113,15 +103,17 @@ 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 !crate::utils::validation::validate_redirect_code(code) { if !crate::utils::validation::validate_redirect_code(code)
&& !crate::utils::validation::validate_page_code(code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
// We need to look up owner_user_id and status from global_slugs // We need to look up owner_user_id, target_type, target_id, and status from global_slugs
let (owner_user_id, slug_status) = { let (owner_user_id, target_type, target_id, slug_status) = {
let system_conn = state.system_db.lock().unwrap(); let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn let mut stmt = match system_conn
.prepare("SELECT owner_user_id, status FROM global_slugs WHERE slug = ?1;") .prepare("SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;")
{ {
Ok(s) => s, Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
@@ -129,38 +121,27 @@ pub async fn qr_handler(
use rusqlite::OptionalExtension; use rusqlite::OptionalExtension;
match stmt match stmt
.query_row(rusqlite::params![code], |row| { .query_row(rusqlite::params![code], |row| {
Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
}) })
.optional() .optional()
{ {
Ok(Some((uid, status))) => (uid, status), Ok(Some(info)) => info,
Ok(None) => (1, "active".to_string()), // fallback to admin Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
} }
}; };
if slug_status != "active" { if slug_status == "disabled" {
return (StatusCode::NOT_FOUND, "Url not found").into_response(); return (StatusCode::GONE, "This content has been disabled").into_response();
} else if slug_status != "active" {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
let user_dbs = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let url_opt = {
let conn = user_dbs.content.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, code) {
Ok(u) => u,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Url not found").into_response(),
};
// Construct public base URL // Construct public base URL
let proto = if state.config.cookie_secure { let proto = if state.config.cookie_secure {
"https" "https"
@@ -178,7 +159,11 @@ pub async fn qr_handler(
.clone() .clone()
.unwrap_or_else(|| format!("{}://{}", proto, host_header)); .unwrap_or_else(|| format!("{}://{}", proto, host_header));
let full_url = format!("{}/{}", base_url.trim_end_matches('/'), code); let full_url = if target_type == "page" {
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
} else {
format!("{}/{}", base_url.trim_end_matches('/'), code)
};
// Generate QR code based on format // Generate QR code based on format
let (body, content_type) = if ext == "svg" { let (body, content_type) = if ext == "svg" {
@@ -211,22 +196,25 @@ pub async fn qr_handler(
.into_response(); .into_response();
}; };
// Log the QR access event // Log the QR access event in a try-catch style
let ip = get_client_ip(&headers, connect_info); let _ = {
let user_agent = headers let ip = get_client_ip(&headers, connect_info);
.get("user-agent") let user_agent = headers
.and_then(|h| h.to_str().ok()) .get("user-agent")
.map(|s| s.to_string()); .and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
{ if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
let analytics_conn = user_dbs.analytics.lock().unwrap(); if let Ok(analytics_conn) = user_dbs.analytics.lock() {
let _ = crate::db::qr::log_qr_access( let _ = crate::db::qr::log_qr_access(
&analytics_conn, &analytics_conn,
&url.id, &target_id,
Some(ip.as_str()), Some(ip.as_str()),
user_agent.as_deref(), user_agent.as_deref(),
); );
} }
}
};
Response::builder() Response::builder()
.header("content-type", content_type) .header("content-type", content_type)
+32 -12
View File
@@ -24,8 +24,10 @@ 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 or a valid custom slug) // Basic validation of code (must be 6 hex characters, 4 hex characters, or a valid custom slug)
if !crate::utils::validation::validate_redirect_code(&code) { if !crate::utils::validation::validate_redirect_code(&code)
&& !crate::utils::validation::validate_page_code(&code)
{
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
@@ -49,7 +51,7 @@ pub async fn resolve_redirect(
.optional() .optional()
}; };
let (owner_user_id, _target_type, _target_id, slug_status) = match slug_info { let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Ok(Some(info)) => info, Ok(Some(info)) => info,
Ok(None) => { Ok(None) => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs // Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
@@ -67,14 +69,24 @@ pub async fn resolve_redirect(
.into_response(); .into_response();
} }
// 2. Get user specific database connections // If target type is page, redirect permanently to /p/slug
let user_dbs = match state.get_user_dbs(owner_user_id) { if target_type == "page" {
Ok(dbs) => dbs, return Redirect::permanent(&format!("/p/{}", code)).into_response();
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), }
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
}; };
let url_opt = { let url_opt = {
let conn = user_dbs.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &code) { match crate::db::content::get_url_by_code(&conn, &code) {
Ok(url) => url, Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
@@ -96,7 +108,7 @@ pub async fn resolve_redirect(
if expires_at.with_timezone(&Utc) < Utc::now() { if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately // Mark as expired in DB asynchronously/immediately
{ {
let conn = user_dbs.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
let _ = conn.execute( let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;", "UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()], [url.id.clone()],
@@ -131,12 +143,12 @@ pub async fn resolve_redirect(
// 6. Increment access count & retrieve preview config // 6. Increment access count & retrieve preview config
let _new_access_count = { let _new_access_count = {
let conn = user_dbs.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1) crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
}; };
let preview_opt = { let preview_opt = {
let conn = user_dbs.content.lock().unwrap(); let conn = content_conn.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
}; };
@@ -188,6 +200,14 @@ pub async fn resolve_redirect(
} }
.into_response() .into_response()
} else { } else {
Redirect::temporary(&url.destination).into_response() {
use axum::http::{header, HeaderValue};
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&url.destination).unwrap(),
);
resp
}
} }
} }
+16
View File
@@ -52,10 +52,26 @@ pub fn create_router(state: AppState) -> Router {
"/user/analytics/url/:id", "/user/analytics/url/:id",
get(admin::user_url_analytics_get), get(admin::user_url_analytics_get),
) )
.route(
"/user/analytics/url/:id/export/csv",
get(admin::user_url_analytics_csv_export),
)
.route(
"/user/analytics/url/:id/export/json",
get(admin::user_url_analytics_json_export),
)
.route( .route(
"/user/analytics/page/:id", "/user/analytics/page/:id",
get(admin::user_page_analytics_get), get(admin::user_page_analytics_get),
) )
.route(
"/user/analytics/page/:id/export/csv",
get(admin::user_page_analytics_csv_export),
)
.route(
"/user/analytics/page/:id/export/json",
get(admin::user_page_analytics_json_export),
)
.route("/api-tokens", get(admin::api_tokens_get)) .route("/api-tokens", get(admin::api_tokens_get))
.route("/api-tokens/create", post(admin::api_tokens_create_post)) .route("/api-tokens/create", post(admin::api_tokens_create_post))
.route( .route(
+9
View File
@@ -0,0 +1,9 @@
<td style="text-align: center; vertical-align: middle;">
<a href="/api/qr/{{ code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
<a href="/api/qr/{{ code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</td>
+32
View File
@@ -7,6 +7,38 @@
{% block header_title %}System Health Dashboard{% endblock %} {% block header_title %}System Health Dashboard{% endblock %}
{% block content %} {% block content %}
{% if !registry_errors.is_empty() || !registry_warnings.is_empty() %}
<div style="display: grid; grid-template-columns: 1fr; gap: 1rem; margin-bottom: 1.5rem;">
{% if !registry_errors.is_empty() %}
<div class="card" style="border: 1px solid var(--dead-color); background-color: rgba(220, 53, 69, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: var(--dead-color); display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Errors (Action Required)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for err in registry_errors %}
<li>{{ err }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if !registry_warnings.is_empty() %}
<div class="card" style="border: 1px solid #ffc107; background-color: rgba(255, 193, 7, 0.1); padding: 1.5rem;">
<h3 style="font-size: 1.15rem; color: #ffc107; display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; margin-top: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
Global Registry Warnings (Attention Needed)
</h3>
<ul style="margin: 0; padding-left: 1.5rem; color: var(--text-primary); display: flex; flex-direction: column; gap: 0.25rem;">
{% for warn in registry_warnings %}
<li>{{ warn }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start; margin-bottom: 1.5rem;">
<!-- DB Health Report --> <!-- DB Health Report -->
<div class="card" style="padding: 0; overflow: hidden;"> <div class="card" style="padding: 0; overflow: hidden;">
+104 -10
View File
@@ -4,17 +4,111 @@
{% block active_pages %}active{% endblock %} {% block active_pages %}active{% endblock %}
{% block sidebar_links %}
{% if is_admin %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li>
<a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="active">
<a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li>
<a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1-1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/admin/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/admin/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% else %}
<li>
<a href="/user/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li>
<a href="/user/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="active">
<a href="/user/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/user/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/user/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/user/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% endif %}
{% endblock %}
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
</div>
{% endblock %}
{% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %} {% block header_title %}Page Analytics: /p/{{ page.code }}{% endblock %}
{% block header_actions %} {% block header_actions %}
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<a href="/admin/analytics/page/{{ page.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/csv{% else %}/user/analytics/page/{{ page.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export CSV 📥 Export CSV
</a> </a>
<a href="/admin/analytics/page/{{ page.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}/export/json{% else %}/user/analytics/page/{{ page.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export JSON 📥 Export JSON
</a> </a>
<a href="/admin/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/pages{% else %}/user/pages{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages Back to Landing Pages
</a> </a>
@@ -24,7 +118,7 @@
{% block content %} {% block content %}
<!-- Date Filter Form --> <!-- Date Filter Form -->
<div class="card" style="margin-bottom: 2rem;"> <div class="card" style="margin-bottom: 2rem;">
<form method="GET" action="/admin/analytics/page/{{ page.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;"> <form method="GET" action="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;"> <div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label> <label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;"> <input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
@@ -34,7 +128,7 @@
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;"> <input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div> </div>
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button> <button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
</form> </form>
</div> </div>
@@ -195,8 +289,8 @@
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;"> <div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
{% if current_page > 1 %} {% if current_page > 1 %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %} {% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
@@ -206,13 +300,13 @@
{% if self.is_current(p) %} {% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span> <span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %} {% else %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if current_page < total_pages %} {% if current_page < total_pages %}
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/analytics/page/{{ page.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a> <a href="{% if is_admin %}/admin/analytics/page/{{ page.id }}{% else %}/user/analytics/page/{{ page.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %} {% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
+4 -1
View File
@@ -84,6 +84,7 @@
<th>SEO Preview Path</th> <th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th> <th>Analytics</th>
<th>QR Code</th>
<th>Created</th> <th>Created</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
@@ -91,7 +92,7 @@
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started! No landing pages registered. Create one to get started!
</td> </td>
</tr> </tr>
@@ -126,6 +127,8 @@
📊 Analytics 📊 Analytics
</a> </a>
</td> </td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);"> <td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }} {{ page.created_at[0..10] }}
</td> </td>
+104 -10
View File
@@ -4,17 +4,111 @@
{% block active_urls %}active{% endblock %} {% block active_urls %}active{% endblock %}
{% block sidebar_links %}
{% if is_admin %}
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li class="active">
<a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li>
<a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/admin/users">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>
Users Management
</a>
</li>
<li>
<a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/admin/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/admin/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% else %}
<li>
<a href="/user/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li class="active">
<a href="/user/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li>
<a href="/user/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li>
<a href="/user/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li>
<a href="/user/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li>
<a href="/user/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
{% endif %}
{% endblock %}
{% block sidebar_footer %}
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="{% if is_admin %}/admin/logout{% else %}/logout{% endif %}" class="logout-btn">Log Out</a>
</div>
{% endblock %}
{% block header_title %}URL Analytics: /{{ url.code }}{% endblock %} {% block header_title %}URL Analytics: /{{ url.code }}{% endblock %}
{% block header_actions %} {% block header_actions %}
<div style="display: flex; gap: 0.5rem;"> <div style="display: flex; gap: 0.5rem;">
<a href="/admin/analytics/url/{{ url.id }}/export/csv?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/csv{% else %}/user/analytics/url/{{ url.id }}/export/csv{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export CSV 📥 Export CSV
</a> </a>
<a href="/admin/analytics/url/{{ url.id }}/export/json?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}/export/json{% else %}/user/analytics/url/{{ url.id }}/export/json{% endif %}?date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📥 Export JSON 📥 Export JSON
</a> </a>
<a href="/admin/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;"> <a href="{% if is_admin %}/admin/urls{% else %}/user/urls{% endif %}" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to URLs Back to URLs
</a> </a>
@@ -24,7 +118,7 @@
{% block content %} {% block content %}
<!-- Date Filter Form --> <!-- Date Filter Form -->
<div class="card" style="margin-bottom: 2rem;"> <div class="card" style="margin-bottom: 2rem;">
<form method="GET" action="/admin/analytics/url/{{ url.id }}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;"> <form method="GET" action="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" style="display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-end;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 150px;"> <div class="form-group" style="margin: 0; flex: 1; min-width: 150px;">
<label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label> <label for="date_from" style="margin-bottom: 0.25rem; font-size: 0.85rem;">Date From</label>
<input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;"> <input type="date" id="date_from" name="date_from" class="form-input" value="{{ date_from.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
@@ -34,7 +128,7 @@
<input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;"> <input type="date" id="date_to" name="date_to" class="form-input" value="{{ date_to.as_deref().unwrap_or("") }}" style="padding: 0.4rem 0.6rem;">
</div> </div>
<button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button> <button type="submit" class="btn" style="padding: 0.45rem 1.25rem; font-size: 0.9rem;">Apply Filters</button>
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}" class="btn btn-secondary" style="padding: 0.45rem 1.25rem; font-size: 0.9rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">Clear</a>
</form> </form>
</div> </div>
@@ -224,8 +318,8 @@
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;"> <div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem;">
{% if current_page > 1 %} {% if current_page > 1 %}
<a href="/admin/analytics/url/{{ url.id }}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page=1&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page - 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
{% else %} {% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</span>
@@ -235,13 +329,13 @@
{% if self.is_current(p) %} {% if self.is_current(p) %}
<span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span> <span class="btn btn-primary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem; font-weight: bold;">[{{ p }}]</span>
{% else %} {% else %}
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ p }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">{{ p }}</a>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
{% if current_page < total_pages %} {% if current_page < total_pages %}
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ current_page + 1 }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</a>
<a href="/admin/analytics/url/{{ url.id }}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a> <a href="{% if is_admin %}/admin/analytics/url/{{ url.id }}{% else %}/user/analytics/url/{{ url.id }}{% endif %}?analytics_page={{ total_pages }}&date_from={{ date_from.as_deref().unwrap_or("") }}&date_to={{ date_to.as_deref().unwrap_or("") }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</a>
{% else %} {% else %}
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Next &gt;</span>
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span> <span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem; font-size: 0.85rem;">Last &gt;&gt;</span>
+2 -9
View File
@@ -191,15 +191,8 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
<td style="text-align: center; vertical-align: middle;"> {% let code = url.code.as_str() %}
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code"> {% include "components/qr_preview.html" %}
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="margin-top: 0.25rem; display: flex; gap: 0.25rem; justify-content: center;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</td>
<td> <td>
<span class="badge badge-{{ url.status }}"> <span class="badge badge-{{ url.status }}">
{{ url.status }} {{ url.status }}
-87
View File
@@ -1,87 +0,0 @@
{% extends "user_layout.html" %}
{% block title %}Landing Page Analytics - /p/{{ page_code }} - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}Landing Page Analytics: /p/{{ page_code }}{% endblock %}
{% block header_actions %}
<a href="/user/pages" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to Landing Pages
</a>
{% endblock %}
{% block content %}
<!-- Page Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Page Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Page Title</span>
<span style="font-weight: 600; font-size: 1.1rem; color: var(--text-primary);">{{ title }}</span>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this landing page.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+4 -1
View File
@@ -72,6 +72,7 @@
<th>SEO Preview Path</th> <th>SEO Preview Path</th>
<th>Status</th> <th>Status</th>
<th>Analytics</th> <th>Analytics</th>
<th>QR Code</th>
<th>Created</th> <th>Created</th>
<th>Action</th> <th>Action</th>
</tr> </tr>
@@ -79,7 +80,7 @@
<tbody> <tbody>
{% if pages.is_empty() %} {% if pages.is_empty() %}
<tr> <tr>
<td colspan="7" style="text-align: center; color: var(--text-secondary); padding: 3rem;"> <td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages created yet. No landing pages created yet.
</td> </td>
</tr> </tr>
@@ -114,6 +115,8 @@
📊 Analytics 📊 Analytics
</a> </a>
</td> </td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);"> <td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }} {{ page.created_at[0..10] }}
</td> </td>
-89
View File
@@ -1,89 +0,0 @@
{% extends "user_layout.html" %}
{% block title %}Short URL Analytics - /{{ url_code }} - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}URL Analytics: /{{ url_code }}{% endblock %}
{% block header_actions %}
<a href="/user/urls" class="btn btn-secondary" style="padding: 0.5rem 1rem; font-size: 0.9rem; display: inline-flex; align-items: center; gap: 0.25rem;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
Back to URLs
</a>
{% endblock %}
{% block content %}
<!-- Link Details Card -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Link Details
</h3>
<div>
<span style="font-size: 0.8rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.5px; display: block; margin-bottom: 0.25rem;">Destination URL</span>
<a href="{{ destination }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-weight: 600; word-break: break-all;">
{{ destination }}
</a>
</div>
</div>
<!-- Visitor Activity Log -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem; margin: 0;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
Visitor Activity Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Sr</th>
<th>Timestamp</th>
<th>IP Address</th>
<th>Country</th>
<th>Referrer</th>
<th>Browser</th>
<th>User-Agent</th>
<th>UTM Source</th>
<th>UTM Campaign</th>
</tr>
</thead>
<tbody>
{% if visits.is_empty() %}
<tr>
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No visitor activity available for this short link.
</td>
</tr>
{% else %}
{% for entry in visits %}
<tr>
<td>{{ entry.sr }}</td>
<td style="font-family: monospace; font-size: 0.85rem; white-space: nowrap;">{{ entry.timestamp }}</td>
<td style="font-family: monospace; font-size: 0.85rem;">{{ entry.ip_address }}</td>
<td>
{% if entry.country == "Unknown" %}
<span style="color: var(--text-muted);">Unknown</span>
{% else %}
{{ entry.country }}
{% endif %}
</td>
<td>{{ entry.referrer }}</td>
<td>{{ entry.browser }}</td>
<td style="max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-secondary);" title="{{ entry.user_agent }}">
{{ entry.user_agent }}
</td>
<td>{{ entry.utm_source }}</td>
<td>{{ entry.utm_campaign }}</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+2 -11
View File
@@ -85,17 +85,8 @@
<tr> <tr>
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td> <td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td> <td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
<td> {% let code = url.code.as_str() %}
<div style="display: flex; flex-direction: column; align-items: center; gap: 0.25rem;"> {% include "components/qr_preview.html" %}
<a href="/api/qr/{{ url.code }}.png" target="_blank" title="View QR Code">
<img src="/api/qr/{{ url.code }}.svg" alt="QR" style="width: 32px; height: 32px; border-radius: 4px; border: 1px solid var(--border-color); background: white; padding: 1px;">
</a>
<div style="display: flex; gap: 0.25rem;">
<a href="/api/qr/{{ url.code }}.png" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">PNG</a>
<a href="/api/qr/{{ url.code }}.svg" download class="badge" style="font-size: 0.65rem; background-color: rgba(99, 102, 241, 0.1); color: #818cf8; text-decoration: none; padding: 0.1rem 0.25rem;">SVG</a>
</div>
</div>
</td>
<td>{{ url.status }}</td> <td>{{ url.status }}</td>
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td> <td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
<td> <td>
+333
View File
@@ -0,0 +1,333 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
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.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, url, handle, db)
}
#[tokio::test]
async fn test_analytics_and_table_parity() {
let temp_dir = std::env::temp_dir().join(format!("bzod_parity_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
// 1. Log in as admin FIRST (Bootstrap phase: zero users exist)
let admin_client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
let admin_login_url = format!("{}/admin/login", base_url);
let res = admin_client.get(&admin_login_url).send().await.unwrap();
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).unwrap();
let mut admin_login_params = HashMap::new();
admin_login_params.insert("username", "admin");
admin_login_params.insert("password", "bootstrap-secret");
admin_login_params.insert("csrf_token", &csrf_token);
let res = admin_client
.post(&admin_login_url)
.form(&admin_login_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// Admin adds a URL to the global content_db
let _admin_url_id = {
let conn_admin = db.content.lock().unwrap();
let url = bzod::db::content::create_url_extended(
&conn_admin,
"!admin-slug",
"https://admin.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!admin-slug",
1,
"url",
&url.id,
"active",
)
.unwrap();
url.id
};
// Admin adds a Landing Page to the global content_db
let _admin_page_id = {
let conn_admin = db.content.lock().unwrap();
let page = bzod::db::content::create_landing_page(
&conn_admin,
"!admin-page",
"admin-page",
"Title Admin",
"<html></html>",
"published",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!admin-page",
1,
"page",
&page.id,
"active",
)
.unwrap();
page.id
};
// 2. Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
create_temp_config(temp_dir.clone()),
)
.await
.unwrap();
let id_a = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// User A adds a URL
let urla_id = {
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"!usera-slug",
"https://usera.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!usera-slug",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
url.id
};
// User A adds a Landing Page
let pagea_id = {
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"!usera-page",
"usera-page",
"Title A",
"<html></html>",
"published",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!usera-page",
id_a,
"page",
&page.id,
"active",
)
.unwrap();
page.id
};
// --- Log in as User A ---
let login_url = format!("{}/login", base_url);
let res = client.get(&login_url).send().await.unwrap();
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).unwrap();
let mut login_params = HashMap::new();
login_params.insert("username", "usera");
login_params.insert("password", "password123");
login_params.insert("csrf_token", &csrf_token);
let res = client
.post(&login_url)
.form(&login_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER);
// 1. Get user URLs dashboard and check for QR Code preview
let res = client
.get(format!("{}/user/urls", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let user_urls_html = res.text().await.unwrap();
assert!(user_urls_html.contains("QR Code"));
assert!(user_urls_html.contains("/api/qr/!usera-slug.svg"));
// 2. Get user Pages dashboard and check for QR Code preview
let res = client
.get(format!("{}/user/pages", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let user_pages_html = res.text().await.unwrap();
assert!(user_pages_html.contains("QR Code"));
assert!(user_pages_html.contains("/api/qr/!usera-page.svg"));
// 3. Get user URL analytics and verify dashboard features exist
let res = client
.get(format!("{}/user/analytics/url/{}", base_url, urla_id))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let user_url_analytics = res.text().await.unwrap();
assert!(user_url_analytics.contains("Export CSV"));
assert!(user_url_analytics.contains("Export JSON"));
assert!(user_url_analytics.contains("date_from"));
assert!(user_url_analytics.contains("Daily Click Traffic"));
assert!(user_url_analytics.contains("Referrer Channels"));
assert!(user_url_analytics.contains("Browser breakdown"));
// 4. Get user Page analytics and verify dashboard features exist
let res = client
.get(format!("{}/user/analytics/page/{}", base_url, pagea_id))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let user_page_analytics = res.text().await.unwrap();
assert!(user_page_analytics.contains("Export CSV"));
assert!(user_page_analytics.contains("Export JSON"));
assert!(user_page_analytics.contains("date_from"));
assert!(user_page_analytics.contains("Daily Page Views"));
assert!(user_page_analytics.contains("Referrer Channels"));
// 5. Get admin URLs dashboard and check for QR Code preview
let res = admin_client
.get(format!("{}/admin/urls", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let admin_urls_html = res.text().await.unwrap();
assert!(admin_urls_html.contains("QR Code"));
assert!(admin_urls_html.contains("/api/qr/!admin-slug.svg"));
// 6. Get admin Pages dashboard and check for QR Code preview
let res = admin_client
.get(format!("{}/admin/pages", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let admin_pages_html = res.text().await.unwrap();
assert!(admin_pages_html.contains("QR Code"));
assert!(admin_pages_html.contains("/api/qr/!admin-page.svg"));
let _ = fs::remove_dir_all(&temp_dir);
}
+31 -10
View File
@@ -135,8 +135,15 @@ async fn test_backup_restore_roundtrip() {
// Register global slug // Register global slug
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!rt-slug", user_id, "url", "rt-id") bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!rt-slug",
user_id,
"url",
"rt-id",
"active",
)
.unwrap();
} }
// Backup user // Backup user
@@ -234,8 +241,15 @@ async fn test_restore_slug_collision_rejection() {
.unwrap(); .unwrap();
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_a, "url", "a-id") bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!collision-slug",
id_a,
"url",
"a-id",
"active",
)
.unwrap();
} }
// Backup User A // Backup User A
@@ -289,18 +303,25 @@ async fn test_restore_slug_collision_rejection() {
.unwrap(); .unwrap();
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!collision-slug", id_b, "url", "b-id") bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!collision-slug",
id_b,
"url",
"b-id",
"active",
)
.unwrap();
} }
// Attempt to restore User A from backup // Attempt to restore User A from backup - must fail on collision
bzod::cli::restore_user::run( let res = bzod::cli::restore_user::run(
backup_file.to_string_lossy().to_string(), backup_file.to_string_lossy().to_string(),
None, None,
config.clone(), config.clone(),
) )
.await .await;
.unwrap(); assert!(res.is_err());
// Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected // Verify that User B still owns the slug in global_slugs and User A's slug registration was skipped/rejected
{ {
+2 -2
View File
@@ -177,7 +177,7 @@ async fn test_scenario_a_user_create_login_shorten_visit_analytics() {
let redir_url = format!("{}/!mygoogle", base_url); let redir_url = format!("{}/!mygoogle", base_url);
let res = client.get(&redir_url).send().await.unwrap(); let res = client.get(&redir_url).send().await.unwrap();
// It should redirect to google.com // It should redirect to google.com
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!( assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(), res.headers().get("location").unwrap().to_str().unwrap(),
"https://google.com" "https://google.com"
@@ -573,7 +573,7 @@ async fn test_scenario_c_slug_transfer_workflow() {
.send() .send()
.await .await
.unwrap(); .unwrap();
assert_eq!(res.status(), reqwest::StatusCode::TEMPORARY_REDIRECT); assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!( assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(), res.headers().get("location").unwrap().to_str().unwrap(),
"https://yahoo.com" "https://yahoo.com"
+2 -2
View File
@@ -30,7 +30,7 @@ async fn test_concurrent_slug_creation() {
let task1 = tokio::spawn(async move { let task1 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path1).unwrap(); let conn = rusqlite::Connection::open(&path1).unwrap();
b1.wait().await; b1.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10") bzod::db::users::register_global_slug(&conn, "!conc-slug", 10, "url", "url10", "active")
}); });
let b2 = barrier.clone(); let b2 = barrier.clone();
@@ -38,7 +38,7 @@ async fn test_concurrent_slug_creation() {
let task2 = tokio::spawn(async move { let task2 = tokio::spawn(async move {
let conn = rusqlite::Connection::open(&path2).unwrap(); let conn = rusqlite::Connection::open(&path2).unwrap();
b2.wait().await; b2.wait().await;
bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20") bzod::db::users::register_global_slug(&conn, "!conc-slug", 20, "url", "url20", "active")
}); });
let res1 = task1.await.unwrap(); let res1 = task1.await.unwrap();
+9 -2
View File
@@ -58,8 +58,15 @@ async fn test_global_slug_index_consistency() {
// Register globally // Register globally
{ {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!integ-slug", user_id, "url", &url_id) bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!integ-slug",
user_id,
"url",
&url_id,
"active",
)
.unwrap();
} }
// Consistency Check: // Consistency Check:
+2 -1
View File
@@ -25,7 +25,8 @@ async fn test_content_flagging_and_disabling() {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
// Register slug // Register slug
bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc").unwrap(); bzod::db::users::register_global_slug(&system_conn, "!badslug", 10, "url", "url_abc", "active")
.unwrap();
// Verify it is active initially // Verify it is active initially
let status: String = system_conn let status: String = system_conn
+226
View File
@@ -0,0 +1,226 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
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.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
fn extract_csrf_token(html: &str) -> Option<String> {
let marker = "name=\"csrf_token\" value=\"";
if let Some(pos) = html.find(marker) {
let start = pos + marker.len();
if let Some(end) = html[start..].find('"') {
return Some(html[start..start + end].to_string());
}
}
None
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, url, handle, db)
}
#[tokio::test]
async fn test_ownership_isolation_endpoints() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_ownership_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
// Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
create_temp_config(temp_dir.clone()),
)
.await
.unwrap();
// Create User B
let _ = bzod::cli::create_user::run(
Some("userb".to_string()),
Some("password123".to_string()),
None,
create_temp_config(temp_dir.clone()),
)
.await
.unwrap();
let (id_a, _id_b) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "userb")
.unwrap()
.unwrap()
.id;
(a, b)
};
// User A adds a URL
let urla_id = {
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"!usera-slug",
"https://usera.com",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!usera-slug",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
url.id
};
// User A adds a Landing Page
let pagea_id = {
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"!usera-page",
"usera-page",
"Title A",
"<html></html>",
"published",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!usera-page",
id_a,
"page",
&page.id,
"active",
)
.unwrap();
page.id
};
// 1. Log in as User B
let login_url = format!("{}/login", base_url);
let res = client.get(&login_url).send().await.unwrap();
let html = res.text().await.unwrap();
let csrf_token = extract_csrf_token(&html).unwrap();
let mut login_params = HashMap::new();
login_params.insert("username", "userb");
login_params.insert("password", "password123");
login_params.insert("csrf_token", &csrf_token);
let res = client
.post(&login_url)
.form(&login_params)
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::SEE_OTHER); // Redirects after login
// 2. User B tries to view User A's URL analytics -> 403 Forbidden
let url_analytics_url = format!("{}/user/analytics/url/{}", base_url, urla_id);
let res = client.get(&url_analytics_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 3. User B tries to view User A's Page analytics -> 403 Forbidden
let page_analytics_url = format!("{}/user/analytics/page/{}", base_url, pagea_id);
let res = client.get(&page_analytics_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 4. User B tries to export CSV of User A's URL analytics -> 403 Forbidden
let csv_export_url = format!("{}/user/analytics/url/{}/export/csv", base_url, urla_id);
let res = client.get(&csv_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 5. User B tries to export JSON of User A's URL analytics -> 403 Forbidden
let json_export_url = format!("{}/user/analytics/url/{}/export/json", base_url, urla_id);
let res = client.get(&json_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 6. User B tries to export CSV of User A's Page analytics -> 403 Forbidden
let csv_page_export_url = format!("{}/user/analytics/page/{}/export/csv", base_url, pagea_id);
let res = client.get(&csv_page_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
// 7. User B tries to export JSON of User A's Page analytics -> 403 Forbidden
let json_page_export_url = format!("{}/user/analytics/page/{}/export/json", base_url, pagea_id);
let res = client.get(&json_page_export_url).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::FORBIDDEN);
let _ = fs::remove_dir_all(&temp_dir);
}
+300
View File
@@ -0,0 +1,300 @@
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::time::Instant;
use tokio::net::TcpListener;
use bzod::analytics::AnalyticsQueue;
use bzod::config::Config;
use bzod::db::Db;
use bzod::state::AppState;
use bzod::web::create_router;
fn compute_sha256(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
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.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false;
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config
}
async fn start_test_server(
temp_dir: PathBuf,
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100);
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(HashMap::new())),
db: db.clone(),
config,
analytics_queue: queue,
start_time: Instant::now(),
};
let router = create_router(state);
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let url = format!("http://{}", addr);
let handle = tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
let client = reqwest::Client::builder()
.cookie_store(true)
.redirect(reqwest::redirect::Policy::none())
.build()
.unwrap();
(client, url, handle, db)
}
#[tokio::test]
async fn test_qr_endpoints_and_redirection_hardening() {
let temp_dir = std::env::temp_dir().join(format!("bzod_qr_test_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base_url, _server_handle, db) = start_test_server(temp_dir.clone()).await;
// Create User A
let _ = bzod::cli::create_user::run(
Some("usera".to_string()),
Some("password123".to_string()),
None,
create_temp_config(temp_dir.clone()),
)
.await
.unwrap();
let id_a = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "usera")
.unwrap()
.unwrap()
.id
};
// User A adds an active URL
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"a1b2c3",
"https://active.com",
Some("active-url-title"),
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"a1b2c3",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
}
// User A adds a disabled URL
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let url = bzod::db::content::create_url_extended(
&conn_a,
"d4e5f6",
"https://disabled.com",
Some("disabled-url-title"),
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"d4e5f6",
id_a,
"url",
&url.id,
"disabled",
)
.unwrap();
}
// User A adds an active Page
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"a1b2",
"active-page",
"Active Page",
"<html></html>",
"published",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"a1b2",
id_a,
"page",
&page.id,
"active",
)
.unwrap();
}
// User A adds a disabled Page
{
let conn_a = bzod::jobs::open_user_content_conn(&db, id_a).unwrap();
let page = bzod::db::content::create_landing_page(
&conn_a,
"c3d4",
"disabled-page",
"Disabled Page",
"<html></html>",
"draft",
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"c3d4",
id_a,
"page",
&page.id,
"disabled",
)
.unwrap();
}
// 1. Verify Active URL QR endpoints return 200 with correct content types
let res = client
.get(format!("{}/api/qr/a1b2c3.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
assert_eq!(
res.headers().get("content-type").unwrap().to_str().unwrap(),
"image/png"
);
let res = client
.get(format!("{}/api/qr/a1b2c3.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
assert_eq!(
res.headers().get("content-type").unwrap().to_str().unwrap(),
"image/svg+xml"
);
// 2. Verify Disabled URL redirect returns 410 Gone
let res = client
.get(format!("{}/a1b2c3", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY); // Redirects to destination
let res = client
.get(format!("{}/d4e5f6", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 3. Verify Disabled URL QR endpoints return 410 Gone
let res = client
.get(format!("{}/api/qr/d4e5f6.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let res = client
.get(format!("{}/api/qr/d4e5f6.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 4. Verify Active Page QR endpoints return 200
let res = client
.get(format!("{}/api/qr/a1b2.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
let res = client
.get(format!("{}/api/qr/a1b2.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::OK);
// 5. Verify Disabled Page redirection returns 410 Gone
let res = client
.get(format!("{}/p/c3d4", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 6. Verify Disabled Page QR endpoints return 410 Gone
let res = client
.get(format!("{}/api/qr/c3d4.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let res = client
.get(format!("{}/api/qr/c3d4.svg", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// 7. Verify Missing URL/Page QR endpoints return 404 Not Found
let res = client
.get(format!("{}/api/qr/missing-slug.png", base_url))
.send()
.await
.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::NOT_FOUND);
let _ = fs::remove_dir_all(&temp_dir);
}
+2
View File
@@ -80,6 +80,7 @@ async fn test_global_slug_lookup_and_redirection() {
user_id, user_id,
"url", "url",
"xyz", "xyz",
"active",
) )
.unwrap(); .unwrap();
} }
@@ -173,6 +174,7 @@ async fn test_disabled_slug_returns_410() {
user_id, user_id,
"url", "url",
"xyz", "xyz",
"active",
) )
.unwrap(); .unwrap();
+3 -1
View File
@@ -23,7 +23,8 @@ async fn test_global_slug_uniqueness() {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
// Register a slug // Register a slug
bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1").unwrap(); bzod::db::users::register_global_slug(&system_conn, "!myslug", 1, "url", "url1", "active")
.unwrap();
// Verify it is not available // Verify it is not available
let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap(); let avail = bzod::db::users::is_slug_available(&system_conn, "!myslug").unwrap();
@@ -113,6 +114,7 @@ async fn test_slug_release_on_user_deletion() {
user_id, user_id,
"url", "url",
"url_xyz", "url_xyz",
"active",
) )
.unwrap(); .unwrap();
let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap(); let avail = bzod::db::users::is_slug_available(&system_conn, "!user-slug").unwrap();
+242
View File
@@ -0,0 +1,242 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
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_admin_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_1_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"url",
"url2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_admin_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"page",
"page2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_3_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
2,
"url",
"url2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_user_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_4_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 2, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
2,
"page",
"page2",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_url_page_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_5_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "url", "url1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"page",
"page1",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_page_url_slug_collision_rejected() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_6_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "collision", 1, "page", "page1", "active")
.unwrap();
let res = bzod::db::users::register_global_slug(
&system_conn,
"collision",
1,
"url",
"url1",
"active",
);
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_slug_reusable_after_delete() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_7_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
bzod::db::users::release_global_slug(&system_conn, "slug", 1).unwrap();
assert!(bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "page", "page1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_disabled_slug_still_blocks_registration() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_8_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "disabled")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
let res =
bzod::db::users::register_global_slug(&system_conn, "slug", 2, "page", "page1", "active");
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_transferred_slug_remains_unique() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_9_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
assert!(!bzod::db::users::is_slug_available(&system_conn, "slug").unwrap());
// Transfer slug (by setting owner_user_id to 2)
system_conn
.execute(
"UPDATE global_slugs SET owner_user_id = 2 WHERE slug = 'slug'",
[],
)
.unwrap();
let res =
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url2", "active");
assert!(res.is_err());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_transfer_preserves_global_slug_record() {
let temp_dir = std::env::temp_dir().join(format!("bzod_test_col_10_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "slug", 1, "url", "url1", "active")
.unwrap();
// Verify it exists in global_slugs
let owner_id: i64 = system_conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = 'slug'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(owner_id, 1);
let _ = fs::remove_dir_all(&temp_dir);
}
+9 -2
View File
@@ -72,8 +72,15 @@ async fn test_slug_transfer() {
// Register globally // Register globally
{ {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(&system_conn, "!trans-slug", id_a, "url", &url.id) bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!trans-slug",
id_a,
"url",
&url.id,
"active",
)
.unwrap();
let users_conn = db.users.lock().unwrap(); let users_conn = db.users.lock().unwrap();
bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap(); bzod::db::users::increment_quota_counter(&users_conn, id_a, "urls").unwrap();
+19 -5
View File
@@ -22,8 +22,15 @@ async fn test_soft_delete_reserves_slug() {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
// Register slug // Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-delete", 10, "url", "url_123") bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!slug-to-delete",
10,
"url",
"url_123",
"active",
)
.unwrap();
// Soft delete slug // Soft delete slug
bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap(); bzod::db::users::soft_delete_global_slug(&system_conn, "!slug-to-delete", 10).unwrap();
@@ -39,7 +46,7 @@ async fn test_soft_delete_reserves_slug() {
|row| row.get(0), |row| row.get(0),
) )
.unwrap(); .unwrap();
assert_eq!(status, "soft_deleted"); assert_eq!(status, "disabled");
let _ = fs::remove_dir_all(&temp_dir); let _ = fs::remove_dir_all(&temp_dir);
} }
@@ -55,8 +62,15 @@ async fn test_permanent_delete_releases_slug() {
let system_conn = db.system.lock().unwrap(); let system_conn = db.system.lock().unwrap();
// Register slug // Register slug
bzod::db::users::register_global_slug(&system_conn, "!slug-to-purge", 10, "url", "url_456") bzod::db::users::register_global_slug(
.unwrap(); &system_conn,
"!slug-to-purge",
10,
"url",
"url_456",
"active",
)
.unwrap();
// Release global slug (permanent deletion) // Release global slug (permanent deletion)
bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap(); bzod::db::users::release_global_slug(&system_conn, "!slug-to-purge", 10).unwrap();
+90
View File
@@ -0,0 +1,90 @@
use bzod::config::Config;
use bzod::db::Db;
use std::fs;
use std::path::PathBuf;
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_cleanup_stale_reservations() {
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_1_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a reserving slug older than 15 minutes (e.g. 20 minutes ago)
let old_time = (chrono::Utc::now() - chrono::Duration::minutes(20)).to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES ('stale-slug', 2, 'url', '', ?1, ?1, 'reserving')",
[&old_time]
).unwrap();
// Verify it exists before cleanup
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists);
// Run cleanup
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
assert_eq!(cleaned, 1);
// Verify it is gone
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'stale-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(!exists);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn test_cleanup_preserves_valid_reservations() {
let temp_dir = std::env::temp_dir().join(format!("bzod_tx_test_2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db");
let system_conn = db.system.lock().unwrap();
// Insert a reserving slug that is brand new (e.g. 1 minute ago)
let new_time = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
system_conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES ('fresh-slug', 2, 'url', '', ?1, ?1, 'reserving')",
[&new_time]
).unwrap();
// Run cleanup
let cleaned = bzod::db::users::cleanup_stale_reservations(&system_conn, &temp_dir).unwrap();
assert_eq!(cleaned, 0);
// Verify it is still there
let exists: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = 'fresh-slug')",
[],
|r| r.get(0),
)
.unwrap();
assert!(exists);
let _ = fs::remove_dir_all(&temp_dir);
}