Release v0.6.0

This commit is contained in:
thakares committed 2026-08-09 17:17:57 +05:30
1 parent 7069ca9db7
commit 38fc7123cf
75 files changed
+11199 -7508

No files matched your search

+1 -1
View File
@@ -1,4 +1,4 @@
/target /target/
data/ data/
*.db *.db
*.db-wal *.db-wal
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]] [[package]]
name = "bzod" name = "bzod"
version = "0.5.3" version = "0.6.0"
dependencies = [ dependencies = [
"argon2", "argon2",
"askama", "askama",
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "bzod" name = "bzod"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform" description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.5.3" version = "0.6.0"
edition = "2021" edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener" repository = "https://github.com/thakares/nx9-url-shortener"
+31 -2
View File
@@ -6,7 +6,7 @@
![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue) ![SQLite](https://img.shields.io/badge/SQLite-Embedded-blue)
![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey) ![Platform](https://img.shields.io/badge/Platform-Linux-lightgrey)
![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green) ![License](https://img.shields.io/badge/License-MIT%20OR%20Apache--2.0-green)
![Version](https://img.shields.io/badge/Version-v0.5.3-purple) ![Version](https://img.shields.io/badge/Version-v0.6.0-purple)
[![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod) [![GitHub](https://img.shields.io/badge/GitHub-thakares%2Fbzod-181717?logo=github)](https://github.com/thakares/bzod)
[![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod) [![Codeberg](https://img.shields.io/badge/Codeberg-thakares%2Fbzod-2185D0?logo=codeberg)](https://codeberg.org/thakares/bzod)
@@ -90,7 +90,7 @@ No recurring subscription fees.
--- ---
## Runtime Efficiency (v0.5.3) ## Runtime Efficiency (v0.6.0)
| Metric | Value | | Metric | Value |
|---------|------:| |---------|------:|
@@ -945,6 +945,33 @@ src/
├── templates/ ├── templates/
├── utils/ ├── utils/
├── web/ ├── web/
│ ├── admin/
│ │ ├── analytics.rs
│ │ ├── api_keys.rs
│ │ ├── audit.rs
│ │ ├── auth.rs
│ │ ├── backups.rs
│ │ ├── dashboard.rs
│ │ ├── health.rs
│ │ ├── moderation.rs
│ │ ├── mod.rs
│ │ ├── pages.rs
│ │ ├── quotas.rs
│ │ ├── sessions.rs
│ │ ├── settings.rs
│ │ ├── urls.rs
│ │ └── users.rs
│ ├── api.rs
│ ├── bulk.rs
│ ├── middleware.rs
│ ├── mod.rs
│ ├── multi_user.rs
│ ├── pages.rs
│ ├── password_gate.rs
│ ├── qr.rs
│ ├── redirect.rs
│ ├── routes.rs
│ └── system.rs
templates/ templates/
@@ -1108,6 +1135,8 @@ Highlights include:
- Upgrade Validation - Upgrade Validation
- Backup Manifest - Backup Manifest
- Transaction-safe Maintenance - Transaction-safe Maintenance
- Modular Admin Architecture (v0.6.0)
- Redirect Handler Hardening (v0.6.0)
--- ---
+32 -7
View File
@@ -4,6 +4,8 @@
set -euo pipefail set -euo pipefail
BZOD_VERSION="0.6.0"
SERVICE_USER="bzod" SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod" INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod" CONFIG_DIR="/etc/bzod"
@@ -48,7 +50,7 @@ case $ARCH in
esac esac
REPO="thakares/nx9-url-shortener" REPO="thakares/nx9-url-shortener"
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}" RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
TMP_BINARY=$(mktemp) TMP_BINARY=$(mktemp)
@@ -93,13 +95,24 @@ if [ ! -x "${INSTALL_PATH}" ]; then
exit 1 exit 1
fi fi
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || { "${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
echo -e "${RED}Binary verification failed${NC}" echo -e "${RED}Binary verification failed${NC}"
exit 1 exit 1
} }
# Show installed version # Verify -V also works
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
echo -e "${RED}Binary -V verification failed${NC}"
exit 1
}
# Show installed version and verify it matches requested version
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown") VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
exit 1
fi
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}" echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
# 3. Create System User # 3. Create System User
@@ -175,11 +188,23 @@ systemctl daemon-reload
# 7. Initialize & Start # 7. Initialize & Start
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}" echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then # Database creation and migration is handled automatically by 'bzod serve'
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}" if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
echo -e "${GREEN}✓ Databases initialized${NC}"
else
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}" echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
# Pre-upgrade: stop service and backup databases
if systemctl is-active --quiet bzod 2>/dev/null; then
echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
systemctl stop bzod
fi
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}"
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
else
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
fi fi
systemctl enable --now bzod systemctl enable --now bzod
+1 -1
View File
@@ -18,7 +18,7 @@ services:
- PORT=8654 - PORT=8654
- RUST_LOG=info - RUST_LOG=info
hostname: bzod hostname: bzod
image: nx9-url-shortener:v0.5.3 image: nx9-url-shortener:v0.6.0
ports: ports:
- mode: ingress - mode: ingress
target: 8654 target: 8654
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide # BZOD Administrator Guide
Version: v0.5.3 Version: v0.6.0
--- ---
+22 -5
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide # BZOD Architecture Guide
Version: v0.5.3 Version: v0.6.0
--- ---
@@ -89,7 +89,22 @@ Responsible for:
Major modules: Major modules:
```text ```text
admin.rs admin/ (modular feature directory)
auth.rs (authentication and session handling)
dashboard.rs (dashboard rendering)
urls.rs (URL management handlers)
pages.rs (landing page management handlers)
analytics.rs (analytics and export handlers)
settings.rs (settings and configuration handlers)
users.rs (user management handlers)
sessions.rs (session administration)
quotas.rs (quota management)
health.rs (health diagnostics)
backups.rs (backup and restore handlers)
api_keys.rs (API key management)
audit.rs (audit log handlers)
moderation.rs (content moderation handlers)
mod.rs (module exports and shared helpers)
api.rs api.rs
pages.rs pages.rs
redirect.rs redirect.rs
@@ -339,9 +354,11 @@ Locate owner database
↓ ↓
Resolve URL Resolve URL
↓ ↓
Validate destination
↓
Record analytics Record analytics
↓ ↓
302 Redirect 301 Redirect (with safe Location header construction)
``` ```
--- ---
@@ -590,7 +607,7 @@ Coverage includes:
* Upgrade validation * Upgrade validation
* Multi-user isolation * Multi-user isolation
v0.5.0 includes more than 90 automated tests. The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
--- ---
@@ -635,7 +652,7 @@ Planned for future releases:
# Summary # Summary
BZOD v0.5.0 is built around a simple principle: BZOD is built around a simple principle:
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy. > Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide # Backup & Restore Guide
Version: v0.5.3 Version: v0.6.0
Applies To: BZOD Multi-User Platform Applies To: BZOD Multi-User Platform
--- ---
+71
View File
@@ -6,6 +6,77 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
--- ---
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
# v0.5.3 — Architecture Refinement & Redirect Hardening
---
## Changed
### Architecture
* Eliminated the monolithic `admin.rs` handler file
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
* Extracted shared authentication and authorization helpers
* Extracted common export and helper functionality
### Redirect Handling
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
* Added destination URL validation (scheme validation, control character rejection)
* Added safe HTTP Location header construction
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
* Removed synchronous expiration writes from the redirect hot path
---
## Improved
* Database lock scoping across admin handlers
* Error handling consistency and observability
* Handler decomposition for oversized functions
* Reduced duplicated handler logic across admin operations
---
## Verified
* Root landing page (GET /) confirmed as intentional route serving www/index.html
* Release binary built successfully
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
* SQLite WAL mode and foreign-key enforcement initialized successfully
* All existing migrations reported as up to date
* Comprehensive automated test suite passed, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
---
## Notes
* This release is an internal architecture and quality improvement
* No new user-facing features were introduced
* Existing API and route behavior was preserved
* Existing redirect security and tenant isolation behavior was preserved
---
# v0.5.1 - General Availability (GA) # v0.5.1 - General Availability (GA)
Release Date: 2026-06-20 Release Date: 2026-06-20
+1 -1
View File
@@ -2,7 +2,7 @@
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management. BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
The current command list for BZOD v0.5.3 is: The current command list for BZOD v0.6.0 is:
```text ```text
$ bzod --help $ bzod --help
+1 -1
View File
@@ -1,4 +1,4 @@
# BZOD v0.5.1 vs Self-Hosted URL Management Platforms # BZOD v0.6.0 vs Self-Hosted URL Management Platforms
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform. BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
+1 -1
View File
@@ -2,7 +2,7 @@
# BZOD Database Architecture # BZOD Database Architecture
BZOD v0.5.1 uses SQLite exclusively. BZOD v0.6.0 uses SQLite exclusively.
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability. Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide # BZOD Installation Guide
Version: v0.5.1 Version: v0.6.0
--- ---
@@ -183,13 +183,13 @@ sudo pacman -S \
Example: Example:
```bash ```bash
wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
``` ```
Extract: Extract:
```bash ```bash
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz tar -xzf bzod-v0.6.0-linux-amd64.tar.gz
``` ```
Install: Install:
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide # BZOD Multi-User Architecture Guide
Version: v0.5.1 Version: v0.6.0
--- ---
+129
View File
@@ -1,3 +1,132 @@
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
Release Date: 2026-08-09
## Highlights
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
## Breaking Changes
None.
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
---
# Highlights
## Modular Admin Architecture
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
Feature modules:
* `auth.rs` — authentication and session handling
* `dashboard.rs` — dashboard rendering
* `urls.rs` — URL management handlers
* `pages.rs` — landing page management handlers
* `analytics.rs` — analytics and export handlers
* `settings.rs` — settings and configuration handlers
* `users.rs` — user management handlers
* `sessions.rs` — session administration
* `quotas.rs` — quota management
* `health.rs` — health diagnostics
* `backups.rs` — backup and restore handlers
* `api_keys.rs` — API key management
* `audit.rs` — audit log handlers
* `moderation.rs` — content moderation handlers
Benefits:
* Improved code organization and navigability
* Reduced coupling between feature areas
* Improved database lock scoping
* Reduced duplicated handler logic
* Better error handling consistency and observability
* Simplified future extension
---
## Redirect Handler Hardening
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
Changes:
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
* Added destination URL validation (scheme enforcement, control character rejection)
* Added safe Location header construction that handles malformed values gracefully
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions
* Removed synchronous expiration writes from the redirect hot path
Existing redirect security and tenant isolation behavior was preserved.
---
## Root Landing Page Verification
* Confirmed `GET /` as an intentional application route serving `www/index.html`
* Resolved a runtime path-resolution issue affecting static landing-page resolution
* Verified `GET /` returns HTTP 200
* Verified `GET /login` returns HTTP 200
* Verified `GET /admin/login` returns HTTP 200
---
# Testing & Validation
BZOD v0.5.3 passed:
* Release build (`cargo build --release`)
* Comprehensive automated test suite, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
* Runtime smoke tests against the release binary
* SQLite WAL mode and foreign-key enforcement initialization
* Database migration verification (all migrations up to date)
---
# Compatibility
* No breaking changes
* No API changes
* No route changes
* No database schema changes
* No configuration changes
* Direct upgrade from v0.5.1 with no migration required
---
# Repository
* Clean source tree established
* Build artifacts, temporary reports, and IDE metadata removed
* Existing BZOD Git history preserved
* Refactoring baseline merged with existing history
---
---
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening # BZOD v0.5.1 — Namespace Integrity & Platform Hardening
**Release Date:** 2026-06-20 **Release Date:** 2026-06-20
+27 -4
View File
@@ -1,6 +1,6 @@
# BZOD Security Guide # BZOD Security Guide
Version: v0.5.1 Version: v0.6.0
--- ---
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
* Disaster recovery * Disaster recovery
* Operational simplicity * Operational simplicity
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0. This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.6.0.
--- ---
@@ -432,6 +432,27 @@ for:
--- ---
# Redirect Security
The redirect handler validates destination URLs before constructing HTTP Location headers.
Protections include:
* URL scheme validation (only http and https destinations are permitted)
* Control character rejection
* CRLF injection prevention
* Safe Location header construction (no panics on malformed values)
Invalid redirect destinations return:
```http
500 Internal Server Error
```
with structured server-side logging. Full destination values are not exposed to clients.
---
# Audit Logging # Audit Logging
Security-sensitive actions are logged. Security-sensitive actions are logged.
@@ -599,7 +620,7 @@ If compromise is suspected:
# Security Testing # Security Testing
BZOD v0.5.0 includes tests covering: BZOD v0.6.0 includes tests covering:
* Authentication * Authentication
* Authorization * Authorization
@@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering:
* Upgrade migrations * Upgrade migrations
* Backup integrity * Backup integrity
* Disaster recovery * Disaster recovery
* Redirect destination validation
* HTTP Location header safety
These tests are executed during CI and release validation. These tests are executed during CI and release validation.
@@ -642,7 +665,7 @@ These may be addressed in future releases.
# Summary # Summary
BZOD v0.5.0 provides: BZOD v0.6.0 provides:
* Centralized authentication * Centralized authentication
* Secure session management * Secure session management
+32
View File
@@ -325,6 +325,38 @@ and:
for final landing page render. for final landing page render.
Root landing page:
```text
GET /
```
must serve the static landing page.
Expected:
```http
200 OK
Content-Type: text/html
```
Redirect security:
Redirect destinations are validated against:
* Invalid URL schemes
* CRLF injection attempts
* Control character injection
* Malformed HTTP Location header values
Invalid destinations must return:
```http
500 Internal Server Error
```
and must not panic or produce malformed HTTP responses.
--- ---
# 12. Backup Validation # 12. Backup Validation
+1 -1
View File
@@ -1,6 +1,6 @@
# Upgrade Guide # Upgrade Guide
Version: v0.5.1 Version: v0.6.0
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1. This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
+8 -4
View File
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
} }
impl AnalyticsQueue { impl AnalyticsQueue {
pub fn new(db: Db, capacity: usize) -> Self { pub fn new(
db: Db,
capacity: usize,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> (Self, tokio::task::JoinHandle<()>) {
let (sender, receiver) = mpsc::channel(capacity); let (sender, receiver) = mpsc::channel(capacity);
// Spawn background worker to batch-write records // Spawn background worker to batch-write records
tokio::spawn(async move { let handle = tokio::spawn(async move {
super::worker::run_worker(db, receiver).await; super::worker::run_worker(db, receiver, shutdown_rx).await;
}); });
Self { sender } (Self { sender }, handle)
} }
// Attempt to queue a visit. Non-blocking. // Attempt to queue a visit. Non-blocking.
+10 -1
View File
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
use crate::db::Db; use crate::db::Db;
use crate::models::VisitRecord; use crate::models::VisitRecord;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) { pub async fn run_worker(
db: Db,
mut receiver: mpsc::Receiver<VisitRecord>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let mut batch = Vec::new(); let mut batch = Vec::new();
let batch_size = 50; let batch_size = 50;
let flush_interval = Duration::from_secs(2); let flush_interval = Duration::from_secs(2);
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
flush_batch(&db, &mut batch); flush_batch(&db, &mut batch);
} }
} }
_ = shutdown_rx.changed() => {
info!("Analytics worker flushing pending records");
flush_batch(&db, &mut batch);
break;
}
} }
} }
} }
+33
View File
@@ -0,0 +1,33 @@
use crate::config::Config;
use crate::db::Db;
use crate::services::destination_audit::{audit_all_destinations, format_report};
use std::path::PathBuf;
use tracing::info;
/// Read-only audit of all stored redirect destinations.
///
/// Does not rewrite, delete, or "repair" any records.
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Starting read-only destination audit...");
let db = Db::init(&config)?;
let report = audit_all_destinations(&db)?;
print!("{}", format_report(&report));
if report.invalid > 0 {
// Non-zero exit so automation can detect findings without treating them as crashes.
Err(format!(
"destination audit found {} invalid stored URL(s)",
report.invalid
)
.into())
} else {
Ok(())
}
}
+7
View File
@@ -1,6 +1,7 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
pub mod admin_migrate; pub mod admin_migrate;
pub mod audit_destinations;
pub mod backup; pub mod backup;
pub mod backup_user; pub mod backup_user;
pub mod create_admin; pub mod create_admin;
@@ -23,6 +24,7 @@ pub mod validate;
#[derive(Parser)] #[derive(Parser)]
#[command(name = "bzod")] #[command(name = "bzod")]
#[command(version)]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")] #[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
pub struct Cli { pub struct Cli {
#[command(subcommand)] #[command(subcommand)]
@@ -72,6 +74,11 @@ pub enum Commands {
#[arg(long)] #[arg(long)]
data_dir: Option<String>, data_dir: Option<String>,
}, },
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
AuditDestinations {
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new administrator user in the database /// Create a new administrator user in the database
CreateAdmin { CreateAdmin {
#[arg(long)] #[arg(long)]
+297 -25
View File
@@ -1,21 +1,250 @@
use crate::config::Config; use crate::config::Config;
use crate::services::registry_validator::RegistryIssueType;
use flate2::read::GzDecoder; use flate2::read::GzDecoder;
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
use std::path::PathBuf; use std::path::{Path, PathBuf};
use tar::Archive; use tar::Archive;
use tracing::{error, info}; use tracing::{error, info, warn};
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
let manifest_path = temp_dir.join("backup_manifest.json");
if !manifest_path.exists() {
return false;
}
match std::fs::read_to_string(&manifest_path) {
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
Err(_) => false,
},
Err(_) => false,
}
}
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
fn is_flat_layout(temp_dir: &Path) -> bool {
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
}
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
///
/// This function:
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
/// 2. Reads the actual administrator identity from admin.db (preserving
/// the original username and argon2id password hash — no manufacturing).
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
/// 4. Creates an admin account with the original credentials.
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
/// accounts for any missing tenants.
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
let users_db_path = temp_dir.join("admin").join("users.db");
let admin_db_path = temp_dir.join("admin").join("admin.db");
let system_db_path = temp_dir.join("admin").join("system.db");
// Check if users.db already has the users table (i.e., not a legacy backup)
{
let conn = rusqlite::Connection::open(&users_db_path)?;
let has_users_table: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_users_table {
info!("users.db already has users table; skipping legacy bootstrap");
return Ok(());
}
}
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
// Step 1: Run migrations to create the users.db schema
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
crate::db::sqlite::enable_wal(&users_conn, "users")?;
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
// Step 2: Read the actual administrator identity from admin.db
let (admin_username, admin_password_hash) = {
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
// The legacy admin.db users table has schema:
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
// Read the actual admin — typically the first (and often only) user.
let result: Result<(String, String), _> = admin_conn.query_row(
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((username, hash)) => {
info!(
"Preserved administrator identity from legacy admin.db: username='{}'",
username
);
(username, hash)
}
Err(e) => {
return Err(format!(
"Failed to read administrator credentials from legacy admin.db: {}",
e
)
.into());
}
}
};
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
// This account owns the content.db/analytics.db from the flat backup (users/1/).
// It uses the original admin's password hash so no synthetic credentials are introduced.
let now = chrono::Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
1i64,
"legacy_admin",
&admin_password_hash,
"disabled",
&now,
"system"
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
info!("Created legacy_admin system account (id=1) for tenant content ownership");
// Step 4: Create the actual admin account with original credentials
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![
&admin_username,
&admin_password_hash,
"active",
&now,
"admin"
],
)?;
let admin_id = users_conn.last_insert_rowid();
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
info!(
"Created admin account '{}' (id={}) with original credentials",
admin_username, admin_id
);
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
if system_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let has_global_slugs: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_global_slugs {
let mut stmt =
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let owner_id: i64 = row.get(0)?;
// Skip user 1 (legacy_admin) and the admin we just created
if owner_id == 1 || owner_id == admin_id {
continue;
}
// Check if this user already exists in users.db
let exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_id],
|r| r.get(0),
)
.unwrap_or(false);
if !exists {
// Create a disabled placeholder so RegistryValidator can resolve ownership.
// The tenant's actual databases were not included in the flat backup.
let placeholder_name = format!("restored_user_{}", owner_id);
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
owner_id,
&placeholder_name,
&admin_password_hash,
"disabled",
&now,
"standard",
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
warn!(
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
owner_id
);
}
}
}
}
Ok(())
}
/// Classify registry issues into hard errors vs warnings for legacy restore.
///
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
/// TenantAdminHasIsolatedContent
fn classify_registry_issues(
issues: &[crate::services::registry_validator::RegistryIssue],
is_legacy: bool,
) -> (
Vec<&crate::services::registry_validator::RegistryIssue>,
Vec<&crate::services::registry_validator::RegistryIssue>,
) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
match issue.issue_type {
RegistryIssueType::DuplicateSlug
| RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => {
errors.push(issue);
}
RegistryIssueType::MissingOwner if !is_legacy => {
errors.push(issue);
}
_ => {
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
// These represent pre-existing inconsistencies in the backup data,
// not restore corruption.
warnings.push(issue);
}
}
}
(errors, warnings)
}
pub fn perform_restore( pub fn perform_restore(
file_path: &std::path::Path, file_path: &Path,
data_dir: &std::path::Path, data_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive // 1. Open and unpack the archive to a temporary directory
let f = File::open(file_path)?; let f = File::open(file_path)?;
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. Unpack to temporary directory first
let temp_dir = let temp_dir =
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4())); std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir)?; std::fs::create_dir_all(&temp_dir)?;
@@ -25,7 +254,33 @@ pub fn perform_restore(
return Err(e.into()); return Err(e.into());
} }
// 3. Run validation on temp_dir // 2. Detect backup format
let is_legacy = is_legacy_flat_backup(&temp_dir);
let needs_normalization = is_flat_layout(&temp_dir);
if is_legacy {
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
}
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
if needs_normalization {
info!("Normalizing flat database layout into multi-tenant structure...");
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to normalize legacy layout: {}", e).into());
}
}
// 4. For legacy backups: bootstrap the empty users.db with the current schema
// and populate it from admin.db credentials
if is_legacy {
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
}
}
// 5. Run validation on the normalized temp_dir
let mut temp_config = Config::load(); let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone(); temp_config.data_dir = temp_dir.clone();
@@ -46,16 +301,8 @@ pub fn perform_restore(
} }
// Registry integrity check // Registry integrity check
let system_db_path = if temp_dir.join("admin/system.db").exists() { let system_db_path = temp_dir.join("admin").join("system.db");
temp_dir.join("admin/system.db") let users_db_path = temp_dir.join("admin").join("users.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() { if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?; let system_conn = rusqlite::Connection::open(&system_db_path)?;
@@ -68,12 +315,37 @@ pub fn perform_restore(
) { ) {
Ok(issues) => { Ok(issues) => {
if !issues.is_empty() { if !issues.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir); let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
return Err(format!(
"Registry integrity errors in backup: {} issues detected", // Log all warnings
issues.len() for w in &warnings {
) warn!(
.into()); "Legacy restore warning: {:?} — {}",
w.issue_type, w.description
);
}
// Abort only on hard errors
if !hard_errors.is_empty() {
let descriptions: Vec<String> = hard_errors
.iter()
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
.collect();
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!(
"Registry integrity errors in backup ({} critical): {}",
hard_errors.len(),
descriptions.join("; ")
)
.into());
}
if !warnings.is_empty() {
info!(
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
warnings.len()
);
}
} }
} }
Err(e) => { Err(e) => {
@@ -83,13 +355,13 @@ pub fn perform_restore(
} }
} }
// 4. If validation succeeds, copy temp_dir contents to data_dir // 6. If validation succeeds, atomically replace data_dir contents
if data_dir.exists() { if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir); let _ = std::fs::remove_dir_all(data_dir);
} }
std::fs::create_dir_all(data_dir)?; std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?; std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? { for entry in std::fs::read_dir(src)? {
let entry = entry?; let entry = entry?;
+96 -20
View File
@@ -7,6 +7,30 @@ use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
use tracing::info; use tracing::info;
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
pub async fn run( pub async fn run(
host: Option<String>, host: Option<String>,
port: Option<u16>, port: Option<u16>,
@@ -29,39 +53,63 @@ pub async fn run(
// Init DBs // Init DBs
let db = Db::init(&config)?; let db = Db::init(&config)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut join_handles = Vec::new();
// Init Queue // Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000); let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
join_handles.push(("analytics_worker", analytics_handle));
// Spawn background tasks // Spawn background tasks
let link_checker_db = db.clone(); let link_checker_db = db.clone();
let link_checker_interval = config.link_check_interval_mins; let link_checker_interval = config.link_check_interval_mins;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await; join_handles.push((
}); "link_checker",
tokio::spawn(async move {
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
}),
));
let aggregator_db = db.clone(); let aggregator_db = db.clone();
let aggregator_interval = config.aggregation_interval_mins; let aggregator_interval = config.aggregation_interval_mins;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await; join_handles.push((
}); "aggregator",
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
}),
));
let retention_db = db.clone(); let retention_db = db.clone();
let retention_days = config.data_retention_days; let retention_days = config.data_retention_days;
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_retention_cleaner(retention_db, retention_days).await; join_handles.push((
}); "retention_cleaner",
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
}),
));
// Spawn optional backup scheduler // Spawn optional backup scheduler
let backup_db = db.clone(); let backup_db = db.clone();
let backup_config = config.clone(); let backup_config = config.clone();
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await; join_handles.push((
}); "backup_scheduler",
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
}),
));
let expiry_db = db.clone(); let expiry_db = db.clone();
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_expiry_checker(expiry_db).await; join_handles.push((
}); "expiry_checker",
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db, rx).await;
}),
));
let reconcile_db = db.clone(); let reconcile_db = db.clone();
let reconcile_interval_hours = { let reconcile_interval_hours = {
@@ -75,9 +123,13 @@ pub async fn run(
.and_then(|val| val.parse::<u64>().ok()) .and_then(|val| val.parse::<u64>().ok())
.unwrap_or(24) .unwrap_or(24)
}; };
tokio::spawn(async move { let rx = shutdown_rx.clone();
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await; join_handles.push((
}); "quota_reconciliation",
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
}),
));
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
@@ -98,7 +150,31 @@ pub async fn run(
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Listening for requests on http://{}", addr); info!("Listening for requests on http://{}", addr);
axum::serve(listener, router).await?;
axum::serve(listener, router)
.with_graceful_shutdown(async move {
shutdown_signal().await;
info!("Shutdown signal received");
info!("Stopping HTTP server...");
let _ = shutdown_tx.send(true);
})
.await?;
info!("Stopping background workers...");
let timeout_duration = std::time::Duration::from_secs(10);
let deadline = tokio::time::Instant::now() + timeout_duration;
for (name, handle) in join_handles {
match tokio::time::timeout_at(deadline, handle).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
}
}
info!("Background workers stopped");
info!("BZOD shutdown complete");
Ok(()) Ok(())
} }
+12 -2
View File
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day; use crate::analytics::aggregate_day;
use crate::db::Db; use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) { pub async fn run_aggregator(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop { loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Analytics aggregator shutting down...");
break;
}
}
info!("Running background analytics aggregator..."); info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = { let user_ids: Vec<i64> = {
+12 -2
View File
@@ -4,7 +4,11 @@ use crate::db::Db;
use std::time::Duration; use std::time::Duration;
use tracing::{error, info}; use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) { pub async fn run_backup_scheduler(
db: Db,
config: Config,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
if !config.backup_enabled { if !config.backup_enabled {
info!("Background backup scheduler is disabled."); info!("Background backup scheduler is disabled.");
return; return;
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
); );
loop { loop {
// Run backup every configured interval // Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Backup scheduler shutting down...");
break;
}
}
info!("Running background database backup..."); info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup"); let job_id = log_job_start(&db.system, "database_backup");
+49 -9
View File
@@ -1,33 +1,73 @@
use crate::db::Db; use crate::db::Db;
use std::time::Duration; use std::time::Duration;
use tracing::info; use tracing::{error, info, warn};
/// Background job that marks expired URLs. /// Background job that marks expired URLs.
/// ///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0` /// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`. /// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) { ///
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
/// authoritative and returns 410 without depending on this sweeper. The sweeper
/// is maintenance (persist `expired=1`) and must remain idempotent.
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
loop { loop {
tokio::time::sleep(Duration::from_secs(60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
_ = shutdown_rx.changed() => {
info!("Expiry checker shutting down...");
break;
}
}
let user_ids: Vec<i64> = { let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap(); let conn = match db.users.lock() {
Ok(c) => c,
Err(e) => {
error!(error = %e, "expiry job: users_db mutex poisoned");
continue;
}
};
let mut stmt = match conn.prepare("SELECT id FROM users;") { let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s, Ok(s) => s,
Err(_) => continue, Err(e) => {
error!(error = %e, "expiry job: failed to list users");
continue;
}
}; };
let rows = match stmt.query_map([], |row| row.get(0)) { let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r, Ok(r) => r,
Err(_) => continue, Err(e) => {
error!(error = %e, "expiry job: failed to map user ids");
continue;
}
}; };
rows.filter_map(|r| r.ok()).collect() rows.filter_map(|r| r.ok()).collect()
}; };
let mut total_expired = 0; let mut total_expired = 0;
for user_id in user_ids { for user_id in user_ids {
if let Ok(conn) = super::open_user_content_conn(&db, user_id) { match super::open_user_content_conn(&db, user_id) {
let count = crate::db::content::expire_urls(&conn).unwrap_or(0); Ok(conn) => match crate::db::content::expire_urls(&conn) {
total_expired += count; Ok(count) => total_expired += count,
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: expire_urls failed"
);
}
},
Err(e) => {
// Missing content.db for a user is common; only log open errors that are unexpected.
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: could not open content.db"
);
}
}
} }
} }
+13 -2
View File
@@ -8,7 +8,11 @@ use uuid::Uuid;
use super::{log_job_end, log_job_start}; use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
pub async fn run_link_checker(db: Db, interval_mins: u64) { pub async fn run_link_checker(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let client = Client::builder() let client = Client::builder()
.timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1") .user_agent("bzod-link-checker/0.1")
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
loop { loop {
// Sleep first to give server time to start up // Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Link checker shutting down...");
break;
}
}
info!("Running background link health check..."); info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker"); let job_id = log_job_start(&db.system, "link_checker");
+12 -2
View File
@@ -2,10 +2,20 @@ use crate::db::Db;
use std::time::Duration; use std::time::Duration;
use tracing::{error, info}; use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) { pub async fn run_quota_reconciliation(
db: Db,
interval_hours: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop { loop {
// Sleep first // Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Quota reconciliation shutting down...");
break;
}
}
info!("Running background quota reconciliation..."); info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = { let user_ids: Vec<i64> = {
+12 -2
View File
@@ -4,7 +4,11 @@ use tracing::{error, info};
use super::{log_job_end, log_job_start}; use super::{log_job_end, log_job_start};
use crate::db::Db; use crate::db::Db;
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) { pub async fn run_retention_cleaner(
db: Db,
retention_days_opt: Option<i64>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let retention_days = match retention_days_opt { let retention_days = match retention_days_opt {
Some(days) => days, Some(days) => days,
None => return, None => return,
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
loop { loop {
// Check once every 24 hours // Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await; tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Retention cleaner shutting down...");
break;
}
}
info!("Running background data retention cleanup..."); info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = { let user_ids: Vec<i64> = {
+3
View File
@@ -38,6 +38,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Validate { data_dir } => { Commands::Validate { data_dir } => {
bzod::cli::validate::run(data_dir, config).await?; bzod::cli::validate::run(data_dir, config).await?;
} }
Commands::AuditDestinations { data_dir } => {
bzod::cli::audit_destinations::run(data_dir, config).await?;
}
Commands::CreateAdmin { username, data_dir } => { Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?; bzod::cli::create_admin::run(username, data_dir, config).await?;
} }
+121
View File
@@ -0,0 +1,121 @@
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
//!
//! Extracted from admin restore handlers so path moves are testable without HTTP.
use std::path::{Path, PathBuf};
use tracing::warn;
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
///
/// Layout:
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let admin_dir = data_dir.join("admin");
let users_1_dir = data_dir.join("users").join("1");
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
let admin_files = [
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
"users.db",
"users.db-wal",
"users.db-shm",
];
for f in admin_files {
let src = data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored admin file into admin/"
);
return Err(e);
}
}
}
let content_files = [
"content.db",
"content.db-wal",
"content.db-shm",
"analytics.db",
"analytics.db-wal",
"analytics.db-shm",
];
for f in content_files {
let src = data_dir.join(f);
if src.exists() {
let dst = users_1_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored content file into users/1/"
);
return Err(e);
}
}
}
Ok(())
}
/// Paths used when reopening connections after restore.
#[derive(Debug, Clone)]
pub struct RestoredDbPaths {
pub admin: PathBuf,
pub system: PathBuf,
pub users: PathBuf,
pub content: PathBuf,
pub analytics: PathBuf,
}
impl RestoredDbPaths {
pub fn from_data_dir(data_dir: &Path) -> Self {
Self {
admin: data_dir.join("admin/admin.db"),
system: data_dir.join("admin/system.db"),
users: data_dir.join("admin/users.db"),
content: data_dir.join("users/1/content.db"),
analytics: data_dir.join("users/1/analytics.db"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn moves_flat_files_into_tenant_layout() {
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("admin.db"), b"a").unwrap();
fs::write(dir.join("system.db"), b"s").unwrap();
fs::write(dir.join("users.db"), b"u").unwrap();
fs::write(dir.join("content.db"), b"c").unwrap();
fs::write(dir.join("analytics.db"), b"an").unwrap();
normalize_restored_layout(&dir).unwrap();
assert!(dir.join("admin/admin.db").exists());
assert!(dir.join("admin/system.db").exists());
assert!(dir.join("admin/users.db").exists());
assert!(dir.join("users/1/content.db").exists());
assert!(dir.join("users/1/analytics.db").exists());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Bulk URL creation business logic (transaction + slug reservation).
//!
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::models::Url;
use crate::utils::validation::validate_redirect_destination;
use rusqlite::{Connection, Transaction};
use std::sync::Mutex;
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
#[derive(Debug, Clone)]
pub struct BulkUrlCreateItem {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub expires_at: Option<String>,
pub password: Option<String>,
pub max_access_count: Option<i64>,
}
#[derive(Debug)]
pub enum BulkUrlError {
BadRequest(String),
Conflict(String),
Forbidden(String),
Internal(String),
}
impl BulkUrlError {
pub fn message(&self) -> &str {
match self {
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
}
}
}
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
for slug in slugs {
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
}
}
/// Check that the tenant can accept `additional` new URLs.
pub fn ensure_url_quota(
users_db: &Mutex<Connection>,
user_id: i64,
additional: i64,
) -> Result<(), BulkUrlError> {
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
match crate::db::users::get_user_quotas(&users_conn, user_id) {
Ok(Some(quotas)) => {
if quotas.current_urls + additional > quotas.max_urls {
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
} else {
Ok(())
}
}
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
}
}
/// Create many URLs inside a single content transaction with global slug reservation.
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
system_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
items: Vec<BulkUrlCreateItem>,
) -> Result<Vec<Url>, BulkUrlError> {
let mut conn = crate::utils::lock_db(content_db, "content_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let tx = conn.transaction().map_err(|e| {
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
})?;
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in items {
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
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 = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
}
}
Ok(created_urls)
}
fn create_one_in_tx(
tx: &Transaction<'_>,
system_db: &Mutex<Connection>,
owner_user_id: i64,
item: BulkUrlCreateItem,
reserved_slugs: &mut Vec<String>,
) -> Result<Url, BulkUrlError> {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(BulkUrlError::BadRequest(format!(
"Short code '{code}' must be 6 hex characters"
)));
}
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
return Err(BulkUrlError::Conflict(format!(
"Short code '{code}' already exists"
)));
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
owner_user_id,
"url",
"",
"reserving",
) {
return Err(BulkUrlError::Internal(format!(
"Failed to reserve slug '{code}': {e}"
)));
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
return Err(BulkUrlError::Internal(format!(
"Password hashing error: {e}"
)));
}
}
} else {
None
};
if !validate_redirect_destination(&item.destination) {
return Err(BulkUrlError::BadRequest(format!(
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
)));
}
let tags = item.tags.unwrap_or_default();
crate::db::content::create_url_extended(
tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
)
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
}
+261
View File
@@ -0,0 +1,261 @@
//! Read-only audit of stored redirect destinations.
//!
//! Scans tenant content databases and classifies each `urls.destination` using
//! the same rules as write-path validation. Never rewrites or deletes data.
use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection;
use std::path::Path;
use tracing::{error, info, warn};
/// Summary counters for a destination audit run.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DestinationAuditReport {
pub scanned_users: usize,
pub total_urls: usize,
pub valid_http: usize,
pub valid_https: usize,
pub invalid: usize,
pub control_characters: usize,
pub unsupported_scheme: usize,
pub malformed: usize,
pub empty: usize,
pub too_long: usize,
pub non_ascii: usize,
/// Safe sample of invalid records: (owner_user_id, code, class_label).
/// Destination bodies are never included (may contain control chars / secrets).
pub invalid_samples: Vec<InvalidDestinationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidDestinationSample {
pub owner_user_id: i64,
pub code: String,
pub url_id: String,
pub class: &'static str,
pub destination_len: usize,
}
const MAX_SAMPLES: usize = 50;
fn class_label(c: DestinationClass) -> &'static str {
match c {
DestinationClass::ValidHttp => "valid_http",
DestinationClass::ValidHttps => "valid_https",
DestinationClass::Empty => "empty",
DestinationClass::TooLong => "too_long",
DestinationClass::ControlCharacters => "control_characters",
DestinationClass::NonAscii => "non_ascii",
DestinationClass::UnsupportedScheme => "unsupported_scheme",
DestinationClass::Malformed => "malformed",
}
}
/// Classify a single destination and update report counters.
pub fn record_destination(
report: &mut DestinationAuditReport,
owner_user_id: i64,
code: &str,
url_id: &str,
destination: &str,
) {
report.total_urls += 1;
let class = classify_redirect_destination(destination);
match class {
DestinationClass::ValidHttp => report.valid_http += 1,
DestinationClass::ValidHttps => report.valid_https += 1,
DestinationClass::Empty => {
report.empty += 1;
report.invalid += 1;
}
DestinationClass::TooLong => {
report.too_long += 1;
report.invalid += 1;
}
DestinationClass::ControlCharacters => {
report.control_characters += 1;
report.invalid += 1;
}
DestinationClass::NonAscii => {
report.non_ascii += 1;
report.invalid += 1;
}
DestinationClass::UnsupportedScheme => {
report.unsupported_scheme += 1;
report.invalid += 1;
}
DestinationClass::Malformed => {
report.malformed += 1;
report.invalid += 1;
}
}
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
report.invalid_samples.push(InvalidDestinationSample {
owner_user_id,
code: code.to_string(),
url_id: url_id.to_string(),
class: class_label(class),
destination_len: destination.len(),
});
}
}
/// Scan one content database connection for URL destinations.
pub fn audit_content_conn(
conn: &Connection,
owner_user_id: i64,
report: &mut DestinationAuditReport,
) -> rusqlite::Result<()> {
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, code, destination) = row?;
record_destination(report, owner_user_id, &code, &id, &destination);
}
Ok(())
}
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
let path = data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn)
}
/// Audit all tenant content databases found under the configured data directory.
///
/// Read-only: does not modify any records.
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
let mut report = DestinationAuditReport::default();
let user_ids: Vec<i64> = {
let users = db
.users
.lock()
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
let mut stmt = users
.prepare("SELECT id FROM users;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for user_id in user_ids {
match open_user_content(&db.data_dir, user_id) {
Ok(conn) => {
report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
error!(
owner_user_id = user_id,
error = %e,
"destination audit failed for user content.db"
);
return Err(format!("audit user {} content.db: {}", user_id, e));
}
}
Err(rusqlite::Error::InvalidPath(_)) => {
// User has no content DB yet — skip.
}
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"could not open user content.db for destination audit"
);
}
}
}
info!(
total_urls = report.total_urls,
valid = report.valid_http + report.valid_https,
invalid = report.invalid,
"destination audit complete"
);
Ok(report)
}
/// Format a human-readable report for CLI output.
pub fn format_report(report: &DestinationAuditReport) -> String {
let mut out = String::new();
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
out.push_str("===========================================\n");
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
out.push_str(&format!(
" control characters: {}\n",
report.control_characters
));
out.push_str(&format!(
" unsupported scheme: {}\n",
report.unsupported_scheme
));
out.push_str(&format!(" malformed: {}\n", report.malformed));
out.push_str(&format!(" empty: {}\n", report.empty));
out.push_str(&format!(" too long: {}\n", report.too_long));
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
if !report.invalid_samples.is_empty() {
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
for s in &report.invalid_samples {
out.push_str(&format!(
" user={} code={} id={} class={} dest_len={}\n",
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_control_character_destination() {
let mut report = DestinationAuditReport::default();
record_destination(
&mut report,
1,
"ab12cd",
"id-1",
"https://evil.example/\r\nX:1",
);
assert_eq!(report.total_urls, 1);
assert_eq!(report.invalid, 1);
assert_eq!(report.control_characters, 1);
assert_eq!(report.invalid_samples.len(), 1);
assert_eq!(report.invalid_samples[0].class, "control_characters");
// Ensure we never store the destination body in the sample.
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
}
#[test]
fn records_valid_https() {
let mut report = DestinationAuditReport::default();
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 0);
assert!(report.invalid_samples.is_empty());
}
}
+5
View File
@@ -1,7 +1,12 @@
pub mod api_keys; pub mod api_keys;
pub mod audit; pub mod audit;
pub mod backup_layout;
pub mod bulk; pub mod bulk;
pub mod bulk_urls;
pub mod destination_audit;
pub mod landing_pages; pub mod landing_pages;
pub mod qr; pub mod qr;
pub mod registry_validator; pub mod registry_validator;
pub mod shortener; pub mod shortener;
pub mod slug_transfer;
pub mod urls;
+13 -2
View File
@@ -10,13 +10,24 @@ pub fn create_url(
description: Option<&str>, description: Option<&str>,
tags: &[String], tags: &[String],
) -> Result<Url, AppError> { ) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap(); if !crate::utils::validation::validate_redirect_destination(destination) {
return Err(AppError::BadRequest(
"Destination must be a valid http(s) URL without control characters".into(),
));
}
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?; let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url) Ok(url)
} }
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> { pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap(); let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::get_url_by_code(&conn, code)?; let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url) Ok(url)
} }
+243
View File
@@ -0,0 +1,243 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates global_slugs
//! ownership. Handlers own admin auth and HTTP mapping.
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
use rusqlite::OptionalExtension;
#[derive(Debug)]
pub enum TransferError {
NotFound(&'static str),
BadRequest(String),
Internal(String),
}
impl TransferError {
pub fn message(&self) -> String {
match self {
Self::NotFound(m) => (*m).to_string(),
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct SlugTransferRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Debug)]
pub struct SlugTransferResult {
pub old_owner_user_id: i64,
pub new_owner_user_id: i64,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in `global_slugs`.
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let mut stmt = system_conn
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let row_opt = stmt
.query_row([slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
.map_err(|e| TransferError::Internal(e.to_string()))?;
match row_opt {
Some(r) => Ok(r),
None => Err(TransferError::NotFound("Slug not found")),
}
}
/// Copy content row between tenants and return the new target id.
fn copy_content(
state: &AppState,
old_dbs: &UserDbs,
new_dbs: &UserDbs,
slug: &str,
target_type: &str,
new_owner_user_id: i64,
) -> Result<String, TransferError> {
let old_conn = lock_db(&old_dbs.content, "old_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let new_conn = lock_db(&new_dbs.content, "new_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if target_type == "url" {
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
Ok(Some(u)) => u,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_urls >= quota.max_urls {
return Err(TransferError::BadRequest(
"New owner has exceeded URL quota limit".into(),
));
}
}
}
let new_url = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
let _ = crate::db::content::delete_url(&old_conn, &url.id);
Ok(new_url.id)
} else if target_type == "page" {
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
Ok(Some(p)) => p,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_landings >= quota.max_landings {
return Err(TransferError::BadRequest(
"New owner has exceeded landing page quota limit".into(),
));
}
}
}
let new_page = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
Ok(new_page.id)
} else {
Err(TransferError::NotFound(
"Content not found in owner database",
))
}
}
/// Perform a full slug transfer (content + registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
if old_owner_user_id == req.new_owner_user_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.get_user_dbs(old_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.get_user_dbs(req.new_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
let new_target_id = copy_content(
state,
&old_dbs,
&new_dbs,
&req.slug,
&target_type,
req.new_owner_user_id,
)?;
{
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![
req.slug,
old_owner_user_id,
req.new_owner_user_id,
now,
admin_username
],
);
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ =
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"SLUG_TRANSFER",
"slug",
&req.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
target_type,
new_target_id,
})
}
+117
View File
@@ -0,0 +1,117 @@
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
//!
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
//! destination preparation that must stay consistent across entry points.
use crate::utils::validation::validate_redirect_destination;
/// Optional UTM parameters applied to a destination URL.
#[derive(Debug, Default, Clone)]
pub struct UtmParams<'a> {
pub source: Option<&'a str>,
pub medium: Option<&'a str>,
pub campaign: Option<&'a str>,
}
/// Normalize and optionally append UTM parameters to a destination.
///
/// Returns `Err` when the base destination fails canonical validation.
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
let mut dest = raw.trim().to_string();
if !validate_redirect_destination(&dest) {
return Err("Destination must be a valid http(s) URL without control characters");
}
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(src) = utm.source {
let src = src.trim();
if !src.is_empty() {
query.append_pair("utm_source", src);
has_utm = true;
}
}
if let Some(med) = utm.medium {
let med = med.trim();
if !med.is_empty() {
query.append_pair("utm_medium", med);
has_utm = true;
}
}
if let Some(camp) = utm.campaign {
let camp = camp.trim();
if !camp.is_empty() {
query.append_pair("utm_campaign", camp);
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
Ok(dest)
}
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut rfc = trimmed.to_string();
if rfc.len() == 16 {
// HTML datetime-local → assume UTC seconds
rfc.push_str(":00Z");
}
Some(rfc)
}
/// Parse optional max-access-count form field.
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_rejects_crlf() {
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
assert!(err.contains("valid http"));
}
#[test]
fn prepare_appends_utm() {
let dest = prepare_destination(
"https://example.com/path",
UtmParams {
source: Some("newsletter"),
medium: Some("email"),
campaign: Some("spring"),
},
)
.unwrap();
assert!(dest.contains("utm_source=newsletter"));
assert!(dest.contains("utm_medium=email"));
assert!(dest.contains("utm_campaign=spring"));
}
#[test]
fn parse_expires_datetime_local() {
assert_eq!(
parse_expires_at_input("2030-01-01T12:00"),
Some("2030-01-01T12:00:00Z".to_string())
);
assert_eq!(parse_expires_at_input(" "), None);
}
}
+7 -7
View File
@@ -29,7 +29,7 @@ pub struct AppState {
impl AppState { impl AppState {
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> { pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = self.user_dbs.lock().unwrap(); let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
if let Some(dbs) = pool.get(&user_id) { if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone()); return Ok(dbs.clone());
} }
@@ -87,12 +87,12 @@ impl AppState {
Ok(dbs) Ok(dbs)
} }
pub fn db_compact(&self) -> Result<(), rusqlite::Error> { pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?; crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?; crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?; crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
self.system_db.lock().unwrap().execute("VACUUM;", [])?; crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
self.users_db.lock().unwrap().execute("VACUUM;", [])?; crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
Ok(()) Ok(())
} }
} }
+61
View File
@@ -0,0 +1,61 @@
//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections.
//!
//! Prefer these on request paths so a poisoned mutex returns a controlled error
//! instead of panicking the worker thread.
use crate::error::AppError;
use std::sync::{Mutex, MutexGuard};
use tracing::error;
/// Acquire a database mutex, mapping poison to [`AppError::Internal`].
///
/// Logs the mutex name (not connection contents or secrets).
pub fn lock_db<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, AppError> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
AppError::Internal(format!("{name} mutex poisoned"))
})
}
/// Acquire a database mutex, mapping poison to a plain error string.
///
/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`.
pub fn lock_db_str<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, String> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
format!("{name} mutex poisoned")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn lock_db_succeeds_on_healthy_mutex() {
let m = Mutex::new(42);
let g = lock_db(&m, "test").unwrap();
assert_eq!(*g, 42);
}
#[test]
fn lock_db_maps_poison() {
let m = Mutex::new(1);
let _ = std::panic::catch_unwind(|| {
let _g = m.lock().unwrap();
panic!("poison");
});
let err = lock_db(&m, "poisoned_db").unwrap_err();
match err {
AppError::Internal(msg) => assert!(msg.contains("poisoned_db")),
other => panic!("unexpected {other:?}"),
}
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
pub mod db_lock;
pub mod hashing; pub mod hashing;
pub mod network; pub mod network;
pub mod random; pub mod random;
@@ -5,8 +6,9 @@ pub mod system;
pub mod time; pub mod time;
pub mod validation; pub mod validation;
pub use db_lock::{lock_db, lock_db_str};
pub use hashing::sha256_hash; pub use hashing::sha256_hash;
pub use network::get_client_ip; pub use network::{get_client_ip, resolve_cookie_secure};
pub use random::generate_token; pub use random::generate_token;
pub use system::{get_db_file_info, get_memory_usage}; pub use system::{get_db_file_info, get_memory_usage};
pub use time::format_duration; pub use time::format_duration;
+116
View File
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
} }
"127.0.0.1".to_string() "127.0.0.1".to_string()
} }
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
pub(crate) fn extract_hostname(host_header: &str) -> &str {
if host_header.starts_with('[') {
if let Some(end_idx) = host_header.find(']') {
return &host_header[1..end_idx];
}
}
host_header.split(':').next().unwrap_or(host_header)
}
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
///
/// Policy:
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
/// the cookie during local development over cleartext HTTP.
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
// 1. Explicit HTTPS via reverse proxy
if let Some(proto) = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
if proto.eq_ignore_ascii_case("https") {
return true;
}
}
// 2. Exact loopback development (prevent cookie drop)
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
let hostname = extract_hostname(host_hdr);
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
return false;
}
}
// 3. Global config (normally true)
config_secure
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn test_extract_hostname() {
assert_eq!(extract_hostname("localhost"), "localhost");
assert_eq!(extract_hostname("localhost:8080"), "localhost");
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
assert_eq!(extract_hostname("[::1]"), "::1");
assert_eq!(extract_hostname("[::1]:8080"), "::1");
assert_eq!(extract_hostname("example.com"), "example.com");
assert_eq!(extract_hostname("example.com:443"), "example.com");
assert_eq!(
extract_hostname("localhost.example.com"),
"localhost.example.com"
);
}
#[test]
fn test_resolve_cookie_secure() {
let mut headers = HeaderMap::new();
// No headers, global config is true -> Secure=true
assert!(resolve_cookie_secure(true, &headers));
// No headers, global config is false -> Secure=false
assert!(!resolve_cookie_secure(false, &headers));
// Exact loopback matches -> Secure=false
let loopback_hosts = [
"localhost",
"localhost:8080",
"127.0.0.1",
"127.0.0.1:8080",
"[::1]",
"[::1]:8080",
];
for host in loopback_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
!resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// Non-loopback localhost subdomains -> Secure=true (relying on config)
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
for host in public_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// X-Forwarded-Proto = https overrides loopback
headers.insert("host", HeaderValue::from_static("localhost"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(resolve_cookie_secure(true, &headers));
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
// X-Forwarded-Proto = http DOES NOT override global config
headers.insert("host", HeaderValue::from_static("example.com"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
}
}
+141
View File
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
pub fn validate_page_code(code: &str) -> bool { pub fn validate_page_code(code: &str) -> bool {
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code) (code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
} }
/// Classification of a stored or proposed redirect destination.
///
/// Used by write-path validation and read-only legacy data audits. Order of checks
/// matches `validate_redirect_destination` so both share the same rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DestinationClass {
ValidHttp,
ValidHttps,
Empty,
TooLong,
ControlCharacters,
NonAscii,
UnsupportedScheme,
Malformed,
}
impl DestinationClass {
pub fn is_valid(self) -> bool {
matches!(self, Self::ValidHttp | Self::ValidHttps)
}
}
fn scheme_prefix(dest: &str) -> Option<&str> {
let end = dest.find(':')?;
let scheme = &dest[..end];
if scheme.is_empty() {
return None;
}
if scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
{
Some(scheme)
} else {
None
}
}
/// Classify a destination using the same rules as write-path validation.
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
let dest = destination.trim();
if dest.is_empty() {
return DestinationClass::Empty;
}
if dest.len() > 2048 {
return DestinationClass::TooLong;
}
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
return DestinationClass::ControlCharacters;
}
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
if !dest.is_ascii() {
return DestinationClass::NonAscii;
}
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
if let Some(scheme) = scheme_prefix(dest) {
let scheme_l = scheme.to_ascii_lowercase();
if scheme_l != "http" && scheme_l != "https" {
return DestinationClass::UnsupportedScheme;
}
}
match reqwest::Url::parse(dest) {
Ok(url) => {
if url.host_str().is_none() {
return DestinationClass::Malformed;
}
match url.scheme() {
"http" => DestinationClass::ValidHttp,
"https" => DestinationClass::ValidHttps,
_ => DestinationClass::UnsupportedScheme,
}
}
Err(_) => DestinationClass::Malformed,
}
}
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
///
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
/// values already exist in older data.
pub fn validate_redirect_destination(destination: &str) -> bool {
classify_redirect_destination(destination).is_valid()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_crlf_destination() {
assert!(!validate_redirect_destination(
"https://example.com/\r\nX-Injected: 1"
));
assert!(!validate_redirect_destination(
"https://example.com/\nX-Injected: 1"
));
}
#[test]
fn rejects_non_http_schemes() {
assert!(!validate_redirect_destination("javascript:alert(1)"));
assert!(!validate_redirect_destination("data:text/html,hi"));
assert!(!validate_redirect_destination("/relative/path"));
}
#[test]
fn accepts_normal_https() {
assert!(validate_redirect_destination(
"https://example.com/path?q=1#frag"
));
assert!(validate_redirect_destination("http://localhost:8080/x"));
}
#[test]
fn classifies_destination_categories() {
assert_eq!(
classify_redirect_destination("https://ok.example/"),
DestinationClass::ValidHttps
);
assert_eq!(
classify_redirect_destination("http://ok.example/"),
DestinationClass::ValidHttp
);
assert_eq!(
classify_redirect_destination("javascript:alert(1)"),
DestinationClass::UnsupportedScheme
);
assert_eq!(
classify_redirect_destination("https://x/\r\nX:1"),
DestinationClass::ControlCharacters
);
assert_eq!(
classify_redirect_destination("not a url"),
DestinationClass::Malformed
);
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
}
}
-7065
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+236
View File
@@ -0,0 +1,236 @@
use super::*;
#[derive(Deserialize)]
pub struct CreateApiKeyForm {
pub key_name: String,
pub csrf_token: String,
}
// POST /admin/settings/api-keys/create
pub async fn create_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateApiKeyForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let key_secret = format!("bzo_{}", generate_token(16));
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
let conn = state.admin_db.lock().unwrap();
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
Ok(api_key) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_CREATED",
Some("api_key"),
Some(&api_key.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to(&format!(
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
key_secret
)).into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/settings/api-keys/revoke/:id
pub async fn revoke_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.admin_db.lock().unwrap();
match delete_api_key(&conn, &id) {
Ok(_) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_REVOKED",
Some("api_key"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=API Token revoked").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/settings?error=Failed to revoke key: {}",
e
))
.into_response(),
}
}
// GET /api-tokens
pub async fn api_tokens_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let tokens = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
)
.unwrap();
let rows = stmt
.query_map([user.id], |row| {
Ok(crate::models::UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ApiTokensTemplate {
admin_username: user.username.clone(),
username: user.username,
tokens,
new_token: params.get("new_token").cloned(),
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /api-tokens/create
pub async fn api_tokens_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
use sha2::Digest;
let raw_token = format!("key_{}", generate_token(32));
let mut hasher = sha2::Sha256::new();
hasher.update(raw_token.as_bytes());
let hashed_token = hex::encode(hasher.finalize());
{
let conn = state.users_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
rusqlite::params![user.id, hashed_token, now],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_CREATED",
"api_token",
"new",
None,
);
}
Redirect::to(&format!(
"/api-tokens?new_token={}&success=Token generated successfully",
raw_token
))
.into_response()
}
// POST /api-tokens/revoke/:id
pub async fn api_tokens_revoke_post(
State(state): State<AppState>,
jar: CookieJar,
Path(token_id): Path<i64>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
[token_id, user.id],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_REVOKED",
"api_token",
&token_id.to_string(),
None,
);
}
Redirect::to("/api-tokens?success=API token revoked").into_response()
}
+126
View File
@@ -0,0 +1,126 @@
use super::*;
// GET /admin/audit
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::AuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
// GET /user/audit
pub async fn user_audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events =
crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::UserAuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
+517
View File
@@ -0,0 +1,517 @@
use super::*;
// GET /admin
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
match require_auth(&state, &jar).await {
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
Err(redir) => redir.into_response(),
}
}
// GET /admin/login
pub async fn login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/admin/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/admin/login".to_string(),
title: "Admin Login".to_string(),
subtitle: "Administrative Access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
#[derive(Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
pub csrf_token: String,
}
/// Bootstrap is allowed only when the system has no real admin yet.
pub(crate) fn is_bootstrap_allowed(
user_count: i64,
admin_count: i64,
active_session_count: i64,
) -> bool {
user_count <= 1 && admin_count == 0 && active_session_count == 0
}
fn count_login_bootstrap_state(
conn: &rusqlite::Connection,
) -> Result<(i64, i64, i64), rusqlite::Error> {
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
let a_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|r| r.get(0),
)?;
let now = Utc::now().to_rfc3339();
let s_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
[now],
|r| r.get(0),
)?;
Ok((u_count, a_count, s_count))
}
/// Verify an existing admin tenant user may log into the admin UI.
///
/// Does not log the password. Rejection reasons are structured for observability.
pub(crate) fn verify_admin_credentials(
user: &crate::models::TenantUser,
password: &str,
) -> Result<(), &'static str> {
if user.status != "active" {
return Err("account_disabled");
}
if user.account_type != "admin" {
return Err("insufficient_privileges");
}
if !verify_password(password, &user.password_hash) {
return Err("invalid_credentials");
}
Ok(())
}
fn load_tenant_user_by_username(
conn: &rusqlite::Connection,
username: &str,
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
[username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
}
}
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
)
}
// POST /admin/login
pub async fn login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let bootstrap_allowed = {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match count_login_bootstrap_state(&conn) {
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
Err(e) => {
tracing::error!(error = %e, "login bootstrap state query failed");
false
}
}
};
let user_opt = if bootstrap_allowed
&& form.username == state.config.admin_username
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
{
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => {
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
}
};
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
Ok(u) => {
if let Err(e) = state.db.init_user_databases(u.id) {
tracing::error!(
"Failed to init user databases during admin bootstrap: {:?}",
e
);
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&u.username,
"BOOTSTRAP_USER_PROVISIONED",
"user",
&u.id.to_string(),
Some(&metadata),
);
}
Some(User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
})
}
Err(e) => {
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
None
}
}
} else {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match load_tenant_user_by_username(&conn, &form.username) {
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
Ok(()) => Some(tenant_user_to_admin_user(u)),
Err(reason) => {
tracing::warn!(username = form.username, reason, "login rejected");
None
}
},
Ok(None) => {
tracing::warn!(
username = form.username,
reason = "user_not_found",
"login rejected"
);
None
}
Err(e) => {
tracing::error!(error = %e, "login user lookup failed");
None
}
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
let now = Utc::now().to_rfc3339();
if let Err(e) = conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![session_token, user_id_i64, expires, now],
) {
tracing::error!(error = %e, "failed to insert admin session");
return Redirect::to("/admin/login?error=Internal error").into_response();
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/admin/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response()
}
None => {
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
}
Redirect::to("/admin/login?error=Invalid username or password").into_response()
}
}
}
// GET /logout
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
let cookie = Cookie::build("bzod_user_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/login")).into_response()
}
// GET /login
pub async fn public_login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/login".to_string(),
title: "User Login".to_string(),
subtitle: "Standard account access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
// POST /login
pub async fn public_login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let user_opt: Option<crate::models::TenantUser> = {
let conn = state.users_db.lock().unwrap();
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE username = ?1;",
[&form.username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
}
).optional();
match user_res {
Ok(Some(u)) => {
if u.status != "active" {
None
} else if verify_password(&form.password, &u.password_hash) {
Some(u)
} else {
None
}
}
_ => None,
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_user_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/user/dashboard")).into_response()
}
None => {
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
Redirect::to("/login?error=Invalid username or password").into_response()
}
}
}
// GET /admin/logout
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
}
let cookie = Cookie::build("bzod_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/admin/login")).into_response()
}
#[cfg(test)]
mod login_helpers_tests {
use super::is_bootstrap_allowed;
#[test]
fn bootstrap_only_when_no_admin() {
assert!(is_bootstrap_allowed(0, 0, 0));
assert!(is_bootstrap_allowed(1, 0, 0));
assert!(!is_bootstrap_allowed(2, 0, 0));
assert!(!is_bootstrap_allowed(1, 1, 0));
assert!(!is_bootstrap_allowed(1, 0, 1));
}
}
+300
View File
@@ -0,0 +1,300 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupFileRow {
pub filename: String,
pub size_str: String,
pub created_str: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupHistoryRow {
pub id: String,
pub backup_path: String,
pub status: String,
pub created_at: String,
pub size_bytes: i64,
pub error_message: Option<String>,
}
// GET /admin/backups
pub async fn backups_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut files = vec![];
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
for entry in dir_entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(filename) = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
{
if filename.ends_with(".tar.gz") {
let meta = entry.metadata().unwrap();
let size_str = format_size(meta.len());
let created_str = meta
.created()
.ok()
.map(|c| {
let datetime: chrono::DateTime<chrono::Utc> = c.into();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "-".to_string());
files.push(BackupFileRow {
filename,
size_str,
created_str,
});
}
}
}
}
}
files.sort_by(|a, b| b.filename.cmp(&a.filename));
let history = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(BackupHistoryRow {
id: row.get(0)?,
backup_path: row.get(1)?,
status: row.get(2)?,
created_at: row.get(3)?,
size_bytes: row.get(4)?,
error_message: row.get(5)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::BackupsTemplate {
admin_username: user.username,
files,
history,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/backups/create
pub async fn backups_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (_user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
Ok(path) => {
let filename = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("backup.tar.gz");
Redirect::to(&format!(
"/admin/backups?success=Backup created successfully: {}",
filename
))
.into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to generate backup: {}",
e
))
.into_response(),
}
}
// GET /admin/backups/download/:filename
pub async fn backups_download_get(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
) -> Response {
if require_auth(&state, &jar).await.is_err() {
return StatusCode::UNAUTHORIZED.into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return StatusCode::NOT_FOUND.into_response();
}
match std::fs::read(&backup_path) {
Ok(bytes) => {
let body = axum::body::Body::from(bytes);
Response::builder()
.header("content-type", "application/octet-stream")
.header(
"content-disposition",
format!("attachment; filename=\"{}\"", filename),
)
.body(body)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
// POST /admin/backups/delete/:filename
pub async fn backups_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
}
match std::fs::remove_file(&backup_path) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"BACKUP_DELETE",
"backup",
&filename,
None,
);
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to delete backup file: {}",
e
))
.into_response(),
}
}
// POST /admin/backups/restore
pub async fn backups_restore_post(
State(state): State<AppState>,
jar: CookieJar,
mut multipart: axum::extract::Multipart,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut backup_bytes = vec![];
let mut confirm_text = String::new();
let mut csrf_token = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or_default().to_string();
if name == "backup_file" {
if let Ok(bytes) = field.bytes().await {
backup_bytes = bytes.to_vec();
}
} else if name == "confirm_text" {
if let Ok(text) = field.text().await {
confirm_text = text.trim().to_string();
}
} else if name == "csrf_token" {
if let Ok(text) = field.text().await {
csrf_token = text.trim().to_string();
}
}
}
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if confirm_text != "RESTORE" {
return Redirect::to(
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
)
.into_response();
}
if backup_bytes.is_empty() {
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
.into_response();
}
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
return Redirect::to(&format!(
"/admin/backups?error=Failed to save uploaded file: {}",
e
))
.into_response();
}
match crate::cli::restore::run(
temp_file_path.to_string_lossy().to_string(),
None,
state.config.clone(),
)
.await
{
Ok(_) => {
let _ = std::fs::remove_file(&temp_file_path);
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions;", []);
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"RESTORE_EXECUTION",
"backup",
"upload",
None,
);
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
.into_response()
}
Err(e) => {
let _ = std::fs::remove_file(&temp_file_path);
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
}
}
}
+170
View File
@@ -0,0 +1,170 @@
use super::*;
// GET /user/dashboard
pub async fn user_dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (total_urls, active_links, dead_links) = {
let conn = user_dbs.content.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0))
};
let total_pages = {
let conn = user_dbs.content.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0)
};
let total_clicks = {
let conn = user_dbs.analytics.lock().unwrap();
get_total_clicks(&conn).unwrap_or(0)
};
let clicks_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_clicks_trend(&conn, "url", "all", 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
};
let mut trend_map = std::collections::BTreeMap::new();
for i in (0..30).rev() {
let date_str = (Utc::now() - chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string();
trend_map.insert(date_str, 0i64);
}
for (d, c) in clicks_data {
trend_map.insert(d, c);
}
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
let traffic_chart = generate_line_chart(&formatted_trend);
let countries_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "country", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
.unwrap_or_default()
};
let countries_chart = generate_bar_chart(&countries_data);
let referrers_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "referrer", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
.unwrap_or_default()
};
let referrers_chart = generate_bar_chart(&referrers_data);
let browsers_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "browser", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
.unwrap_or_default()
};
let browsers_chart = generate_bar_chart(&browsers_data);
let template = crate::templates::UserDashboardTemplate {
admin_username: user.username,
total_urls,
total_pages,
total_clicks,
active_links,
dead_links,
traffic_chart,
countries_chart,
browsers_chart,
referrers_chart,
};
template.into_response()
}
// GET /admin/dashboard
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0))
};
let total_pages = {
let conn = state.content_db.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0)
};
let total_clicks = {
let conn = state.analytics_db.lock().unwrap();
get_total_clicks(&conn).unwrap_or(0)
};
let clicks_data = {
let conn = state.analytics_db.lock().unwrap();
get_clicks_trend(&conn, "url", "all", 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
};
let mut trend_map = std::collections::BTreeMap::new();
for i in (0..30).rev() {
let date_str = (Utc::now() - chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string();
trend_map.insert(date_str, 0i64);
}
for (d, c) in clicks_data {
trend_map.insert(d, c);
}
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
let traffic_chart = generate_line_chart(&formatted_trend);
let countries_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "country", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
.unwrap_or_default()
};
let countries_chart = generate_bar_chart(&countries_data);
let referrers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "referrer", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
.unwrap_or_default()
};
let referrers_chart = generate_bar_chart(&referrers_data);
let browsers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "browser", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
.unwrap_or_default()
};
let browsers_chart = generate_bar_chart(&browsers_data);
let template = crate::templates::DashboardTemplate {
admin_username: user.username,
total_urls,
total_pages,
total_clicks,
active_links,
dead_links,
traffic_chart,
countries_chart,
browsers_chart,
referrers_chart,
};
template.into_response()
}
+169
View File
@@ -0,0 +1,169 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct JobHistoryRow {
pub id: String,
pub job_name: String,
pub status: String,
pub started_at: String,
pub finished_at: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct HealthCheckRow {
pub id: String,
pub object_type: String,
pub object_id: String,
pub checked_at: String,
pub status_code: Option<i64>,
pub error_message: Option<String>,
pub is_healthy: i64,
}
// GET /admin/health
pub async fn health_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut db_reports = vec![];
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin")
{
db_reports.push(r);
}
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system")
{
db_reports.push(r);
}
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users")
{
db_reports.push(r);
}
let system_db_path = state.config.data_dir.join("admin").join("system.db");
let users_db_path = state.config.data_dir.join("admin").join("users.db");
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
let system_db_size = format_size(
std::fs::metadata(&system_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let users_db_size = format_size(
std::fs::metadata(&users_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let admin_db_size = format_size(
std::fs::metadata(&admin_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let users_dir = state.config.data_dir.join("users");
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
let job_history = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(JobHistoryRow {
id: row.get(0)?,
job_name: row.get(1)?,
status: row.get(2)?,
started_at: row.get(3)?,
finished_at: row.get(4)?,
error_message: row.get(5)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let health_checks = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(HealthCheckRow {
id: row.get(0)?,
object_type: row.get(1)?,
object_id: row.get(2)?,
checked_at: row.get(3)?,
status_code: row.get(4)?,
error_message: row.get(5)?,
is_healthy: row.get(6)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let (registry_errors, registry_warnings) = {
let system_conn = state.system_db.lock().unwrap();
let users_conn = state.users_db.lock().unwrap();
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&state.config.data_dir,
None,
) {
Ok(issues) => {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
use crate::services::registry_validator::RegistryIssueType;
match issue.issue_type {
RegistryIssueType::StaleReservation
| RegistryIssueType::TenantAdminHasIsolatedContent => {
warnings.push(format!(
"Warning for slug {}: {}",
issue.slug, issue.description
));
}
_ => {
errors.push(format!(
"Error for slug {}: {}",
issue.slug, issue.description
));
}
}
}
(errors, warnings)
}
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
}
};
let template = crate::templates::HealthTemplate {
admin_username: user.username,
db_reports,
total_data_size,
system_db_size,
users_db_size,
admin_db_size,
tenants_db_size,
job_history,
health_checks,
registry_errors,
registry_warnings,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
+849
View File
@@ -0,0 +1,849 @@
//! Admin web UI handlers (feature-split modules).
//!
//! Handlers are organized by domain; shared auth, audit, export, and helpers
//! live in this module root so child modules can access them via `super`.
use crate::auth::{
authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token,
hash_password, verify_csrf, verify_password, verify_sha256,
};
use crate::charts::{generate_bar_chart, generate_line_chart};
use crate::db::admin::{
create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config,
write_audit_log as write_audit_log_legacy,
};
use crate::db::analytics::{
clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings,
get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors,
get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated,
get_total_clicks, get_visits_schema_columns, parse_ua,
};
use crate::db::content::{
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts,
list_landing_pages, list_urls,
};
use crate::models::User;
use crate::state::AppState;
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use rusqlite::{params, OptionalExtension};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::net::SocketAddr;
use tar::Builder;
use uuid::Uuid;
// --- Shared constants, auth, audit, and export helpers ---
#[allow(clippy::too_many_arguments)]
pub(crate) fn write_audit_log(
conn: &rusqlite::Connection,
state: &AppState,
username: &str,
action: &str,
object_type: Option<&str>,
object_id: Option<&str>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<crate::models::AuditLog> {
let res = write_audit_log_legacy(
conn,
username,
action,
object_type,
object_id,
ip_address,
user_agent,
);
// Also write to unified audit events in system.db
let system_conn = state.system_db.lock().unwrap();
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
username,
action,
object_type.unwrap_or(""),
object_id.unwrap_or(""),
Some(&metadata),
);
res
}
pub(crate) const PAGE_SIZE: usize = 25;
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
// Helper: Verify admin session and return user or redirect to login
pub(crate) async fn require_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(User, String), Redirect> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/admin/login")),
};
match authenticate_admin_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/admin/login")),
}
}
// Helper: Verify tenant user session and return user or redirect to login
pub(crate) async fn require_user_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(crate::models::TenantUser, String), Redirect> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/login")),
};
match authenticate_user_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/login")),
}
}
#[derive(Deserialize)]
pub struct AnalyticsQuery {
pub analytics_page: Option<usize>,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
pub(crate) fn validate_date_filters(
date_from: Option<&str>,
date_to: Option<&str>,
) -> Result<(Option<String>, Option<String>), StatusCode> {
let from_parsed = match date_from {
Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") {
Ok(d) => Some(d),
Err(_) => return Err(StatusCode::BAD_REQUEST),
},
_ => None,
};
let to_parsed = match date_to {
Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
Ok(d) => Some(d),
Err(_) => return Err(StatusCode::BAD_REQUEST),
},
_ => None,
};
if let (Some(f), Some(t)) = (from_parsed, to_parsed) {
if f > t {
return Err(StatusCode::BAD_REQUEST);
}
}
Ok((
from_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
to_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
))
}
pub(crate) fn escape_csv_field(field: &str) -> String {
let needs_escaping =
field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r');
if needs_escaping {
let escaped = field.replace('"', "\"\"");
format!("\"{}\"", escaped)
} else {
field.to_string()
}
}
pub(crate) struct DbExportStream {
receiver: tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::convert::Infallible>>,
}
impl futures_util::stream::Stream for DbExportStream {
type Item = Result<axum::body::Bytes, std::convert::Infallible>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.receiver.poll_recv(cx)
}
}
pub(crate) async fn perform_csv_export(
state: AppState,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = state.content_db.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = state.analytics_db.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
let (has_utm_source, has_utm_campaign) = {
let conn = state.analytics_db.lock().unwrap();
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
(cols.contains("utm_source"), cols.contains("utm_campaign"))
};
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
let analytics_db = state.analytics_db.clone();
let target_id = id.clone();
tokio::task::spawn_blocking(move || {
let conn = analytics_db.lock().unwrap();
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
if has_utm_source {
header.push_str(",UTM Source");
}
if has_utm_campaign {
header.push_str(",UTM Campaign");
}
header.push('\n');
if tx
.blocking_send(Ok(axum::body::Bytes::from(header)))
.is_err()
{
return;
}
let select_fields = if has_utm_source && has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
} else if has_utm_source {
"timestamp, ip_address, country, referer, user_agent, utm_source"
} else if has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
} else {
"timestamp, ip_address, country, referer, user_agent"
};
let mut sql = format!(
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
select_fields
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(target_type.to_string()), Box::new(target_id)];
if let Some(df) = clean_date_from.as_deref() {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = clean_date_to.as_deref() {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return,
};
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
Ok(r) => r,
Err(_) => return,
};
let mut csv_buffer = String::new();
while let Ok(Some(row)) = rows.next() {
let timestamp: String = row.get(0).unwrap_or_default();
let ip_address: String = row.get(1).unwrap_or_default();
let country: String = row.get(2).unwrap_or_default();
let referer: String = row.get(3).unwrap_or_default();
let user_agent: String = row.get(4).unwrap_or_default();
let (browser, _, _) = parse_ua(&user_agent);
let referrer = clean_referrer(&referer);
let country_display = if country.is_empty() {
"Unknown".to_string()
} else {
country
};
let mut line = format!(
"{},{},{},{},{},{}",
escape_csv_field(&timestamp),
escape_csv_field(&ip_address),
escape_csv_field(&country_display),
escape_csv_field(&referrer),
escape_csv_field(&browser),
escape_csv_field(&user_agent)
);
let mut col_idx = 5;
if has_utm_source {
let utm_src: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
col_idx += 1;
}
if has_utm_campaign {
let utm_camp: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
}
line.push('\n');
csv_buffer.push_str(&line);
if csv_buffer.len() >= 8192 {
let bytes = axum::body::Bytes::from(csv_buffer);
if tx.blocking_send(Ok(bytes)).is_err() {
return;
}
csv_buffer = String::new();
}
}
if !csv_buffer.is_empty() {
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
}
});
let stream = DbExportStream { receiver: rx };
let filename = if target_type == "url" {
format!("url_{}_analytics.csv", id)
} else {
format!("page_{}_analytics.csv", id)
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
axum::body::Body::from_stream(stream),
)
.into_response()
}
pub(crate) async fn perform_json_export(
state: AppState,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = state.content_db.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = state.analytics_db.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
if count > MAX_JSON_EXPORT_ROWS as i64 {
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
}
let visits_raw = {
let conn = state.analytics_db.lock().unwrap();
match get_target_visits_all_in_memory(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
) {
Ok(v) => v,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
#[derive(serde::Serialize)]
struct JsonExportRow {
timestamp: String,
ip_address: String,
country: String,
referrer: String,
browser: String,
user_agent: String,
}
let export_rows: Vec<JsonExportRow> = visits_raw
.into_iter()
.map(|r| {
let (browser, _, _) = parse_ua(&r.user_agent);
let referrer = clean_referrer(&r.referer);
let country_display = if r.country.is_empty() {
"Unknown".to_string()
} else {
r.country
};
JsonExportRow {
timestamp: r.timestamp,
ip_address: r.ip_address,
country: country_display,
referrer,
browser,
user_agent: r.user_agent,
}
})
.collect();
let body_str = match serde_json::to_string(&export_rows) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
}
};
let filename = if target_type == "url" {
format!("url_{}_analytics.json", id)
} else {
format!("page_{}_analytics.json", id)
};
(
StatusCode::OK,
[
("Content-Type", "application/json"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
body_str,
)
.into_response()
}
// Helpers
pub(crate) fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.2} KB", bytes as f64 / 1024.0)
} else {
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
let mut total = 0;
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
total += get_dir_size(&path)?;
} else {
total += entry.metadata()?.len();
}
}
}
Ok(total)
}
pub(crate) async fn perform_user_csv_export(
user_dbs: crate::state::UserDbs,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = user_dbs.content.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
let (has_utm_source, has_utm_campaign) = {
let conn = user_dbs.analytics.lock().unwrap();
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
(cols.contains("utm_source"), cols.contains("utm_campaign"))
};
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
let analytics_db = user_dbs.analytics.clone();
let target_id = id.clone();
tokio::task::spawn_blocking(move || {
let conn = analytics_db.lock().unwrap();
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
if has_utm_source {
header.push_str(",UTM Source");
}
if has_utm_campaign {
header.push_str(",UTM Campaign");
}
header.push('\n');
if tx
.blocking_send(Ok(axum::body::Bytes::from(header)))
.is_err()
{
return;
}
let select_fields = if has_utm_source && has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
} else if has_utm_source {
"timestamp, ip_address, country, referer, user_agent, utm_source"
} else if has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
} else {
"timestamp, ip_address, country, referer, user_agent"
};
let mut sql = format!(
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
select_fields
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(target_type.to_string()), Box::new(target_id)];
if let Some(df) = clean_date_from.as_deref() {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = clean_date_to.as_deref() {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return,
};
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
Ok(r) => r,
Err(_) => return,
};
let mut csv_buffer = String::new();
while let Ok(Some(row)) = rows.next() {
let timestamp: String = row.get(0).unwrap_or_default();
let ip_address: String = row.get(1).unwrap_or_default();
let country: String = row.get(2).unwrap_or_default();
let referer: String = row.get(3).unwrap_or_default();
let user_agent: String = row.get(4).unwrap_or_default();
let (browser, _, _) = parse_ua(&user_agent);
let referrer = clean_referrer(&referer);
let country_display = if country.is_empty() {
"Unknown".to_string()
} else {
country
};
let mut line = format!(
"{},{},{},{},{},{}",
escape_csv_field(&timestamp),
escape_csv_field(&ip_address),
escape_csv_field(&country_display),
escape_csv_field(&referrer),
escape_csv_field(&browser),
escape_csv_field(&user_agent)
);
let mut col_idx = 5;
if has_utm_source {
let utm_src: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
col_idx += 1;
}
if has_utm_campaign {
let utm_camp: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
}
line.push('\n');
csv_buffer.push_str(&line);
if csv_buffer.len() >= 8192 {
let bytes = axum::body::Bytes::from(csv_buffer);
if tx.blocking_send(Ok(bytes)).is_err() {
return;
}
csv_buffer = String::new();
}
}
if !csv_buffer.is_empty() {
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
}
});
let stream = DbExportStream { receiver: rx };
let filename = if target_type == "url" {
format!("url_{}_analytics.csv", id)
} else {
format!("page_{}_analytics.csv", id)
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
axum::body::Body::from_stream(stream),
)
.into_response()
}
pub(crate) async fn perform_user_json_export(
user_dbs: crate::state::UserDbs,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = user_dbs.content.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
if count > MAX_JSON_EXPORT_ROWS as i64 {
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
}
let visits_raw = {
let conn = user_dbs.analytics.lock().unwrap();
match get_target_visits_all_in_memory(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
) {
Ok(v) => v,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
#[derive(serde::Serialize)]
struct JsonExportRow {
timestamp: String,
ip_address: String,
country: String,
referrer: String,
browser: String,
user_agent: String,
}
let export_rows: Vec<JsonExportRow> = visits_raw
.into_iter()
.map(|r| {
let (browser, _, _) = parse_ua(&r.user_agent);
let referrer = clean_referrer(&r.referer);
let country_display = if r.country.is_empty() {
"Unknown".to_string()
} else {
r.country
};
JsonExportRow {
timestamp: r.timestamp,
ip_address: r.ip_address,
country: country_display,
referrer,
browser,
user_agent: r.user_agent,
}
})
.collect();
let body_str = match serde_json::to_string(&export_rows) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
}
};
let filename = if target_type == "url" {
format!("url_{}_analytics.json", id)
} else {
format!("page_{}_analytics.json", id)
};
(
StatusCode::OK,
[
("Content-Type", "application/json"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
body_str,
)
.into_response()
}
// --- Feature modules ---
mod auth;
pub use auth::*;
mod dashboard;
pub use dashboard::*;
mod urls;
pub use urls::*;
mod pages;
pub use pages::*;
mod users;
pub use users::*;
mod analytics;
pub use analytics::*;
mod settings;
pub use settings::*;
mod audit;
pub use audit::*;
mod sessions;
pub use sessions::*;
mod quotas;
pub use quotas::*;
mod health;
pub use health::*;
mod backups;
pub use backups::*;
mod api_keys;
pub use api_keys::*;
mod moderation;
pub use moderation::*;
+639
View File
@@ -0,0 +1,639 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GlobalSlugRow {
pub slug: String,
pub owner_user_id: i64,
pub target_type: String,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub deleted_at: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ModerationLogEntry {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugHistoryRow {
pub id: i64,
pub slug: String,
pub old_owner_user_id: Option<i64>,
pub new_owner_user_id: Option<i64>,
pub action: String,
pub timestamp: String,
pub admin_username: Option<String>,
}
// GET /admin/moderation
pub async fn moderation_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let flagged_items = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(GlobalSlugRow {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let logs = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(ModerationLogEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ModerationTemplate {
admin_username: user.username,
flagged_items,
logs,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminModerateForm {
pub slug: String,
pub action: String,
pub severity: String,
pub reason: String,
pub csrf_token: String,
}
// POST /admin/moderation
pub async fn moderation_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminModerateForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response();
}
let action = form.action.trim().to_lowercase();
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
}
let (owner_user_id, target_type) = {
let system_conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt {
Some(r) => r,
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
}
};
let owner_username = {
let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
.unwrap_or(None)
.map(|u| u.username)
};
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
if action == "deleted" {
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
} else {
let _ = system_conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![action, now, form.slug],
);
}
let event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
user.username,
owner_user_id,
owner_username,
target_type,
form.slug,
action,
form.severity,
form.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"CONTENT_MODERATION",
"slug",
&form.slug,
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
);
}
Redirect::to(&format!(
"/admin/moderation?success=Moderation action '{}' applied",
action
))
.into_response()
}
#[derive(Deserialize)]
pub struct SlugsQuery {
pub search: Option<String>,
pub owner: Option<i64>,
pub status: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
// GET /admin/slugs
pub async fn slugs_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<SlugsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let system_conn = state.system_db.lock().unwrap();
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
let mut values = vec![];
if let Some(ref s) = query.search {
if !s.trim().is_empty() {
sql.push_str(" AND slug LIKE ?");
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
}
}
if let Some(o) = query.owner {
sql.push_str(" AND owner_user_id = ?");
values.push(rusqlite::types::Value::Integer(o));
}
if let Some(ref st) = query.status {
if !st.trim().is_empty() {
sql.push_str(" AND status = ?");
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
}
}
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
let slugs = {
let mut stmt = system_conn.prepare(&sql).unwrap();
let rows = stmt
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
Ok(GlobalSlugRow {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let history = {
let mut stmt = system_conn.prepare(
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(SlugHistoryRow {
id: row.get(0)?,
slug: row.get(1)?,
old_owner_user_id: row.get(2)?,
new_owner_user_id: row.get(3)?,
action: row.get(4)?,
timestamp: row.get(5)?,
admin_username: row.get(6)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SlugsTemplate {
admin_username: user.username,
slugs,
history,
csrf_token,
search_filter: query.search,
owner_filter: query.owner,
status_filter: query.status,
success: query.success,
error: query.error,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminTransferForm {
pub slug: String,
pub new_owner_user_id: i64,
pub csrf_token: String,
}
// POST /admin/slugs/transfer
pub async fn slugs_transfer_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminTransferForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
let user_exists = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[form.new_owner_user_id],
|row| row.get::<_, bool>(0),
)
.unwrap_or(false)
};
if !user_exists {
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
}
let (old_owner, target_type, mut new_target_id) =
{
let conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String, String)> = conn.query_row(
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
).optional().unwrap_or(None);
match row_opt {
Some(r) => r,
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
}
};
if old_owner == form.new_owner_user_id {
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
.into_response();
}
let old_dbs = match state.get_user_dbs(old_owner) {
Ok(dbs) => dbs,
Err(_) => {
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
.into_response()
}
};
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
.into_response()
}
};
{
let old_conn = old_dbs.content.lock().unwrap();
let new_conn = new_dbs.content.lock().unwrap();
if target_type == "url" {
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
Ok(u) => u,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to retrieve old URL: {}",
e
))
.into_response()
}
};
if let Some(url) = url_opt {
// Check quota
let users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_urls >= quota.max_urls {
return Redirect::to(
"/admin/slugs?error=New owner has exceeded URL quota limit",
)
.into_response();
}
}
// Copy
let new_url = match crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
) {
Ok(u) => u,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to copy URL record: {}",
e
))
.into_response()
}
};
new_target_id = new_url.id;
// Delete old
let _ = crate::db::content::delete_url(&old_conn, &url.id);
}
} else if target_type == "page" {
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
{
Ok(p) => p,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to retrieve old page: {}",
e
))
.into_response()
}
};
if let Some(page) = page_opt {
// Check quota
let users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_landings >= quota.max_landings {
return Redirect::to(
"/admin/slugs?error=New owner has exceeded landing page quota limit",
)
.into_response();
}
}
// Copy
let new_page = match crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
) {
Ok(p) => p,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to copy page record: {}",
e
))
.into_response()
}
};
new_target_id = new_page.id;
// Delete old
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
}
}
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
);
let _ = conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_TRANSFER",
"slug",
&form.slug,
Some(&format!(
"Transferred from {} to {}",
old_owner, form.new_owner_user_id
)),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
form.slug, form.new_owner_user_id
))
.into_response()
}
#[derive(Deserialize)]
pub struct AdminSlugStatusForm {
pub slug: String,
pub status: String,
pub csrf_token: String,
}
// POST /admin/slugs/status
pub async fn slugs_status_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugStatusForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
let status = form.status.trim().to_lowercase();
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![status, now, form.slug],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_STATUS_UPDATE",
"slug",
&form.slug,
Some(&format!("Status set to {}", status)),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} status updated to {}",
form.slug, status
))
.into_response()
}
#[derive(Deserialize)]
pub struct AdminSlugDeleteForm {
pub slug: String,
pub csrf_token: String,
}
// POST /admin/slugs/delete
pub async fn slugs_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugDeleteForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let old_owner_user_id: Option<i64> = conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| row.get(0),
)
.optional()
.unwrap_or(None);
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
let _ = conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_RELEASE",
"slug",
&form.slug,
Some("Slug released/deleted from global index"),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} released successfully",
form.slug
))
.into_response()
}
+484
View File
@@ -0,0 +1,484 @@
use super::*;
#[derive(Deserialize)]
pub struct UserPagesQuery {
pub error: Option<String>,
pub page: Option<usize>,
}
#[derive(Deserialize)]
pub struct CreateUserPageForm {
pub title: String,
pub slug: String,
pub code: String,
pub custom_slug: String,
pub state: String,
pub html_content: String,
pub csrf_token: String,
}
pub async fn user_pages_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UserPagesQuery>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (pages, total_pages, page, visible_pages) = {
let conn = user_dbs.content.lock().unwrap();
let total_records = get_landing_page_count(&conn).unwrap_or(0);
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(pages, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserPagesTemplate {
admin_username: user.username.clone(),
username: user.username,
pages,
csrf_token,
error: query.error,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
pub async fn user_pages_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUserPageForm>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
}
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(2);
} else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters")
.into_response();
}
} else if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to(
"/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
)
.into_response();
}
let clean_slug = form.slug.trim().to_lowercase();
if clean_slug.is_empty() {
return Redirect::to("/user/pages?error=Slug is required").into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) {
return Redirect::to("/user/pages?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/user/pages?error=Short code/slug already exists")
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
user.id,
"page",
"",
"reserving",
) {
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = user_dbs.content.lock().unwrap();
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if form.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],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings");
}
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/pages").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
}
}
}
pub async fn user_pages_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
}
let conn = user_dbs.content.lock().unwrap();
match get_landing_page_by_id(&conn, &id) {
Ok(Some(page)) => {
let _ = crate::db::users::decrement_quota_counter(
&state.users_db.lock().unwrap(),
user.id,
"landings",
);
match delete_landing_page(&conn, &id) {
Ok(_) => {
let _ = crate::db::users::release_global_slug(
&state.system_db.lock().unwrap(),
&page.code,
user.id,
);
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_PAGE_DELETION",
Some("page"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/pages").into_response()
}
Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
_ => Redirect::to("/user/pages?error=Page not found").into_response(),
}
}
// GET /admin/pages
#[derive(Deserialize)]
pub struct PagesQuery {
pub error: Option<String>,
pub page: Option<usize>,
}
pub async fn pages_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<PagesQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (pages, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap();
let total_records = get_landing_page_count(&conn).unwrap_or(0);
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(pages, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::PagesTemplate {
admin_username: user.username,
pages,
csrf_token,
error: query.error,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreatePageForm {
pub title: String,
pub slug: String,
pub code: String,
pub custom_slug: String,
pub state: String,
pub html_content: String,
pub csrf_token: String,
}
// POST /admin/pages/create
pub async fn pages_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreatePageForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(2);
} else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/pages?error=Custom code must be exactly 4 hex characters",
)
.into_response();
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let clean_slug = form.slug.trim().to_lowercase();
if clean_slug.is_empty() {
return Redirect::to("/admin/pages?error=Slug is required").into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
.unwrap_or(false)
{
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
{
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if form.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],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
admin_user_id,
"landings",
);
}
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/pages/delete/:id
pub async fn pages_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap();
match delete_landing_page(&conn, &id) {
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_DELETION",
Some("page"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
+117
View File
@@ -0,0 +1,117 @@
use super::*;
// GET /admin/quotas
pub async fn quotas_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let quotas = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(crate::models::UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::QuotasTemplate {
admin_username: user.username,
quotas,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/quotas
pub async fn quotas_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response();
}
let action = form.get("action").cloned().unwrap_or_default();
let users_conn = state.users_db.lock().unwrap();
if action == "reconcile_all" {
let user_ids: Vec<i64> = {
let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap();
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
for uid in user_ids {
if let Ok(user_dbs) = state.get_user_dbs(uid) {
let user_content_conn = user_dbs.content.lock().unwrap();
let _ =
crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
}
}
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"QUOTAS_RECONCILE_ALL",
"quota",
"all",
None,
);
Redirect::to("/admin/quotas?success=All user quotas reconciled successfully")
.into_response()
} else if action == "reconcile" {
let uid_str = form.get("user_id").cloned().unwrap_or_default();
let uid = uid_str.parse::<i64>().unwrap_or(0);
if let Ok(user_dbs) = state.get_user_dbs(uid) {
let user_content_conn = user_dbs.content.lock().unwrap();
let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"QUOTAS_RECONCILE",
"quota",
&uid.to_string(),
None,
);
Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response()
} else {
Redirect::to("/admin/quotas?error=User databases not found").into_response()
}
} else {
Redirect::to("/admin/quotas?error=Invalid action").into_response()
}
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
// GET /admin/sessions
pub async fn sessions_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let sessions = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(crate::models::UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SessionsTemplate {
admin_username: user.username,
sessions,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/sessions/revoke/:id
pub async fn sessions_revoke_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"SESSION_REVOKED",
"session",
&id,
None,
);
}
Redirect::to("/admin/sessions?success=Session revoked").into_response()
}
// POST /admin/sessions/revoke-all
pub async fn sessions_revoke_all_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions;", []);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"SESSIONS_ALL_REVOKED",
"session",
"all",
None,
);
}
Redirect::to("/admin/sessions?success=All active sessions revoked").into_response()
}
File diff suppressed because it is too large. Load diff
+639
View File
@@ -0,0 +1,639 @@
use super::*;
#[derive(Deserialize)]
pub struct UserUrlsQuery {
pub tag: Option<String>,
pub error: Option<String>,
pub page: Option<usize>,
}
#[derive(Deserialize)]
pub struct CreateUserUrlForm {
pub destination: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub custom_slug: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: String,
pub csrf_token: String,
#[serde(default)]
pub expires_at: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub max_access_count: String,
#[serde(default)]
pub utm_source: String,
#[serde(default)]
pub utm_medium: String,
#[serde(default)]
pub utm_campaign: String,
}
pub async fn user_urls_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UserUrlsQuery>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (urls, total_pages, page, visible_pages) = {
let conn = user_dbs.content.lock().unwrap();
let total_records = if let Some(tag_str) = query.tag.as_deref() {
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
} else {
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
};
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
.unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(urls, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
let template = crate::templates::UserUrlsTemplate {
admin_username: user.username.clone(),
username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
base_url,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
pub async fn user_urls_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUserUrlForm>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
}
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let ip = get_client_ip(&headers, connect_info);
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters")
.into_response();
}
} else if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to(
"/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
)
.into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) {
return Redirect::to("/user/urls?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
user.id,
"url",
"",
"reserving",
) {
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let dest = match crate::services::urls::prepare_destination(
&form.destination,
crate::services::urls::UtmParams {
source: Some(&form.utm_source),
medium: Some(&form.utm_medium),
campaign: Some(&form.utm_campaign),
},
) {
Ok(d) => d,
Err(msg) => {
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
}
};
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
let res = {
let conn = user_dbs.content.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
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],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls");
}
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/urls").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
}
}
}
pub async fn user_urls_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
}
let conn = user_dbs.content.lock().unwrap();
match get_url_by_id(&conn, &id) {
Ok(Some(url)) => {
let _ = crate::db::users::decrement_quota_counter(
&state.users_db.lock().unwrap(),
user.id,
"urls",
);
match delete_url(&conn, &id) {
Ok(_) => {
let _ = crate::db::users::release_global_slug(
&state.system_db.lock().unwrap(),
&url.code,
user.id,
);
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_URL_DELETION",
Some("url"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/urls").into_response()
}
Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e))
.into_response(),
}
}
_ => Redirect::to("/user/urls?error=Link not found").into_response(),
}
}
// GET /admin/urls
#[derive(Deserialize)]
pub struct UrlsQuery {
pub tag: Option<String>,
pub error: Option<String>,
pub page: Option<usize>,
}
pub async fn urls_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UrlsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (urls, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap();
let total_records = if let Some(tag_str) = query.tag.as_deref() {
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
} else {
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
};
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
.unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(urls, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
let template = crate::templates::UrlsTemplate {
admin_username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
base_url,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreateUrlForm {
pub destination: String,
pub code: String,
pub custom_slug: String,
pub title: String,
pub description: String,
pub tags: String,
pub csrf_token: String,
pub expires_at: String,
pub password: String,
pub max_access_count: String,
pub utm_source: String,
pub utm_medium: String,
pub utm_campaign: String,
}
// POST /admin/urls/create
pub async fn urls_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUrlForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/urls?error=Custom code must be exactly 6 hex characters",
)
.into_response();
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let dest = match crate::services::urls::prepare_destination(
&form.destination,
crate::services::urls::UtmParams {
source: Some(&form.utm_source),
medium: Some(&form.utm_medium),
campaign: Some(&form.utm_campaign),
},
) {
Ok(d) => d,
Err(msg) => {
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
}
};
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
{
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/urls?error=Short code/slug already exists")
.into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
{
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
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],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
}
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/urls/delete/:id
pub async fn urls_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap();
match delete_url(&conn, &id) {
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"URL_DELETION",
Some("url"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
}
}
}
+704
View File
@@ -0,0 +1,704 @@
use super::*;
#[derive(Deserialize)]
pub struct UsersQuery {
pub success: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct CreateUserForm {
pub username: String,
pub password: String,
pub account_type: String,
pub metadata: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UpdateUserStatusForm {
pub status: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UpdateUserTypeForm {
pub account_type: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ResetPasswordForm {
pub new_password: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct DeleteUserForm {
pub csrf_token: String,
}
pub async fn users_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UsersQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let users = {
let conn = state.users_db.lock().unwrap();
crate::db::users::list_users(&conn).unwrap_or_default()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UsersTemplate {
admin_username: user.username,
users,
csrf_token,
success: query.success,
error: query.error,
};
template.into_response()
}
pub async fn users_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<CreateUserForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let username = form.username.trim().to_lowercase();
if username.len() < 3 {
return Redirect::to("/admin/users?error=Username must be at least 3 characters")
.into_response();
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Redirect::to(
"/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores",
)
.into_response();
}
if form.password.trim().len() < 8 {
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
.into_response();
}
let account_type = if form.account_type.trim().is_empty() {
"standard"
} else {
form.account_type.trim()
};
let metadata = if form.metadata.trim().is_empty() {
None
} else {
Some(form.metadata.trim())
};
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
};
let new_user = {
let conn = state.users_db.lock().unwrap();
match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) {
Ok(u) => u,
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
return Redirect::to("/admin/users?error=Username already exists").into_response();
}
Err(e) => {
return Redirect::to(&format!("/admin/users?error=Database error: {}", e))
.into_response();
}
}
};
if let Err(e) = state.db.init_user_databases(new_user.id) {
return Redirect::to(&format!(
"/admin/users?error=Failed to initialize user databases: {}",
e
))
.into_response();
}
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"USER_CREATION",
Some("user"),
Some(&new_user.id.to_string()),
None,
None,
);
}
Redirect::to("/admin/users?success=User created successfully").into_response()
}
pub async fn users_update_status_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UpdateUserStatusForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let status = form.status.trim().to_lowercase();
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
return Redirect::to("/admin/users?error=Invalid user status").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_status(&conn, id, &status) {
Ok(_) => {
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_STATUS_UPDATE",
Some("user"),
Some(&id.to_string()),
None,
None,
);
Redirect::to("/admin/users?success=User status updated").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to update status: {}",
e
))
.into_response(),
}
}
pub async fn users_update_type_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UpdateUserTypeForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let account_type = form.account_type.trim().to_lowercase();
if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str())
{
return Redirect::to("/admin/users?error=Invalid account type").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_account_type(&conn, id, &account_type) {
Ok(_) => {
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_ACCOUNT_TYPE_UPDATE",
Some("user"),
Some(&id.to_string()),
None,
None,
);
Redirect::to("/admin/users?success=User account type updated").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to update account type: {}",
e
))
.into_response(),
}
}
pub async fn users_reset_password_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<i64>,
Form(form): Form<ResetPasswordForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
if form.new_password.trim().len() < 8 {
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
.into_response();
}
let hash = match hash_password(&form.new_password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&conn, id, &hash) {
Ok(_) => {
let ip = get_client_ip(&headers, connect_info);
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_PASSWORD_RESET",
Some("user"),
Some(&id.to_string()),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/users?success=Password reset successfully").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to reset password: {}",
e
))
.into_response(),
}
}
pub async fn users_delete_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<i64>,
Form(form): Form<DeleteUserForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) {
Ok(_) => {
let ip = get_client_ip(&headers, connect_info);
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_DELETION",
Some("user"),
Some(&id.to_string()),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/users?success=User deleted successfully").into_response()
}
Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(),
}
}
// ---------------------------------------------------------------------------
// GA Hardening UI Handlers and Structs
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct UserDetailStats {
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub current_urls: i64,
pub current_landings: i64,
pub current_api_tokens: i64,
pub current_storage_mb: i64,
pub url_pct: i64,
pub landing_pct: i64,
pub token_pct: i64,
pub storage_pct: i64,
pub total_visits: i64,
}
pub fn get_user_detail_stats(
state: &AppState,
user_id: i64,
) -> Result<UserDetailStats, Box<dyn std::error::Error>> {
use rusqlite::OptionalExtension;
let quotas = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
FROM quotas WHERE user_id = ?1;",
[user_id],
|row| Ok(crate::models::UserQuotas {
user_id,
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
current_urls: row.get(4)?,
current_landings: row.get(5)?,
current_api_tokens: row.get(6)?,
current_storage_mb: row.get(7)?,
})
).optional()?
};
let quotas = quotas.unwrap_or(crate::models::UserQuotas {
user_id,
max_urls: 100,
max_landings: 10,
max_api_tokens: 5,
max_storage_mb: 100,
current_urls: 0,
current_landings: 0,
current_api_tokens: 0,
current_storage_mb: 0,
});
let total_visits = {
if let Ok(dbs) = state.get_user_dbs(user_id) {
let conn = dbs.analytics.lock().unwrap();
conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
row.get::<_, i64>(0)
})
.unwrap_or(0)
} else {
0
}
};
let url_pct = if quotas.max_urls > 0 {
(quotas.current_urls * 100) / quotas.max_urls
} else {
0
};
let landing_pct = if quotas.max_landings > 0 {
(quotas.current_landings * 100) / quotas.max_landings
} else {
0
};
let token_pct = if quotas.max_api_tokens > 0 {
(quotas.current_api_tokens * 100) / quotas.max_api_tokens
} else {
0
};
let storage_pct = if quotas.max_storage_mb > 0 {
(quotas.current_storage_mb * 100) / quotas.max_storage_mb
} else {
0
};
Ok(UserDetailStats {
max_urls: quotas.max_urls,
max_landings: quotas.max_landings,
max_api_tokens: quotas.max_api_tokens,
max_storage_mb: quotas.max_storage_mb,
current_urls: quotas.current_urls,
current_landings: quotas.current_landings,
current_api_tokens: quotas.current_api_tokens,
current_storage_mb: quotas.current_storage_mb,
url_pct,
landing_pct,
token_pct,
storage_pct,
total_visits,
})
}
// GET /admin/users/new
pub async fn users_new_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UsersNewTemplate {
admin_username: user.username,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// GET /admin/users/:id
pub async fn user_detail_get(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let target_user = {
let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE id = ?1;",
[id],
|row| Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
);
match u_res {
Ok(u) => u,
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
}
};
let stats = match get_user_detail_stats(&state, id) {
Ok(s) => s,
Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(),
};
let sessions = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;")
.unwrap();
let rows = stmt
.query_map([id], |row| {
Ok(crate::models::UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let tokens = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
)
.unwrap();
let rows = stmt
.query_map([id], |row| {
Ok(crate::models::UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserDetailTemplate {
admin_username: user.username,
target_user,
stats,
sessions,
tokens,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// GET /admin/users/:id/edit
pub async fn user_edit_get(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let target_user = {
let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE id = ?1;",
[id],
|row| Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
);
match u_res {
Ok(u) => u,
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
}
};
let quotas = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
FROM quotas WHERE user_id = ?1;",
[id],
|row| Ok(crate::models::UserQuotas {
user_id: id,
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
current_urls: row.get(4)?,
current_landings: row.get(5)?,
current_api_tokens: row.get(6)?,
current_storage_mb: row.get(7)?,
})
).unwrap_or(crate::models::UserQuotas {
user_id: id,
max_urls: 100,
max_landings: 10,
max_api_tokens: 5,
max_storage_mb: 100,
current_urls: 0,
current_landings: 0,
current_api_tokens: 0,
current_storage_mb: 0,
})
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserEditTemplate {
admin_username: user.username,
target_user,
quotas,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
#[derive(Deserialize)]
pub struct UserEditForm {
pub account_type: String,
pub metadata: String,
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub csrf_token: String,
}
// POST /admin/users/:id/edit
pub async fn user_edit_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UserEditForm>,
) -> Response {
let (_user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to(&format!(
"/admin/users/{}/edit?error=Invalid CSRF token",
id
))
.into_response();
}
let conn = state.users_db.lock().unwrap();
let _ = conn.execute(
"UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;",
rusqlite::params![form.account_type, form.metadata, id],
);
let _ = conn.execute(
"INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \
VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(user_id) DO UPDATE SET \
max_urls = excluded.max_urls, \
max_landings = excluded.max_landings, \
max_api_tokens = excluded.max_api_tokens, \
max_storage_mb = excluded.max_storage_mb;",
rusqlite::params![
id,
form.max_urls,
form.max_landings,
form.max_api_tokens,
form.max_storage_mb
],
);
Redirect::to(&format!(
"/admin/users/{}?success=User updated successfully",
id
))
.into_response()
}
+25 -27
View File
@@ -99,34 +99,23 @@ pub async fn api_create_url(
} }
} }
let mut dest = payload.destination.trim().to_string(); let dest = match crate::services::urls::prepare_destination(
if let Ok(mut parsed) = reqwest::Url::parse(&dest) { &payload.destination,
let mut has_utm = false; crate::services::urls::UtmParams {
{ source: payload.utm_source.as_deref(),
let mut query = parsed.query_pairs_mut(); medium: payload.utm_medium.as_deref(),
if let Some(ref src) = payload.utm_source { campaign: payload.utm_campaign.as_deref(),
if !src.trim().is_empty() { },
query.append_pair("utm_source", src.trim()); ) {
has_utm = true; Ok(d) => d,
} Err(msg) => {
} return (
if let Some(ref med) = payload.utm_medium { StatusCode::BAD_REQUEST,
if !med.trim().is_empty() { Json(serde_json::json!({ "error": msg })),
query.append_pair("utm_medium", med.trim()); )
has_utm = true; .into_response();
}
}
if let Some(ref camp) = payload.utm_campaign {
if !camp.trim().is_empty() {
query.append_pair("utm_campaign", camp.trim());
has_utm = true;
}
}
} }
if has_utm { };
dest = parsed.to_string();
}
}
let password_hash = if let Some(ref pwd) = payload.password { let password_hash = if let Some(ref pwd) = payload.password {
if pwd.is_empty() { if pwd.is_empty() {
@@ -353,6 +342,15 @@ pub async fn api_update_url(
Json(payload): Json<UpdateUrlRequest>, Json(payload): Json<UpdateUrlRequest>,
) -> Response { ) -> Response {
let tags = payload.tags.unwrap_or_default(); let tags = payload.tags.unwrap_or_default();
if !crate::utils::validation::validate_redirect_destination(&payload.destination) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Destination must be a valid http(s) URL without control characters"
})),
)
.into_response();
}
let conn = state.content_db.lock().unwrap(); let conn = state.content_db.lock().unwrap();
match update_url( match update_url(
&conn, &conn,
+54 -208
View File
@@ -1,8 +1,9 @@
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::auth::ApiUser; use crate::auth::ApiUser;
use crate::services::bulk_urls::{
create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError,
};
use crate::state::AppState; use crate::state::AppState;
use crate::utils::get_client_ip; use crate::utils::{get_client_ip, lock_db};
use axum::{ use axum::{
extract::{ConnectInfo, State}, extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
@@ -68,7 +69,18 @@ pub async fn api_bulk_qr(
// Retrieve URLs from database // Retrieve URLs from database
let mut urls = Vec::new(); let mut urls = Vec::new();
{ {
let conn = state.content_db.lock().unwrap(); let conn = match lock_db(&state.content_db, "content_db") {
Ok(c) => c,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: e.to_string(),
}),
)
.into_response();
}
};
for id in &payload.ids { for id in &payload.ids {
match crate::db::content::get_url_by_id(&conn, id) { match crate::db::content::get_url_by_id(&conn, id) {
Ok(Some(url)) => urls.push(url), Ok(Some(url)) => urls.push(url),
@@ -115,9 +127,8 @@ pub async fn api_bulk_qr(
// Generate ZIP // Generate ZIP
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) { match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
Ok(zip_data) => { Ok(zip_data) => {
// Write Audit Log // Write Audit Log (best-effort; do not fail the download on audit lock poison)
{ if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
user.0.username(), user.0.username(),
@@ -147,6 +158,16 @@ pub async fn api_bulk_qr(
} }
} }
fn bulk_url_error_response(err: BulkUrlError) -> Response {
let (status, msg) = match &err {
BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
};
(status, Json(BulkErrorResponse { error: msg })).into_response()
}
// POST /api/v1/bulk/url // POST /api/v1/bulk/url
pub async fn api_bulk_url( pub async fn api_bulk_url(
State(state): State<AppState>, State(state): State<AppState>,
@@ -185,214 +206,39 @@ pub async fn api_bulk_url(
} }
}; };
// Check quota if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) {
{ return bulk_url_error_response(e);
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 items: Vec<BulkUrlCreateItem> = payload
let tx = match conn.transaction() { .into_iter()
Ok(t) => t, .map(|item| BulkUrlCreateItem {
Err(e) => { destination: item.destination,
return ( code: item.code,
StatusCode::INTERNAL_SERVER_ERROR, title: item.title,
Json(BulkErrorResponse { description: item.description,
error: format!("Failed to start database transaction: {}", e), tags: item.tags,
}), expires_at: item.expires_at,
) password: item.password,
.into_response() max_access_count: item.max_access_count,
} })
.collect();
let created_urls = match create_urls_bulk(
&content_db,
&state.system_db,
&state.users_db,
target_user_id,
items,
) {
Ok(urls) => urls,
Err(e) => return bulk_url_error_response(e),
}; };
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3); // 6 hex
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
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 (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: format!("Short code '{}' must be 6 hex characters", code),
}),
)
.into_response();
}
}
// 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 {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
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 (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Password hashing error: {}", e),
}),
)
.into_response();
}
}
} else {
None
};
let tags = item.tags.unwrap_or_default();
match crate::db::content::create_url_extended(
&tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
) {
Ok(url) => created_urls.push(url),
Err(e) => {
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 (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database insert error: {}", e),
}),
)
.into_response();
}
}
}
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 (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to commit transaction: {}", e),
}),
)
.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());
{ if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
let system_conn = state.db.system.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event( let _ = crate::db::audit_events::write_audit_event(
&system_conn, &system_conn,
user.0.username(), user.0.username(),
+3 -1
View File
@@ -26,6 +26,7 @@ pub async fn gate_post(
State(state): State<AppState>, State(state): State<AppState>,
Path(code): Path<String>, Path(code): Path<String>,
jar: CookieJar, jar: CookieJar,
headers: axum::http::HeaderMap,
Form(form): Form<PasswordGateForm>, Form(form): Form<PasswordGateForm>,
) -> Response { ) -> Response {
let url_opt = match get_url_by_code(&state.db, &code) { let url_opt = match get_url_by_code(&state.db, &code) {
@@ -55,8 +56,9 @@ pub async fn gate_post(
if verify_password(&form.password, password_hash) { if verify_password(&form.password, password_hash) {
// Correct password - set 15 min temporary cookie // Correct password - set 15 min temporary cookie
let cookie_name = format!("bzod_gate_{}", code); let cookie_name = format!("bzod_gate_{}", code);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build((cookie_name, "authorized")) let cookie = Cookie::build((cookie_name, "authorized"))
.secure(state.config.cookie_secure) .secure(secure_flag)
.same_site(axum_extra::extract::cookie::SameSite::Strict) .same_site(axum_extra::extract::cookie::SameSite::Strict)
.http_only(true) .http_only(true)
.path("/") .path("/")
+380 -85
View File
@@ -1,20 +1,298 @@
//! Public short-code redirect hot path.
//!
//! Design notes:
//! - Destination `Location` headers never panic on malformed values.
//! - Content DB work uses a single mutex acquisition where safe.
//! - Expiration is enforced on the read path; persistent `expired=1` is left to
//! the background expiry job (`jobs::expiry`), not written here.
//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved.
//! - Analytics enqueue remains non-blocking (`try_send` via the queue).
use axum::{ use axum::{
extract::{ConnectInfo, Path, State}, extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode}, http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response}, response::{IntoResponse, Redirect, Response},
}; };
use axum_extra::extract::CookieJar; use axum_extra::extract::CookieJar;
use chrono::Utc; use chrono::Utc;
use rusqlite::OptionalExtension; use rusqlite::OptionalExtension;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tracing::{error, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::analytics::get_client_country; use crate::analytics::get_client_country;
use crate::models::VisitRecord; use crate::models::{LinkPreview, Url, VisitRecord};
use crate::state::AppState; use crate::state::AppState;
use crate::templates::PreviewTemplate; use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip; use crate::utils::get_client_ip;
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
enum ResolveOutcome {
Ready(Box<ResolvedUrl>),
/// Early HTTP response that does not need further processing.
Early {
status: StatusCode,
body: &'static str,
},
/// Permanent redirect to a relative path (e.g. page target → `/p/{code}`).
PermanentPath(String),
DbError {
operation: &'static str,
message: String,
owner_user_id: Option<i64>,
resource_id: Option<String>,
},
}
/// Safe client-facing DB error after structured server-side logging.
fn db_error_response(
operation: &str,
code: &str,
owner_user_id: Option<i64>,
resource_id: Option<&str>,
err: impl std::fmt::Display,
) -> Response {
error!(
operation = operation,
code = code,
owner_user_id = owner_user_id,
resource_id = resource_id.unwrap_or(""),
error = %err,
"redirect path database error"
);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
/// Build a permanent redirect without panicking on invalid destinations.
///
/// Defense in depth:
/// 1. Canonical destination rules (http/https, no control chars) — same as writes.
/// 2. `HeaderValue` construction — rejects remaining illegal header bytes.
///
/// Neither step may panic. Full destination values are not logged.
fn permanent_redirect_to(destination: &str, code: &str) -> Response {
if !crate::utils::validation::validate_redirect_destination(destination) {
warn!(
operation = "validate_redirect_destination",
code = code,
destination_len = destination.len(),
"invalid stored redirect destination rejected"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid redirect destination",
)
.into_response();
}
match HeaderValue::from_str(destination) {
Ok(loc) => {
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(header::LOCATION, loc);
resp
}
Err(err) => {
warn!(
operation = "build_location_header",
code = code,
error = %err,
// Do not log the full destination if it may contain control chars;
// log length only for forensics.
destination_len = destination.len(),
"invalid redirect destination rejected (possible response-splitting attempt)"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid redirect destination",
)
.into_response()
}
}
}
/// Result of the initial global-slug + URL resolution phase.
struct ResolvedUrl {
owner_user_id: i64,
url: Url,
content: Arc<Mutex<rusqlite::Connection>>,
}
/// Lookup global slug namespace then load the URL from the tenant content DB.
/// Runs entirely on a blocking thread.
fn resolve_url_blocking(
system_db: Arc<Mutex<rusqlite::Connection>>,
state: AppState,
code: &str,
) -> ResolveOutcome {
// 1. Query global slug namespace in system.db
let slug_info = {
let system_conn = match system_db.lock() {
Ok(c) => c,
Err(e) => {
return ResolveOutcome::DbError {
operation: "lock_system_db",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
};
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(e) => {
return ResolveOutcome::DbError {
operation: "prepare_global_slugs",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
};
match stmt
.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
{
Ok(info) => info,
Err(e) => {
return ResolveOutcome::DbError {
operation: "query_global_slugs",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
}
};
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Some(info) => info,
None => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "url".to_string(), "".to_string(), "active".to_string())
}
};
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return ResolveOutcome::Early {
status: StatusCode::GONE,
body: "This content has been disabled or moderated",
};
}
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
}
// 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(e) => {
return ResolveOutcome::DbError {
operation: "get_user_dbs",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
};
let url = {
let conn = match content_conn.content.lock() {
Ok(c) => c,
Err(e) => {
return ResolveOutcome::DbError {
operation: "lock_content_db",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
};
match crate::db::content::get_url_by_code(&conn, code) {
Ok(Some(url)) => url,
Ok(None) => {
return ResolveOutcome::Early {
status: StatusCode::NOT_FOUND,
body: "Short code not found",
};
}
Err(e) => {
return ResolveOutcome::DbError {
operation: "get_url_by_code",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
}
};
ResolveOutcome::Ready(Box::new(ResolvedUrl {
owner_user_id,
url,
content: content_conn.content,
}))
}
/// Increment access count and load preview under a single content-DB lock.
fn increment_and_preview_blocking(
content: Arc<Mutex<rusqlite::Connection>>,
url_id: &str,
code: &str,
owner_user_id: i64,
fallback_access_count: i64,
) -> Result<(i64, Option<LinkPreview>), String> {
let conn = content
.lock()
.map_err(|e| format!("lock_content_db_hot: {}", e))?;
let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) {
Ok(n) => n,
Err(e) => {
// Preserve prior soft-failure semantics for the counter value used only
// internally; never silence the underlying error.
error!(
operation = "increment_access_count",
code = code,
owner_user_id = owner_user_id,
resource_id = url_id,
error = %e,
"failed to increment access count; continuing with estimated value"
);
fallback_access_count + 1
}
};
let preview = match crate::db::preview::get_preview(&conn, url_id) {
Ok(p) => p,
Err(e) => {
error!(
operation = "get_preview",
code = code,
owner_user_id = owner_user_id,
resource_id = url_id,
error = %e,
"failed to load link preview; continuing without preview"
);
None
}
};
Ok((new_access_count, preview))
}
// GET /:code // GET /:code
// Resolve and redirect // Resolve and redirect
pub async fn resolve_redirect( pub async fn resolve_redirect(
@@ -31,69 +309,51 @@ pub async fn resolve_redirect(
return (StatusCode::NOT_FOUND, "Not Found").into_response(); return (StatusCode::NOT_FOUND, "Not Found").into_response();
} }
// 1. Query global slug namespace in system.db let system_db = state.system_db.clone();
let slug_info = { let state_for_lookup = state.clone();
let system_conn = state.system_db.lock().unwrap(); let code_for_lookup = code.clone();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
) {
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
stmt.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
};
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info { let outcome = match tokio::task::spawn_blocking(move || {
Ok(Some(info)) => info, resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup)
Ok(None) => { })
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs .await
(1, "url".to_string(), "".to_string(), "active".to_string()) {
} Ok(outcome) => outcome,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(), Err(e) => {
}; return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string());
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return (
StatusCode::GONE,
"This content has been disabled or moderated",
)
.into_response();
}
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return Redirect::permanent(&format!("/p/{}", code)).into_response();
}
// 2. Get content database connection via tenant DB resolution
let content_conn = 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 conn = content_conn.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &code) {
Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
} }
}; };
let url = match url_opt { let resolved = match outcome {
Some(u) => u, ResolveOutcome::Ready(r) => r,
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(), ResolveOutcome::Early { status, body } => return (status, body).into_response(),
ResolveOutcome::PermanentPath(path) => {
return Redirect::permanent(&path).into_response();
}
ResolveOutcome::DbError {
operation,
message,
owner_user_id,
resource_id,
} => {
return db_error_response(
operation,
&code,
owner_user_id,
resource_id.as_deref(),
message,
);
}
}; };
// 3. Expiration check let ResolvedUrl {
owner_user_id,
url,
content,
} = *resolved;
// 3. Expiration check (read-only on the hot path).
// Background job `jobs::expiry::run_expiry_checker` persists expired=1.
if url.expired { if url.expired {
return (StatusCode::GONE, "This link has expired").into_response(); return (StatusCode::GONE, "This link has expired").into_response();
} }
@@ -101,14 +361,6 @@ pub async fn resolve_redirect(
if let Some(ref expires_at_str) = url.expires_at { if let Some(ref expires_at_str) = url.expires_at {
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) { if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
if expires_at.with_timezone(&Utc) < Utc::now() { if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately
{
let conn = content_conn.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
);
}
return (StatusCode::GONE, "This link has expired").into_response(); return (StatusCode::GONE, "This link has expired").into_response();
} }
} }
@@ -136,15 +388,40 @@ pub async fn resolve_redirect(
} }
} }
// 6. Increment access count & retrieve preview config // 6. Increment access count & retrieve preview config (single content lock, off executor)
let _new_access_count = { let url_id = url.id.clone();
let conn = content_conn.lock().unwrap(); let code_for_hot = code.clone();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1) let fallback_access_count = url.access_count;
}; let preview_opt = match tokio::task::spawn_blocking(move || {
increment_and_preview_blocking(
let preview_opt = { content,
let conn = content_conn.lock().unwrap(); &url_id,
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None) &code_for_hot,
owner_user_id,
fallback_access_count,
)
})
.await
{
Ok(Ok((_new_access_count, preview))) => preview,
Ok(Err(msg)) => {
return db_error_response(
"increment_and_preview",
&code,
Some(owner_user_id),
Some(&url.id),
msg,
);
}
Err(e) => {
return db_error_response(
"spawn_blocking_hot",
&code,
Some(owner_user_id),
Some(&url.id),
e.to_string(),
);
}
}; };
// Asynchronously record analytics // Asynchronously record analytics
@@ -195,14 +472,32 @@ pub async fn resolve_redirect(
} }
.into_response() .into_response()
} else { } else {
{ permanent_redirect_to(&url.destination, &code)
use axum::http::{header, HeaderValue}; }
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response(); }
resp.headers_mut().insert(
header::LOCATION, #[cfg(test)]
HeaderValue::from_str(&url.destination).unwrap(), mod tests {
); use super::*;
resp
} #[test]
fn permanent_redirect_rejects_crlf() {
let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(resp.headers().get(header::LOCATION).is_none());
}
#[test]
fn permanent_redirect_rejects_control_chars() {
let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn permanent_redirect_accepts_valid_url() {
let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123");
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
let loc = resp.headers().get(header::LOCATION).unwrap();
assert_eq!(loc, "https://example.com/path?q=1");
} }
} }
+3 -1
View File
@@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
fn build_state(config: Config) -> (Db, AppState) { fn build_state(config: Config) -> (Db, AppState) {
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),
+3 -1
View File
@@ -44,7 +44,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir); let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+3 -1
View File
@@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String {
fn build_state(config: Config) -> (Db, bzod::state::AppState) { fn build_state(config: Config) -> (Db, bzod::state::AppState) {
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = bzod::state::AppState { let state = bzod::state::AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),
+3 -1
View File
@@ -49,7 +49,9 @@ async fn start_test_server(
) { ) {
let config = create_temp_config(temp_dir); let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 10); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+7 -2
View File
@@ -23,7 +23,10 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
config.backup_dir = temp_dir.clone(); config.backup_dir = temp_dir.clone();
config.admin_username = "admin".to_string(); config.admin_username = "admin".to_string();
config.base_url = Some("http://localhost:8080".to_string()); config.base_url = Some("http://localhost:8080".to_string());
config.cookie_secure = false; // Disable secure flag for testing over HTTP loopback // We leave config.cookie_secure as default (true).
// The new resolve_cookie_secure logic will automatically drop Secure
// for HTTP requests over the 127.0.0.1 loopback during this test,
// proving the local development fix works end-to-end.
config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret"); config.bootstrap_password_sha256 = compute_sha256("bootstrap-secret");
config config
} }
@@ -45,7 +48,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) { ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir); let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+632
View File
@@ -0,0 +1,632 @@
//! Tests for legacy_flat_backup restore compatibility and current backup roundtrip.
//!
//! These tests use a synthetic fixture that reproduces the exact structure of
//! a real legacy_flat_backup archive:
//! - admin.db with a users table (TEXT UUID PK, username, password_hash)
//! - users.db that is completely empty (no tables, user_version=0)
//! - system.db with global_slugs referencing multiple owner_user_ids
//! - content.db with URLs and landing pages
//! - analytics.db with visits
//! - backup_manifest.json with type "legacy_flat_backup"
//! - Orphaned slug entries (in global_slugs but not in content.db)
//! - A missing tenant (owner_user_id=3 whose databases are not included)
use bzod::config::Config;
use bzod::db::Db;
use flate2::write::GzEncoder;
use flate2::Compression;
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tar::Builder;
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
}
/// Build a synthetic legacy_flat_backup .tar.gz archive that reproduces the
/// exact structure of the real production backup that fails with:
/// "Failed to verify registry integrity in backup: no such table: users"
///
/// IMPORTANT: This uses purely synthetic data — no real credentials, URLs,
/// audit logs, or analytics from the production backup are included.
fn build_synthetic_legacy_fixture(output_path: &std::path::Path) {
use chrono::Utc;
let fixture_dir =
std::env::temp_dir().join(format!("bzod_fixture_build_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&fixture_dir).unwrap();
let now = Utc::now().to_rfc3339();
// --- admin.db: legacy admin schema with TEXT UUID primary key ---
{
let conn = Connection::open(fixture_dir.join("admin.db")).unwrap();
conn.execute_batch(
"CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE api_keys (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
created_at TEXT NOT NULL,
last_used_at TEXT
);
CREATE TABLE audit_logs (
id TEXT PRIMARY KEY,
timestamp TEXT NOT NULL,
username TEXT NOT NULL,
action TEXT NOT NULL,
object_type TEXT,
object_id TEXT,
ip_address TEXT,
user_agent TEXT
);
CREATE TABLE config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)
.unwrap();
// Insert a synthetic admin with a known argon2id hash
// (this is NOT a real password hash — it's a valid format placeholder)
let admin_hash =
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
"admin",
admin_hash,
&now
],
)
.unwrap();
// Insert an audit log entry
conn.execute(
"INSERT INTO audit_logs (id, timestamp, username, action) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params!["audit-1", &now, "admin", "LOGIN"],
)
.unwrap();
// Set user_version to 1 (matching legacy migration state)
conn.execute_batch("PRAGMA user_version = 1;").unwrap();
}
// --- system.db: has global_slugs with multiple owners + orphaned entries ---
{
let mut conn = Connection::open(fixture_dir.join("system.db")).unwrap();
// Run system migrations to get the full schema
bzod::db::migrations::run_migrations(
&mut conn,
"system",
bzod::db::migrations::SYSTEM_MIGRATIONS,
None,
)
.unwrap();
// Insert global_slugs owned by user_id=1 (content exists)
for (slug, target_type, target_id) in &[
("abc123", "url", "url-id-1"),
("def456", "url", "url-id-2"),
("!custom-slug", "url", "url-id-3"),
("!test-page", "page", "page-id-1"),
("!meeting", "page", "page-id-2"),
] {
conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, 1, ?2, ?3, ?4, ?5, 'active');",
rusqlite::params![slug, target_type, target_id, &now, &now],
)
.unwrap();
}
// Insert global_slugs owned by user_id=3 (tenant NOT included in flat backup)
for (slug, target_type, target_id) in &[
("xyz789", "url", "user3-url-1"),
("!user3-page", "page", "user3-page-1"),
] {
conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, 3, ?2, ?3, ?4, ?5, 'active');",
rusqlite::params![slug, target_type, target_id, &now, &now],
)
.unwrap();
}
// Insert an orphaned slug (user_id=1, content doesn't exist)
conn.execute(
"INSERT INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES ('orphan-slug', 1, 'url', 'nonexistent-id', ?1, ?2, 'active');",
rusqlite::params![&now, &now],
)
.unwrap();
}
// --- users.db: completely empty (no schema, user_version=0) ---
{
let _conn = Connection::open(fixture_dir.join("users.db")).unwrap();
// Intentionally empty — this is the root cause of the original bug
}
// --- content.db: URLs and landing pages belonging to user_id=1 ---
{
let mut conn = Connection::open(fixture_dir.join("content.db")).unwrap();
bzod::db::migrations::run_migrations(
&mut conn,
"content",
bzod::db::migrations::CONTENT_MIGRATIONS,
None,
)
.unwrap();
// Insert URLs
for (id, code, dest) in &[
("url-id-1", "abc123", "https://example.com/1"),
("url-id-2", "def456", "https://example.com/2"),
("url-id-3", "!custom-slug", "https://example.com/3"),
] {
conn.execute(
"INSERT INTO urls (id, code, destination, status, created_at, updated_at)
VALUES (?1, ?2, ?3, 'active', ?4, ?5);",
rusqlite::params![id, code, dest, &now, &now],
)
.unwrap();
}
// Insert landing pages
for (id, code, title) in &[
("page-id-1", "!test-page", "Test Page"),
("page-id-2", "!meeting", "Meeting Notes"),
] {
conn.execute(
"INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at)
VALUES (?1, ?2, ?2, ?3, '<h1>Test</h1>', 'published', ?4, ?5);",
rusqlite::params![id, code, title, &now, &now],
)
.unwrap();
}
}
// --- analytics.db: visits ---
{
let mut conn = Connection::open(fixture_dir.join("analytics.db")).unwrap();
bzod::db::migrations::run_migrations(
&mut conn,
"analytics",
bzod::db::migrations::ANALYTICS_MIGRATIONS,
None,
)
.unwrap();
// Insert some visits
for i in 0..10 {
conn.execute(
"INSERT INTO visits (id, target_type, target_id, timestamp, owner_user_id, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, 'url', 'url-id-1', ?2, 1, ?3, ?4, ?5, ?6, ?7, ?8);",
rusqlite::params![format!("visit-{}", i), &now, "127.0.0.1", "test-agent", "", "en-US", "US", 200],
)
.unwrap();
}
}
// --- backup_manifest.json ---
let manifest = serde_json::json!({
"created_at": &now,
"type": "legacy_flat_backup",
"files_included": ["admin.db", "system.db", "users.db", "content.db", "analytics.db"],
"note": "Multi-tenant databases flattened for backward compatibility.",
});
fs::write(
fixture_dir.join("backup_manifest.json"),
manifest.to_string(),
)
.unwrap();
// --- Package into .tar.gz ---
let tar_file = fs::File::create(output_path).unwrap();
let enc = GzEncoder::new(tar_file, Compression::default());
let mut tar = Builder::new(enc);
for name in &[
"admin.db",
"system.db",
"users.db",
"content.db",
"analytics.db",
"backup_manifest.json",
] {
tar.append_path_with_name(fixture_dir.join(name), name)
.unwrap();
}
tar.into_inner().unwrap().finish().unwrap();
let _ = fs::remove_dir_all(&fixture_dir);
}
// ==========================================================================
// Test 1: Legacy flat backup restores without "no such table" error
// ==========================================================================
#[test]
fn test_legacy_flat_backup_restore() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_legacy_restore_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
build_synthetic_legacy_fixture(&fixture_path);
let restore_dir = temp_dir.join("restored_data");
fs::create_dir_all(&restore_dir).unwrap();
// This must succeed — previously it failed with "no such table: users"
let result = bzod::cli::restore::perform_restore(&fixture_path, &restore_dir);
assert!(
result.is_ok(),
"Legacy flat backup restore failed: {:?}",
result.err()
);
// Verify multi-tenant directory structure
assert!(
restore_dir.join("admin/admin.db").exists(),
"admin/admin.db missing"
);
assert!(
restore_dir.join("admin/system.db").exists(),
"admin/system.db missing"
);
assert!(
restore_dir.join("admin/users.db").exists(),
"admin/users.db missing"
);
assert!(
restore_dir.join("users/1/content.db").exists(),
"users/1/content.db missing"
);
assert!(
restore_dir.join("users/1/analytics.db").exists(),
"users/1/analytics.db missing"
);
let _ = fs::remove_dir_all(&temp_dir);
}
// ==========================================================================
// Test 2: Admin credentials are preserved, not manufactured
// ==========================================================================
#[test]
fn test_legacy_restore_preserves_admin_credentials() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_legacy_creds_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
build_synthetic_legacy_fixture(&fixture_path);
let restore_dir = temp_dir.join("restored_data");
fs::create_dir_all(&restore_dir).unwrap();
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
let expected_hash =
"$argon2id$v=19$m=19456,t=2,p=1$dGVzdHNhbHQ$syntheticHashForTestingOnly00000000000000";
// Verify original admin identity in admin.db is untouched
{
let conn = Connection::open(restore_dir.join("admin/admin.db")).unwrap();
let (username, hash): (String, String) = conn
.query_row(
"SELECT username, password_hash FROM users WHERE id = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee';",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.unwrap();
assert_eq!(username, "admin");
assert_eq!(hash, expected_hash, "Admin password hash was modified!");
}
// Verify users.db was bootstrapped with actual admin credentials
{
let conn = Connection::open(restore_dir.join("admin/users.db")).unwrap();
// legacy_admin system placeholder should exist with id=1
let (la_username, la_hash, la_type): (String, String, String) = conn
.query_row(
"SELECT username, password_hash, account_type FROM users WHERE id = 1;",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(la_username, "legacy_admin");
assert_eq!(
la_hash, expected_hash,
"legacy_admin hash should match original admin"
);
assert_eq!(la_type, "system");
// Actual admin account should exist with original credentials
let (admin_hash, admin_type, admin_status): (String, String, String) = conn
.query_row(
"SELECT password_hash, account_type, status FROM users WHERE username = 'admin';",
[],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
)
.unwrap();
assert_eq!(
admin_hash, expected_hash,
"Admin account hash should match original"
);
assert_eq!(admin_type, "admin");
assert_eq!(admin_status, "active");
}
let _ = fs::remove_dir_all(&temp_dir);
}
// ==========================================================================
// Test 3: Functional data is preserved and accessible after restore + Db::init()
// ==========================================================================
#[tokio::test]
async fn test_legacy_restore_functional_data() {
let temp_dir = std::env::temp_dir().join(format!(
"bzod_test_legacy_functional_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&temp_dir).unwrap();
let fixture_path = temp_dir.join("legacy-backup.tar.gz");
build_synthetic_legacy_fixture(&fixture_path);
let restore_dir = temp_dir.join("restored_data");
fs::create_dir_all(&restore_dir).unwrap();
bzod::cli::restore::perform_restore(&fixture_path, &restore_dir).unwrap();
// Initialize Db against the restored data (simulates fresh v0.6.0 startup)
let config = create_temp_config(restore_dir.clone());
let db = Db::init(&config).expect("Db::init failed on restored legacy data");
// Verify URLs
{
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
let url_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
.unwrap();
assert_eq!(url_count, 3, "Expected 3 URLs in restored content.db");
let url = bzod::db::content::get_url_by_code(&content_conn, "abc123")
.unwrap()
.unwrap();
assert_eq!(url.destination, "https://example.com/1");
}
// Verify landing pages
{
let content_conn = bzod::jobs::open_user_content_conn(&db, 1).unwrap();
let page_count: i64 = content_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
.unwrap();
assert_eq!(
page_count, 2,
"Expected 2 landing pages in restored content.db"
);
}
// Verify analytics
{
let analytics_conn = bzod::jobs::open_user_analytics_conn(&db, 1).unwrap();
let visit_count: i64 = analytics_conn
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
.unwrap();
assert_eq!(
visit_count, 10,
"Expected 10 visits in restored analytics.db"
);
}
// Verify global slug registry
{
let system_conn = db.system.lock().unwrap();
let slug_count: i64 = system_conn
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))
.unwrap();
assert!(
slug_count >= 7,
"Expected at least 7 global_slugs (5 user1 + 2 user3 + orphan)"
);
}
// Verify user 3 placeholder exists
{
let users_conn = db.users.lock().unwrap();
let user3_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = 3);",
[],
|r| r.get(0),
)
.unwrap();
assert!(
user3_exists,
"Placeholder for user_id=3 should exist in users.db"
);
let (status, metadata): (String, Option<String>) = users_conn
.query_row(
"SELECT status, metadata FROM users WHERE id = 3;",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(status, "disabled", "User 3 placeholder should be disabled");
assert!(
metadata.as_deref().unwrap_or("").contains("Placeholder"),
"User 3 metadata should document it as a placeholder"
);
}
// Verify admin identity in admin.db
{
let admin_conn = db.admin.lock().unwrap();
let admin_exists: bool = admin_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = 'admin');",
[],
|r| r.get(0),
)
.unwrap();
assert!(
admin_exists,
"Original admin identity must be preserved in admin.db"
);
}
let _ = fs::remove_dir_all(&temp_dir);
}
// ==========================================================================
// Test 4: Current/native backup restore roundtrip
// ==========================================================================
#[tokio::test]
async fn test_current_backup_restore_roundtrip() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_current_rt_{}", 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");
// Create a user and add content
let _ = bzod::cli::create_user::run(
Some("testuser".to_string()),
Some("password123".to_string()),
None,
config.clone(),
)
.await
.unwrap();
let user_id = {
let conn = db.users.lock().unwrap();
bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap()
.id
};
{
let user_content_conn = bzod::jobs::open_user_content_conn(&db, user_id).unwrap();
bzod::db::content::create_url_extended(
&user_content_conn,
"!current-test",
"https://example.com/current",
None,
None,
&vec![],
None,
None,
None,
)
.unwrap();
let system_conn = db.system.lock().unwrap();
bzod::db::users::register_global_slug(
&system_conn,
"!current-test",
user_id,
"url",
"current-id",
"active",
)
.unwrap();
}
// Create a native backup
let backup_path = bzod::jobs::backup::perform_backup(&db, &config)
.await
.unwrap();
// Restore to a fresh directory
let restore_dir = temp_dir.join("restored_native");
fs::create_dir_all(&restore_dir).unwrap();
bzod::cli::restore::perform_restore(std::path::Path::new(&backup_path), &restore_dir).unwrap();
// Verify restored data
let restore_config = create_temp_config(restore_dir.clone());
let restored_db = Db::init(&restore_config).expect("Db::init failed on restored native data");
{
let conn = restored_db.users.lock().unwrap();
let user = bzod::db::users::get_user_by_username(&conn, "testuser")
.unwrap()
.unwrap();
assert_eq!(user.status, "active");
}
{
let content_conn = bzod::jobs::open_user_content_conn(&restored_db, user_id).unwrap();
let url = bzod::db::content::get_url_by_code(&content_conn, "!current-test")
.unwrap()
.unwrap();
assert_eq!(url.destination, "https://example.com/current");
}
let _ = fs::remove_dir_all(&temp_dir);
}
// ==========================================================================
// Test 5: Failed restore does not corrupt existing data
// ==========================================================================
#[tokio::test]
async fn test_failed_restore_does_not_corrupt() {
let temp_dir =
std::env::temp_dir().join(format!("bzod_test_safe_restore_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
// Set up a working installation
let _db = Db::init(&config).expect("Failed to init Db");
// Write a sentinel file to verify the data dir survives
let sentinel = config.data_dir.join("admin").join("sentinel.txt");
fs::write(&sentinel, "intact").unwrap();
// Create a corrupt "backup" file
let corrupt_path = temp_dir.join("corrupt.tar.gz");
fs::write(&corrupt_path, b"this is not a valid tar.gz file").unwrap();
// Attempt restore — must fail
let result = bzod::cli::restore::perform_restore(&corrupt_path, &config.data_dir);
assert!(result.is_err(), "Corrupt backup should fail to restore");
// Verify original data is intact
assert!(
sentinel.exists(),
"Sentinel file must survive failed restore"
);
assert_eq!(
fs::read_to_string(&sentinel).unwrap(),
"intact",
"Sentinel file content must be unchanged"
);
let _ = fs::remove_dir_all(&temp_dir);
}
+3 -1
View File
@@ -44,7 +44,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir); let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+3 -1
View File
@@ -33,7 +33,9 @@ async fn start_test_server(
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) { ) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
let config = create_temp_config(temp_dir); let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 100); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+743
View File
@@ -0,0 +1,743 @@
//! Redirect security & behavioral regression tests (Phase 2).
//!
//! Covers: 301, legacy malicious destinations, expiration, access limits,
//! password gate ordering, previews, tenant slug isolation, concurrent access.
use sha2::{Digest, Sha256};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use tokio::net::TcpListener;
use tokio::sync::Barrier;
use bzod::analytics::AnalyticsQueue;
use bzod::auth::password::hash_password;
use bzod::config::Config;
use bzod::db::Db;
use bzod::services::destination_audit::{
audit_all_destinations, audit_content_conn, DestinationAuditReport,
};
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, Db, tokio::task::JoinHandle<()>) {
let config = create_temp_config(temp_dir);
let db = Db::init(&config).expect("Failed to init Db");
let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
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, db, handle)
}
struct SeedUrl<'a> {
owner_user_id: i64,
code: &'a str,
destination: &'a str,
expired: bool,
expires_at: Option<&'a str>,
password_hash: Option<&'a str>,
max_access_count: Option<i64>,
access_count: i64,
}
fn seed_url(db: &Db, seed: SeedUrl<'_>) {
// Ensure user content DB exists
db.init_user_databases(seed.owner_user_id)
.expect("init user dbs");
{
let system = db.system.lock().unwrap();
let _ = bzod::db::users::register_global_slug(
&system,
seed.code,
seed.owner_user_id,
"url",
"seed",
"active",
);
}
let content_path = db
.data_dir
.join("users")
.join(seed.owner_user_id.to_string())
.join("content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at, expires_at, expired, password_hash, max_access_count, access_count)
VALUES (?1, ?2, ?3, NULL, NULL, 'healthy', ?4, ?4, ?5, ?6, ?7, ?8, ?9);",
rusqlite::params![
id,
seed.code,
seed.destination,
now,
seed.expires_at,
if seed.expired { 1 } else { 0 },
seed.password_hash,
seed.max_access_count,
seed.access_count,
],
)
.unwrap();
}
#[tokio::test]
async fn valid_destination_returns_301() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_301_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "a1b2c3",
destination: "https://example.com/target",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/a1b2c3", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res.headers().get("location").unwrap().to_str().unwrap(),
"https://example.com/target"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn legacy_crlf_destination_fails_closed_no_location() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_crlf_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// Simulate legacy DB row that bypassed modern write validation.
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "dead01",
destination: "https://evil.example/\r\nX-Injected: yes",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/dead01", base)).send().await.unwrap();
// Must not panic; fail closed without Location header.
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn javascript_scheme_legacy_fails_closed() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_js_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab1111",
destination: "javascript:alert(1)",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab1111", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::INTERNAL_SERVER_ERROR);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn expired_flag_returns_410_without_redirect() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab2222",
destination: "https://example.com/gone",
expired: true,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab2222", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
assert!(res.headers().get("location").is_none());
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn wall_clock_expiry_returns_410_without_hot_path_write_dependency() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_exp2_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// expired=0 but expires_at in the past → still 410 (read-path authoritative).
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab3333",
destination: "https://example.com/gone2",
expired: false,
expires_at: Some("2000-01-01T00:00:00Z"),
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab3333", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
// Column may still be 0 until sweeper runs — correctness does not require write.
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let expired_flag: i64 = conn
.query_row("SELECT expired FROM urls WHERE code = 'ab3333';", [], |r| {
r.get(0)
})
.unwrap();
// Either 0 (hot path no write) or 1 is acceptable if something else flipped it;
// the important assertion is 410 above. Prefer documenting no write:
assert!(
expired_flag == 0 || expired_flag == 1,
"unexpected expired flag {}",
expired_flag
);
// Phase 2 guarantee: no hot-path write required — if still 0, sweeper is maintenance only.
assert_eq!(
expired_flag, 0,
"redirect hot path must not persist expired=1"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn access_limit_exhausted_returns_410() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_lim_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab4444",
destination: "https://example.com/limited",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: Some(2),
access_count: 2, // already exhausted
},
);
let res = client.get(format!("{}/ab4444", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn password_gate_before_access_increment() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_pw_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
let hash = hash_password("secret-pass").unwrap();
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab5555",
destination: "https://example.com/secret",
expired: false,
expires_at: None,
password_hash: Some(&hash),
max_access_count: None,
access_count: 0,
},
);
let res = client.get(format!("{}/ab5555", base)).send().await.unwrap();
assert!(res.status().is_redirection());
let loc = res.headers().get("location").unwrap().to_str().unwrap();
assert!(loc.contains("/gate/ab5555"));
// Access count must not have incremented
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab5555';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 0);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn concurrent_redirects_increment_access_count() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_conc_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab6666",
destination: "https://example.com/concurrent",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
const N: usize = 20;
let barrier = Arc::new(Barrier::new(N));
let mut handles = Vec::new();
for _ in 0..N {
let client = client.clone();
let url = format!("{}/ab6666", base);
let b = barrier.clone();
handles.push(tokio::spawn(async move {
b.wait().await;
client.get(&url).send().await.unwrap()
}));
}
let mut ok_301 = 0;
for h in handles {
let res = h.await.unwrap();
if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY {
ok_301 += 1;
}
}
assert_eq!(ok_301, N);
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab6666';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(
count, N as i64,
"each successful redirect should increment access_count once"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn tenant_slug_resolves_owner_not_cross_tenant_content() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_ten_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
// Create two users
let config = create_temp_config(temp_dir.clone());
let _ = bzod::cli::create_user::run(
Some("alice".into()),
Some("password123".into()),
None,
config.clone(),
)
.await;
let _ = bzod::cli::create_user::run(
Some("bob".into()),
Some("password123".into()),
None,
config.clone(),
)
.await;
let (alice_id, bob_id) = {
let conn = db.users.lock().unwrap();
let a = bzod::db::users::get_user_by_username(&conn, "alice")
.unwrap()
.unwrap()
.id;
let b = bzod::db::users::get_user_by_username(&conn, "bob")
.unwrap()
.unwrap()
.id;
(a, b)
};
seed_url(
&db,
SeedUrl {
owner_user_id: alice_id,
code: "!alice1",
destination: "https://alice.example/ok",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: bob_id,
code: "!bob001",
destination: "https://bob.example/ok",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let res_a = client
.get(format!("{}/!alice1", base))
.send()
.await
.unwrap();
assert_eq!(res_a.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res_a.headers().get("location").unwrap().to_str().unwrap(),
"https://alice.example/ok"
);
let res_b = client
.get(format!("{}/!bob001", base))
.send()
.await
.unwrap();
assert_eq!(res_b.status(), reqwest::StatusCode::MOVED_PERMANENTLY);
assert_eq!(
res_b.headers().get("location").unwrap().to_str().unwrap(),
"https://bob.example/ok"
);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn destination_audit_finds_legacy_invalid_without_rewriting() {
let temp_dir = std::env::temp_dir().join(format!("bzod_audit_dest_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).unwrap();
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab9999",
destination: "https://example.com/good",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "bad001",
destination: "https://evil/\r\nX:1",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "bad002",
destination: "javascript:alert(1)",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
let report = audit_all_destinations(&db).unwrap();
assert_eq!(report.total_urls, 3);
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 2);
assert_eq!(report.control_characters, 1);
assert_eq!(report.unsupported_scheme, 1);
// Data not rewritten
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let dest: String = conn
.query_row(
"SELECT destination FROM urls WHERE code = 'bad001';",
[],
|r| r.get(0),
)
.unwrap();
assert!(dest.contains('\r'));
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn disabled_slug_returns_410() {
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_dis_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab7777",
destination: "https://example.com/x",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
{
let system = db.system.lock().unwrap();
system
.execute(
"UPDATE global_slugs SET status = 'disabled' WHERE slug = 'ab7777';",
[],
)
.unwrap();
}
let res = client.get(format!("{}/ab7777", base)).send().await.unwrap();
assert_eq!(res.status(), reqwest::StatusCode::GONE);
let _ = fs::remove_dir_all(&temp_dir);
}
#[tokio::test]
async fn redirect_performance_smoke() {
// Not a CI gate for absolute latency — documents methodology and asserts
// correctness under concurrent load (error rate = 0, access counts match).
let temp_dir = std::env::temp_dir().join(format!("bzod_redir_perf_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&temp_dir).unwrap();
let (client, base, db, _h) = start_test_server(temp_dir.clone()).await;
seed_url(
&db,
SeedUrl {
owner_user_id: 1,
code: "ab8888",
destination: "https://example.com/perf",
expired: false,
expires_at: None,
password_hash: None,
max_access_count: None,
access_count: 0,
},
);
// Warm-up
for _ in 0..10 {
let _ = client.get(format!("{}/ab8888", base)).send().await.unwrap();
}
// Reset access count after warm-up for clean correctness check
{
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
conn.execute(
"UPDATE urls SET access_count = 0 WHERE code = 'ab8888';",
[],
)
.unwrap();
}
const REQUESTS: usize = 200;
const CONCURRENCY: usize = 20;
let mut latencies_ms: Vec<f64> = Vec::with_capacity(REQUESTS);
let mut errors = 0usize;
let mut remaining = REQUESTS;
while remaining > 0 {
let batch = remaining.min(CONCURRENCY);
let mut handles = Vec::with_capacity(batch);
for _ in 0..batch {
let client = client.clone();
let url = format!("{}/ab8888", base);
handles.push(tokio::spawn(async move {
let start = Instant::now();
let res = client.get(&url).send().await;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
(res, elapsed)
}));
}
for h in handles {
match h.await.unwrap() {
(Ok(res), ms) if res.status() == reqwest::StatusCode::MOVED_PERMANENTLY => {
latencies_ms.push(ms);
}
_ => errors += 1,
}
}
remaining -= batch;
}
latencies_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
let p = |q: f64| {
let idx = ((latencies_ms.len() as f64 - 1.0) * q).round() as usize;
latencies_ms[idx]
};
let p50 = p(0.50);
let p95 = p(0.95);
let p99 = p(0.99);
let throughput = REQUESTS as f64
/ (latencies_ms.iter().sum::<f64>() / CONCURRENCY as f64 / 1000.0).max(0.001);
eprintln!("=== redirect_performance_smoke ===");
eprintln!("env: local loopback, SQLite WAL, warm connections");
eprintln!("requests={}, concurrency={}", REQUESTS, CONCURRENCY);
eprintln!(
"p50={:.3}ms p95={:.3}ms p99={:.3}ms errors={} approx_rps={:.1}",
p50, p95, p99, errors, throughput
);
eprintln!("baseline comparison: UNAVAILABLE (no git history in workspace)");
assert_eq!(errors, 0, "error rate must be zero");
assert_eq!(latencies_ms.len(), REQUESTS);
let content_path = db.data_dir.join("users/1/content.db");
let conn = rusqlite::Connection::open(content_path).unwrap();
let count: i64 = conn
.query_row(
"SELECT access_count FROM urls WHERE code = 'ab8888';",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, REQUESTS as i64);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn audit_content_conn_unit_path() {
let conn = rusqlite::Connection::open_in_memory().unwrap();
conn.execute_batch(
"CREATE TABLE urls (
id TEXT PRIMARY KEY,
code TEXT NOT NULL,
destination TEXT NOT NULL
);",
)
.unwrap();
conn.execute(
"INSERT INTO urls VALUES ('1','a','https://ok.example/');",
[],
)
.unwrap();
conn.execute("INSERT INTO urls VALUES ('2','b','javascript:x');", [])
.unwrap();
let mut report = DestinationAuditReport::default();
audit_content_conn(&conn, 9, &mut report).unwrap();
assert_eq!(report.total_urls, 2);
assert_eq!(report.valid_https, 1);
assert_eq!(report.unsupported_scheme, 1);
}
+6 -2
View File
@@ -25,7 +25,9 @@ async fn test_global_slug_lookup_and_redirection() {
let config = create_temp_config(temp_dir.clone()); let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
@@ -120,7 +122,9 @@ async fn test_disabled_slug_returns_410() {
let config = create_temp_config(temp_dir.clone()); let config = create_temp_config(temp_dir.clone());
let db = Db::init(&config).expect("Failed to init Db"); let db = Db::init(&config).expect("Failed to init Db");
let queue = AnalyticsQueue::new(db.clone(), 1000); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
+3 -1
View File
@@ -148,7 +148,9 @@ async fn test_upgrade_from_v0_4_0() {
assert!(temp_dir.join("users/1/analytics.db").exists()); assert!(temp_dir.join("users/1/analytics.db").exists());
// Spawn server // Spawn server
let queue = AnalyticsQueue::new(db.clone(), 10); let (tx, rx) = tokio::sync::watch::channel(false);
Box::leak(Box::new(tx));
let (queue, _) = AnalyticsQueue::new(db.clone(), 10, rx);
let state = AppState { let state = AppState {
admin_db: db.admin.clone(), admin_db: db.admin.clone(),
content_db: db.content.clone(), content_db: db.content.clone(),