Initial public release

This commit is contained in:
thakares committed 2026-06-11 20:19:03 +05:30
commit c1107147b4
92 files changed
+10562

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
# BZOD Platform Configuration
HOST=0.0.0.0
PORT=8080
DATA_DIR=./data
# Security Settings
COOKIE_SECURE=false
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
# Bootstrap Admin Credentials
# Default username: admin
# Default password: admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
# Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
+7
View File
@@ -0,0 +1,7 @@
/target
data/
*.db
*.db-wal
*.db-shm
.env
Generated
+2770
View File
File diff suppressed because it is too large. Load diff
+30
View File
@@ -0,0 +1,30 @@
[package]
name = "bzod"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["cookie"] }
rusqlite = { version = "0.31", features = ["bundled"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.8", features = ["v4", "serde"] }
clap = { version = "4.5", features = ["derive", "env"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenvy = "0.15"
askama = { version = "0.12" }
argon2 = "0.5"
sha2 = "0.10"
rand = "0.8"
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
tar = "0.4"
flate2 = "1.0"
chrono = { version = "0.4", features = ["serde"] }
hex = "0.4"
time = "0.3"
toml = "0.8"
+67
View File
@@ -0,0 +1,67 @@
# ==========================================
# Stage 1: Build
# ==========================================
FROM rust:1.82-slim-bookworm AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
pkg-config \
libssl-dev \
git \
&& rm -rf /var/lib/apt/lists/*
# Copy configuration files
COPY Cargo.toml ./
# Pre-build dependencies to cache them
RUN mkdir src && echo "fn main() {}" > src/main.rs
RUN cargo build --release
RUN rm -rf src
# Copy source and templates
COPY src ./src
COPY templates ./templates
# Trigger rebuilding with actual source
RUN touch src/main.rs
RUN cargo build --release
# ==========================================
# Stage 2: Runner
# ==========================================
FROM debian:bookworm-slim
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y \
openssl \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy binary from builder
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
# Create non-root user and data directory
RUN groupadd -g 10001 bzod && \
useradd -u 10001 -g bzod -m -s /bin/bash bzod
RUN mkdir -p /app/data && chown -R bzod:bzod /app/data
USER bzod
ENV DATA_DIR=/app/data
ENV PORT=8080
ENV HOST=0.0.0.0
ENV COOKIE_SECURE=true
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:$${PORT:-8080}/status || exit 1
ENTRYPOINT ["bzod"]
CMD ["serve"]
+284
View File
@@ -0,0 +1,284 @@
# nx9-url-shortner
A lightweight, self-hosted URL shortener and landing page platform written in Rust.
`nx9-url-shortner` is designed for individuals, organizations, and homelab operators who want complete control over their short links without relying on third-party services.
Built with Rust, SQLite, Axum, and Askama, it provides URL shortening, landing pages, analytics, audit logging, API access, and a web-based administration interface while maintaining a small deployment footprint.
---
## Features
### URL Shortening
Create short links using compact hexadecimal identifiers.
Example:
```text
https://bzo.in/1bb170
```
Redirects to:
```text
https://github.com/thakares/chronoseal-rs
```
---
### Landing Pages
Create standalone landing pages using dedicated page identifiers.
Example:
```text
https://bzo.in/p/1a2b
```
---
### Analytics
Track:
* Total visits
* Country statistics
* Referrers
* User agents
* Daily statistics
* Monthly statistics
* Yearly statistics
---
### Administrative Dashboard
Web-based administration interface featuring:
* URL management
* Landing page management
* API token management
* Audit logs
* Health checks
* Analytics dashboard
* SVG charts
---
### API Support
REST API endpoints for automation and integration.
```text
/api/v1/*
```
---
### Security
* Password-protected administration interface
* Session management
* CSRF protection
* API token authentication
* Audit logging
---
### Self-Hosted
No external services required.
Dependencies:
* Rust
* SQLite
* Docker (optional)
No:
* React
* Node.js
* Redis
* Kubernetes
* External databases
---
## Architecture
### Databases
The application uses four SQLite databases.
| Database | Purpose |
| ------------ | ---------------------------------------- |
| admin.db | Users, sessions, API keys, audit logs |
| content.db | URLs, landing pages, tags |
| analytics.db | Visits and statistics |
| system.db | Jobs, migrations, backups, health checks |
---
## Default Credentials
Initial login:
```text
Username: admin
Password: admin
```
Change the password immediately after first login.
---
## Docker Deployment
### Build
```bash
docker compose build
```
### Start
```bash
docker compose up -d
```
### View Logs
```bash
docker logs -f bzod
```
---
## Docker Compose Example
```yaml
services:
bzod:
build: .
container_name: bzod
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- ./data:/app/data
- ./config:/app/config
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
```
---
## Development
### Build
```bash
cargo build
```
### Run
```bash
cargo run -- serve
```
### Run Migrations
```bash
cargo run -- migrate
```
### Create Admin User
```bash
cargo run -- create-admin
```
### Run Tests
```bash
cargo test
```
---
## Project Structure
```text
src/
├── analytics/
├── auth/
├── charts/
├── cli/
├── db/
├── jobs/
├── models/
├── services/
├── templates/
├── utils/
└── web/
```
---
## Roadmap
Planned features:
* QR code generation
* Link expiration
* Link disabling
* CSV exports
* Bulk URL import
* GeoIP integration
* Multi-user administration
* SSO support
---
## Production Deployment
Recommended stack:
```text
Internet
│
▼
Nginx Proxy Manager
│
▼
nx9-url-shortner
│
▼
SQLite
```
HTTPS is strongly recommended.
---
## License
Apache License 2.0
---
## Author
Sunil Thakare
Built using Rust, SQLite, Axum, Askama, and a preference for simple, maintainable software.
+27
View File
@@ -0,0 +1,27 @@
[Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform
After=network.target
[Service]
Type=simple
User=bzod
Group=bzod
WorkingDirectory=/var/lib/bzod
EnvironmentFile=/etc/bzod/bzod.env
ExecStart=/usr/local/bin/bzod serve --host 0.0.0.0 --port 8080 --data-dir /var/lib/bzod/data
Restart=on-failure
RestartSec=5s
# Hardening / Sandboxing options for security
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/var/lib/bzod
CapabilityBoundingSet=
[Install]
WantedBy=multi-user.target
Executable
+147
View File
@@ -0,0 +1,147 @@
#!/usr/bin/env bash
# BZOD Deployment Script (Debian Native Deployment)
# This script sets up a secure, production-ready systemd service for BZOD.
set -euo pipefail
# Configurations
SERVICE_USER="bzod"
INSTALL_PATH="/usr/local/bin/bzod"
CONFIG_DIR="/etc/bzod"
DATA_DIR="/var/lib/bzod/data"
ENV_FILE="${CONFIG_DIR}/bzod.env"
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
# Color outputs
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${BLUE}=== BZOD Debian Deployment Script ===${NC}"
# 1. Check Root Privileges
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (or via sudo).${NC}"
exit 1
fi
# 2. Install Package Dependencies
echo -e "\n${BLUE}[1/8] Installing system dependencies (SQLite, OpenSSL, Tar)...${NC}"
apt-get update
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
# 3. Compile Production Build Locally
echo -e "\n${BLUE}[2/8] Compiling release binary...${NC}"
if ! command -v cargo &> /dev/null; then
echo -e "${RED}Error: cargo not found. Please install Rust or copy a compiled 'bzod' binary to the current directory.${NC}"
exit 1
fi
cargo build --release
echo -e "${GREEN}Release build completed.${NC}"
# 4. Install Binary
echo -e "\n${BLUE}[3/8] Installing binary to ${INSTALL_PATH}...${NC}"
cp target/release/bzod "${INSTALL_PATH}"
chmod 755 "${INSTALL_PATH}"
chown root:root "${INSTALL_PATH}"
echo -e "${GREEN}Binary installed successfully.${NC}"
# 5. Create Dedicated locked-down System User
echo -e "\n${BLUE}[4/8] Creating dedicated system user '${SERVICE_USER}'...${NC}"
if ! id -u "${SERVICE_USER}" &>/dev/null; then
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
echo -e "${GREEN}System user '${SERVICE_USER}' created.${NC}"
else
echo "User '${SERVICE_USER}' already exists."
fi
# 6. Configure Directory Trees and Permissions
echo -e "\n${BLUE}[5/8] Setting up configuration and data directories...${NC}"
mkdir -p "${CONFIG_DIR}"
mkdir -p "${DATA_DIR}"
# Copy .env file if it exists, otherwise prompt/generate
if [ -f .env ] && [ ! -f "${ENV_FILE}" ]; then
echo "Copying local .env file to ${ENV_FILE}..."
cp .env "${ENV_FILE}"
elif [ ! -f "${ENV_FILE}" ]; then
echo "Generating default configuration file at ${ENV_FILE}..."
cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0
PORT=8080
DATA_DIR=${DATA_DIR}
COOKIE_SECURE=true
SESSION_SECRET=$(openssl rand -hex 32)
ADMIN_USERNAME=admin
# SHA-256 for bootstrap (Default: admin)
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
LINK_CHECK_INTERVAL_MINS=60
AGGREGATION_INTERVAL_MINS=60
DATA_RETENTION_DAYS=365
EOF
fi
chmod 600 "${ENV_FILE}"
chown -R root:"${SERVICE_USER}" "${CONFIG_DIR}"
chown -R "${SERVICE_USER}":"${SERVICE_USER}" /var/lib/bzod
echo -e "${GREEN}Directories and permission parameters configured.${NC}"
# 7. Initialise DB as the service user (avoids file permission conflicts)
echo -e "\n${BLUE}[6/8] Initialising databases...${NC}"
sudo -u "${SERVICE_USER}" "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}Databases initialised.${NC}"
# 8. Set Up Systemd Service
echo -e "\n${BLUE}[7/8] Installing systemd service unit...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}"
[Unit]
Description=BZOD - Personal URL Shortener & Landing Page Platform
After=network.target
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve --host 0.0.0.0 --port 8080 --data-dir ${DATA_DIR}
Restart=on-failure
RestartSec=5s
# Hardening / Sandboxing options for security
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ReadWritePaths=/var/lib/bzod
[Install]
WantedBy=multi-user.target
EOF
chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload
echo -e "${GREEN}Systemd service registered.${NC}"
# 9. Enable and Start the Service
echo -e "\n${BLUE}[8/8] Starting BZOD service...${NC}"
systemctl enable bzod
systemctl restart bzod
sleep 2
if systemctl is-active --quiet bzod; then
echo -e "${GREEN}BZOD service is running successfully!${NC}"
echo -e "\n${BLUE}=== Deployment Completed Successfully ===${NC}"
echo -e "You can access BZOD at http://localhost:8080"
echo -e "Admin Login Dashboard is at http://localhost:8080/admin"
echo -e "System service logs: journalctl -u bzod -f"
echo -e "To change the default admin password, run: bzod create-admin --data-dir ${DATA_DIR}"
else
echo -e "${RED}Error: BZOD service failed to start. Check logs using: journalctl -u bzod -n 50${NC}"
fi
+28
View File
@@ -0,0 +1,28 @@
services:
bzod:
build:
context: .
dockerfile: Dockerfile
container_name: bzod
restart: unless-stopped
ports:
- "8654:8654"
volumes:
- /DATA/AppData/bzod/data:/app/data
- /DATA/AppData/bzod/config:/app/config
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8654/status"]
interval: 30s
timeout: 5s
retries: 3
+140
View File
@@ -0,0 +1,140 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::db::analytics::{parse_ua, clean_referrer};
// Run aggregation for a specific day
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
let mut visits = Vec::new();
{
// 1. Fetch all visits on that day
let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?;
struct RawVisit {
target_type: String,
target_id: String,
user_agent: String,
referer: String,
country: String,
}
let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit {
target_type: row.get(0)?,
target_id: row.get(1)?,
user_agent: row.get(2)?,
referer: row.get(3)?,
country: row.get(4)?,
})
})?;
for r in rows {
visits.push(r?);
}
}
if visits.is_empty() {
return Ok(());
}
// 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
// Also track total per day (all targets combined) using target_id = "all"
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
(v.target_type.clone(), "all".to_string()),
];
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
}
}
// 3. Save to database in a transaction
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);"
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
}
}
tx.commit()?;
// Update monthly and yearly summaries using the daily summaries
aggregate_month_from_daily(conn, &date[0..7])?;
aggregate_year_from_daily(conn, &date[0..4])?;
Ok(())
}
pub fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
FROM daily_summaries
WHERE date LIKE ?2
GROUP BY target_type, target_id, metric_type, metric_key;",
params![year_month, format!("{}-%", year_month)],
)?;
}
tx.commit()?;
Ok(())
}
pub fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
FROM daily_summaries
WHERE date LIKE ?2
GROUP BY target_type, target_id, metric_type, metric_key;",
params![year, format!("{}-%", year)],
)?;
}
tx.commit()?;
Ok(())
}
+48
View File
@@ -0,0 +1,48 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub enum AnalyticsEvent {
RedirectVisit {
url_id: String,
code: String,
timestamp: String,
ip_address: String,
user_agent: String,
referer: String,
accept_language: String,
country: String,
status_code: u16,
},
LandingPageVisit {
page_id: String,
code: String,
slug: String,
timestamp: String,
ip_address: String,
user_agent: String,
referer: String,
accept_language: String,
country: String,
status_code: u16,
},
AdminLogin {
username: String,
timestamp: String,
ip_address: Option<String>,
user_agent: Option<String>,
success: bool,
},
ApiRequest {
username: String,
endpoint: String,
method: String,
timestamp: String,
ip_address: Option<String>,
status_code: u16,
},
SystemEvent {
event_type: String, // 'startup', 'shutdown', 'backup', etc.
details: String,
timestamp: String,
},
}
+19
View File
@@ -0,0 +1,19 @@
use axum::http::HeaderMap;
// Guess or extract client country from headers
pub fn get_client_country(headers: &HeaderMap) -> String {
if let Some(country) = headers.get("cf-ipcountry").and_then(|h| h.to_str().ok()) {
return country.to_uppercase();
}
if let Some(lang) = headers.get("accept-language").and_then(|h| h.to_str().ok()) {
if let Some(dash_idx) = lang.find('-') {
if lang.len() > dash_idx + 2 {
let code = &lang[dash_idx + 1..dash_idx + 3];
if code.chars().all(|c| c.is_ascii_alphabetic()) {
return code.to_uppercase();
}
}
}
}
"Unknown".to_string()
}
+10
View File
@@ -0,0 +1,10 @@
pub mod queue;
pub mod worker;
pub mod location;
pub mod events;
pub mod aggregate;
pub use queue::AnalyticsQueue;
pub use location::get_client_country;
pub use events::AnalyticsEvent;
pub use aggregate::{aggregate_day, aggregate_month_from_daily, aggregate_year_from_daily};
+29
View File
@@ -0,0 +1,29 @@
use tokio::sync::mpsc;
use crate::models::VisitRecord;
use crate::db::Db;
#[derive(Clone)]
pub struct AnalyticsQueue {
sender: mpsc::Sender<VisitRecord>,
}
impl AnalyticsQueue {
pub fn new(db: Db, capacity: usize) -> Self {
let (sender, receiver) = mpsc::channel(capacity);
// Spawn background worker to batch-write records
tokio::spawn(async move {
super::worker::run_worker(db, receiver).await;
});
Self { sender }
}
// Attempt to queue a visit. Non-blocking.
pub fn push(&self, record: VisitRecord) {
use tracing::error;
if let Err(e) = self.sender.try_send(record) {
error!("Failed to queue analytics record: {:?}", e);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
use tokio::sync::mpsc;
use tokio::time::{interval, MissedTickBehavior};
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use crate::models::VisitRecord;
use crate::db::analytics::insert_visits_batch;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
let mut batch = Vec::new();
let batch_size = 50;
let flush_interval = Duration::from_secs(2);
let mut timer = interval(flush_interval);
timer.set_missed_tick_behavior(MissedTickBehavior::Delay);
loop {
tokio::select! {
record_opt = receiver.recv() => {
match record_opt {
Some(record) => {
batch.push(record);
if batch.len() >= batch_size {
flush_batch(&db, &mut batch);
}
}
None => {
info!("Analytics channel closed. Flushing remaining records.");
flush_batch(&db, &mut batch);
break;
}
}
}
_ = timer.tick() => {
if !batch.is_empty() {
flush_batch(&db, &mut batch);
}
}
}
}
}
fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
if batch.is_empty() {
return;
}
info!("Flushing {} visits to analytics database", batch.len());
let mut conn_lock = db.analytics.lock().unwrap();
if let Err(e) = insert_visits_batch(&mut conn_lock, batch) {
error!("Failed to write analytics batch to database: {:?}", e);
} else {
batch.clear();
}
}
+15
View File
@@ -0,0 +1,15 @@
use sha2::{Sha256, Digest};
// Deterministic CSRF token derived from session token
pub fn generate_csrf_token(session_id: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(session_id.as_bytes());
hasher.update(b"csrf-salt-bzod-2026");
hex::encode(hasher.finalize())
}
// Verify CSRF token
pub fn verify_csrf(session_id: &str, submitted_token: &str) -> bool {
let expected = generate_csrf_token(session_id);
expected == submitted_token
}
+34
View File
@@ -0,0 +1,34 @@
use axum::{
extract::{FromRequestParts, FromRef},
http::{request::Parts, StatusCode},
};
use crate::state::AppState;
use crate::models::User;
use crate::auth::session::authenticate_api_key;
// Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub User);
#[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser
where
AppState: FromRef<S>,
S: Send + Sync,
{
type Rejection = (StatusCode, &'static str);
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
let app_state = AppState::from_ref(state);
let auth_header = parts.headers
.get("Authorization")
.and_then(|h| h.to_str().ok())
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
let conn = app_state.admin_db.lock().unwrap();
match authenticate_api_key(&conn, auth_header) {
Ok(Some(user)) => Ok(ApiUser(user)),
Ok(None) => Err((StatusCode::UNAUTHORIZED, "Invalid API token")),
Err(_) => Err((StatusCode::INTERNAL_SERVER_ERROR, "Database error")),
}
}
}
+9
View File
@@ -0,0 +1,9 @@
pub mod password;
pub mod session;
pub mod csrf;
pub mod middleware;
pub use password::{hash_password, verify_password, verify_sha256};
pub use session::{generate_token, authenticate_session, authenticate_api_key};
pub use csrf::{generate_csrf_token, verify_csrf};
pub use middleware::ApiUser;
+31
View File
@@ -0,0 +1,31 @@
use sha2::{Sha256, Digest};
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
// Hashing password with Argon2id
pub fn hash_password(password: &str) -> Result<String, argon2::password_hash::Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2.hash_password(password.as_bytes(), &salt)?.to_string();
Ok(password_hash)
}
// Verifying Argon2id password hash
pub fn verify_password(password: &str, hash: &str) -> bool {
if let Ok(parsed_hash) = PasswordHash::new(hash) {
Argon2::default().verify_password(password.as_bytes(), &parsed_hash).is_ok()
} else {
false
}
}
// Verifying SHA-256 bootstrap hash
pub fn verify_sha256(password: &str, expected_hex: &str) -> bool {
let mut hasher = Sha256::new();
hasher.update(password.as_bytes());
let result = hasher.finalize();
let hex_result = hex::encode(result);
hex_result.eq_ignore_ascii_case(expected_hex)
}
+80
View File
@@ -0,0 +1,80 @@
use sha2::{Sha256, Digest};
use rand::{RngCore, thread_rng};
use axum_extra::extract::CookieJar;
use rusqlite::Connection;
use chrono::Utc;
use crate::db::admin::{get_session, get_user_by_id, update_api_key_last_used, get_api_key_by_hash};
use crate::models::User;
// Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String {
let mut key = vec![0u8; bytes_len];
thread_rng().fill_bytes(&mut key);
hex::encode(key)
}
// Authenticate session from cookies
pub fn authenticate_session(
conn: &Connection,
jar: &CookieJar,
) -> Result<Option<(User, String)>, rusqlite::Error> {
let cookie = match jar.get("bzod_session") {
Some(c) => c,
None => return Ok(None),
};
let session_id = cookie.value();
let session = match get_session(conn, session_id)? {
Some(s) => s,
None => return Ok(None),
};
// Check expiration
if let Ok(expires) = chrono::DateTime::parse_from_rfc3339(&session.expires_at) {
if expires.with_timezone(&Utc) < Utc::now() {
// Expired
return Ok(None);
}
} else {
return Ok(None);
}
// Get user
if let Some(user) = get_user_by_id(conn, &session.user_id)? {
Ok(Some((user, session.id)))
} else {
Ok(None)
}
}
// Authenticate API key from Authorization header
pub fn authenticate_api_key(
conn: &Connection,
auth_header: &str,
) -> Result<Option<User>, rusqlite::Error> {
if !auth_header.starts_with("Bearer ") {
return Ok(None);
}
let key = auth_header.trim_start_matches("Bearer ").trim();
if key.is_empty() {
return Ok(None);
}
// Hash the API key using SHA-256 to compare with stored hash
let mut hasher = Sha256::new();
hasher.update(key.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
if let Some(api_key_rec) = get_api_key_by_hash(conn, &hashed_key)? {
// Update last used timestamp
update_api_key_last_used(conn, &api_key_rec.id)?;
// Get user
if let Some(user) = get_user_by_id(conn, &api_key_rec.user_id)? {
return Ok(Some(user));
}
}
Ok(None)
}
+66
View File
@@ -0,0 +1,66 @@
use super::svg::{wrap_in_svg, DEFAULT_TEXT_COLOR};
pub fn generate_bar_chart(data: &[(String, i64)]) -> String {
if data.is_empty() {
return r##"<svg viewBox="0 0 600 200" class="w-full h-auto bg-slate-900/50 rounded-xl border border-slate-800/80 p-4" xmlns="http://www.w3.org/2000/svg">
<text x="300" y="100" fill="#94a3b8" text-anchor="middle" font-family="system-ui, sans-serif">No data available</text>
</svg>"##.to_string();
}
let bar_height = 24.0;
let gap = 14.0;
let pad_top = 10.0;
let pad_bottom = 10.0;
let pad_left = 130.0;
let pad_right = 70.0;
let width = 600.0;
let height = pad_top + pad_bottom + (data.len() as f64 * (bar_height + gap)) - gap;
let chart_w = width - pad_left - pad_right;
let max_val = data.iter().map(|(_, v)| *v).max().unwrap_or(0);
let max_x = if max_val == 0 { 1.0 } else { max_val as f64 };
let mut bars = String::new();
let total_count: i64 = data.iter().map(|(_, v)| *v).sum();
for (i, (label, val)) in data.iter().enumerate() {
let y = pad_top + (i as f64 * (bar_height + gap));
let bar_w = (*val as f64 / max_x) * chart_w;
let pct = if total_count > 0 {
(*val as f64 / total_count as f64) * 100.0
} else {
0.0
};
// Truncate long labels
let display_label = if label.len() > 18 {
format!("{}...", &label[0..15])
} else {
label.to_string()
};
bars.push_str(&format!(
r##"<!-- Row {i} -->
<text x="{pad_left_label}" y="{text_y}" fill="#e2e8f0" font-size="12" font-family="system-ui, sans-serif" font-weight="500" text-anchor="end" alignment-baseline="middle">{label}</text>
<rect x="{pad_left}" y="{y}" width="{bar_w:.1}" height="{bar_height}" rx="4" fill="url(#barGrad)"/>
<text x="{val_x:.1}" y="{text_y}" fill="{text_color}" font-size="11" font-family="system-ui, sans-serif" alignment-baseline="middle">{val} ({pct:.1}%)</text>
"##,
i = i,
pad_left_label = pad_left - 10.0,
text_y = y + (bar_height / 2.0) + 1.0,
label = display_label,
pad_left = pad_left,
y = y,
bar_w = bar_w.max(2.0),
bar_height = bar_height,
val_x = pad_left + bar_w.max(2.0) + 8.0,
val = val,
pct = pct,
text_color = DEFAULT_TEXT_COLOR
));
}
wrap_in_svg(width, height, &bars)
}
+106
View File
@@ -0,0 +1,106 @@
use super::svg::{wrap_in_svg, DEFAULT_GRID_COLOR, DEFAULT_TEXT_COLOR};
pub fn generate_line_chart(data: &[(String, i64)]) -> String {
if data.is_empty() {
return r##"<svg viewBox="0 0 800 300" class="w-full h-auto bg-slate-900/50 rounded-xl border border-slate-800/80 p-4" xmlns="http://www.w3.org/2000/svg">
<text x="400" y="150" fill="#94a3b8" text-anchor="middle" font-family="system-ui, sans-serif">No traffic data available</text>
</svg>"##.to_string();
}
let width = 800.0;
let height = 300.0;
let pad_left = 60.0;
let pad_right = 30.0;
let pad_top = 30.0;
let pad_bottom = 40.0;
let chart_w = width - pad_left - pad_right;
let chart_h = height - pad_top - pad_bottom;
// Find max value for scaling
let max_val = data.iter().map(|(_, v)| *v).max().unwrap_or(0);
let max_y = if max_val == 0 { 10.0 } else { max_val as f64 };
// Y-axis grid ticks
let mut inner_content = String::new();
let ticks = 4;
for i in 0..=ticks {
let pct = i as f64 / ticks as f64;
let y = pad_top + chart_h - (pct * chart_h);
let val = (pct * max_y).round() as i64;
inner_content.push_str(&format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-dasharray="4,4" stroke-width="1"/>
<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="end" alignment-baseline="middle">{}</text>"##,
pad_left, y, width - pad_right, y, DEFAULT_GRID_COLOR, pad_left - 10.0, y, DEFAULT_TEXT_COLOR, val
));
}
// Coordinates calculations
let count = data.len();
let step_x = if count > 1 { chart_w / (count - 1) as f64 } else { chart_w };
let mut points = Vec::new();
for (i, &(_, val)) in data.iter().enumerate() {
let x = pad_left + (i as f64 * step_x);
let y = pad_top + chart_h - ((val as f64 / max_y) * chart_h);
points.push((x, y));
}
// Path strings
let mut line_path = String::new();
let mut area_path = String::new();
if !points.is_empty() {
line_path.push_str(&format!("M {:.1} {:.1}", points[0].0, points[0].1));
area_path.push_str(&format!("M {:.1} {:.1}", points[0].0, pad_top + chart_h));
area_path.push_str(&format!("L {:.1} {:.1}", points[0].0, points[0].1));
for &(x, y) in points.iter().skip(1) {
line_path.push_str(&format!(" L {:.1} {:.1}", x, y));
area_path.push_str(&format!(" L {:.1} {:.1}", x, y));
}
let last_x = points[points.len() - 1].0;
area_path.push_str(&format!(" L {:.1} {:.1} Z", last_x, pad_top + chart_h));
}
// X-axis labels
let mut x_labels = String::new();
let label_step = (count / 7).max(1);
for (i, (label, _)) in data.iter().enumerate() {
if i % label_step == 0 || i == count - 1 {
let x = points[i].0;
let short_label = if label.len() == 10 { &label[5..] } else { label };
x_labels.push_str(&format!(
r##"<text x="{}" y="{}" fill="{}" font-size="11" font-family="system-ui, sans-serif" text-anchor="middle">{}</text>"##,
x, height - 15.0, DEFAULT_TEXT_COLOR, short_label
));
x_labels.push_str(&format!(
r##"<line x1="{}" y1="{}" x2="{}" y2="{}" stroke="{}" stroke-width="1"/>"##,
x, pad_top + chart_h, x, pad_top + chart_h + 5.0, DEFAULT_GRID_COLOR
));
}
}
// Draw little circles on points
let mut dots = String::new();
if count < 40 {
for &(x, y) in &points {
dots.push_str(&format!(
r##"<circle cx="{:.1}" cy="{:.1}" r="4" fill="#6366f1" stroke="#1e293b" stroke-width="2"/>"##,
x, y
));
}
}
inner_content.push_str(&format!(
r##"<path d="{}" fill="url(#areaGrad)"/>
<path d="{}" fill="none" stroke="#6366f1" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/>
{}
{}"##,
area_path, line_path, dots, x_labels
));
wrap_in_svg(width, height, &inner_content)
}
+10
View File
@@ -0,0 +1,10 @@
pub mod svg;
pub mod line;
pub mod bar;
pub mod pie;
pub mod timeseries;
pub use line::generate_line_chart;
pub use bar::generate_bar_chart;
pub use pie::generate_pie_chart;
pub use timeseries::generate_timeseries_chart;
+8
View File
@@ -0,0 +1,8 @@
// Donut / Pie SVG Chart Renderer (Placeholder / Stub)
pub fn generate_pie_chart(_data: &[(String, i64)]) -> String {
r##"<svg viewBox="0 0 400 400" class="w-full h-auto bg-slate-900/50 rounded-xl border border-slate-800/80 p-4" xmlns="http://www.w3.org/2000/svg">
<circle cx="200" cy="200" r="100" fill="none" stroke="#6366f1" stroke-width="40"/>
<text x="200" y="200" fill="#94a3b8" text-anchor="middle" font-family="system-ui, sans-serif" alignment-baseline="middle">Donut Chart Placeholder</text>
</svg>"##.to_string()
}
+25
View File
@@ -0,0 +1,25 @@
pub const DEFAULT_GRID_COLOR: &str = "#334155";
pub const DEFAULT_TEXT_COLOR: &str = "#94a3b8";
pub fn wrap_in_svg(width: f64, height: f64, content: &str) -> String {
// Note: raw string literals with # inside double-quotes can break standard parsing,
// so we use r##"..."## formatting to prevent compiler errors.
format!(
r##"<svg viewBox="0 0 {width} {height}" class="w-full h-auto" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#6366f1" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#6366f1" stop-opacity="0.01"/>
</linearGradient>
<linearGradient id="barGrad" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#4f46e5"/>
<stop offset="100%" stop-color="#818cf8"/>
</linearGradient>
</defs>
{content}
</svg>"##,
width = width,
height = height,
content = content
)
}
+9
View File
@@ -0,0 +1,9 @@
use super::line::generate_line_chart;
/// Generates a timeseries traffic trend SVG chart.
///
/// This provides a specialized wrapper around line charts, specifically
/// configured for timeseries data streams (daily/monthly traffic logs).
pub fn generate_timeseries_chart(data: &[(String, i64)]) -> String {
generate_line_chart(data)
}
+23
View File
@@ -0,0 +1,23 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::jobs::backup::perform_backup;
pub async fn run(
out: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if let Some(o) = out { config.backup_dir = PathBuf::from(o); }
// Init DB connections to ensure databases exist and migrate if needed
let db = Db::init(&config)?;
info!("Starting database backup...");
let backup_path = perform_backup(&db, &config).await?;
info!("Database backup generated successfully: {}", backup_path);
Ok(())
}
+46
View File
@@ -0,0 +1,46 @@
use std::path::PathBuf;
use std::io::{self, Write};
use tracing::{info, error};
use crate::config::Config;
use crate::db::Db;
use crate::auth::hash_password;
pub async fn run(
username: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
let db = Db::init(&config)?;
let final_username = match username {
Some(u) => u,
None => read_input("Enter administrator username: "),
};
if final_username.trim().is_empty() {
error!("Username cannot be empty");
return Ok(());
}
let password = read_input("Enter password: ");
if password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let conn = db.admin.lock().unwrap();
let u = crate::db::admin::create_user(&conn, &final_username, &hash)?;
info!("Successfully created admin user: {} (ID: {})", u.username, u.id);
Ok(())
}
fn read_input(prompt: &str) -> String {
print!("{}", prompt);
let _ = io::stdout().flush();
let mut input = String::new();
let _ = io::stdin().read_line(&mut input);
input.trim().to_string()
}
+76
View File
@@ -0,0 +1,76 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::sqlite;
use rusqlite::Connection;
/// Run comprehensive database diagnostics.
///
/// Opens each database, collects health reports (schema version, journal mode,
/// foreign key enforcement, integrity check), and prints a summary.
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!("Running BZOD database diagnostics...");
println!("BZOD Database Doctor");
println!("====================");
println!("Data directory: {:?}", config.data_dir);
println!();
let databases = ["admin", "content", "analytics", "system"];
let mut all_healthy = true;
for db_name in &databases {
let db_path = config.data_dir.join(format!("{}.db", db_name));
if !db_path.exists() {
println!("Database: {}", db_name);
println!(" Status: NOT FOUND at {:?}", db_path);
println!();
all_healthy = false;
continue;
}
match Connection::open(&db_path) {
Ok(conn) => {
match sqlite::collect_health_report(&conn, db_name) {
Ok(report) => {
println!("Database: {}", report.database);
println!(" Path: {:?}", db_path);
println!(" Schema version: {}", report.schema_version);
println!(" Journal mode: {}", report.journal_mode);
println!(" Foreign keys: {}", if report.foreign_keys_enabled { "enabled" } else { "DISABLED" });
println!(" Integrity: {}", if report.integrity_ok { "ok" } else { "FAILED" });
if !report.integrity_ok || !report.foreign_keys_enabled {
all_healthy = false;
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: ERROR collecting health report: {}", e);
all_healthy = false;
}
}
}
Err(e) => {
println!("Database: {}", db_name);
println!(" Status: FAILED to open: {}", e);
all_healthy = false;
}
}
println!();
}
println!("--------------------");
if all_healthy {
println!("Overall status: HEALTHY");
} else {
println!("Overall status: ISSUES DETECTED");
}
Ok(())
}
+24
View File
@@ -0,0 +1,24 @@
use std::path::PathBuf;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
pub async fn run(
data_dir: Option<String>,
dry_run: bool,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied.");
info!("Data directory: {:?}", config.data_dir);
return Ok(());
}
info!("Running database migrations...");
let _db = Db::init(&config)?;
info!("Database migrations applied successfully.");
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
use clap::{Parser, Subcommand};
pub mod serve;
pub mod backup;
pub mod restore;
pub mod migrate;
pub mod stats;
pub mod validate;
pub mod create_admin;
pub mod doctor;
#[derive(Parser)]
#[command(name = "bzod")]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub enum Commands {
/// Start the BZOD web server
Serve {
#[arg(long)]
host: Option<String>,
#[arg(long)]
port: Option<u16>,
#[arg(long)]
data_dir: Option<String>,
},
/// Create a tar.gz backup of all databases
Backup {
#[arg(long)]
out: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Restore databases from a tar.gz backup file
Restore {
#[arg(long, required = true)]
file: String,
#[arg(long)]
data_dir: Option<String>,
},
/// Apply pending database schema migrations
Migrate {
#[arg(long)]
data_dir: Option<String>,
/// Show what migrations would be applied without executing them
#[arg(long)]
dry_run: bool,
},
/// Print database statistics and record counts in the terminal
Stats {
#[arg(long)]
data_dir: Option<String>,
},
/// Perform a one-shot validation of all registered short link destinations
Validate {
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new administrator user in the database
CreateAdmin {
#[arg(long)]
username: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
/// Run database diagnostics and health checks
Doctor {
#[arg(long)]
data_dir: Option<String>,
},
}
+45
View File
@@ -0,0 +1,45 @@
use std::path::PathBuf;
use std::fs::File;
use std::io::{self, Write};
use tracing::{info, error};
use flate2::read::GzDecoder;
use tar::Archive;
use crate::config::Config;
pub async fn run(
file: String,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
let file_path = PathBuf::from(file);
if !file_path.exists() {
error!("Backup file not found: {:?}", file_path);
return Ok(());
}
info!("WARNING: Restoring will overwrite existing databases in {:?}", config.data_dir);
print!("Are you sure you want to restore? (y/N): ");
let _ = io::stdout().flush();
let mut confirm = String::new();
let _ = io::stdin().read_line(&mut confirm);
if !confirm.trim().eq_ignore_ascii_case("y") {
info!("Restore cancelled.");
return Ok(());
}
if !config.data_dir.exists() {
std::fs::create_dir_all(&config.data_dir)?;
}
info!("Restoring backup from: {:?}", file_path);
let f = File::open(&file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
archive.unpack(&config.data_dir)?;
info!("Database files successfully restored.");
Ok(())
}
+75
View File
@@ -0,0 +1,75 @@
use std::path::PathBuf;
use std::time::Instant;
use tracing::info;
use crate::config::Config;
use crate::db::Db;
use crate::analytics::AnalyticsQueue;
use crate::state::AppState;
use crate::web::create_router;
pub async fn run(
host: Option<String>,
port: Option<u16>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(h) = host { config.host = h; }
if let Some(p) = port { config.port = p; }
if let Some(d) = data_dir { config.data_dir = PathBuf::from(d); }
info!("Starting BZOD server on {}:{}", config.host, config.port);
info!("Database directory: {:?}", config.data_dir);
// Init DBs
let db = Db::init(&config)?;
// Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000);
// Spawn background tasks
let link_checker_db = db.clone();
let link_checker_interval = config.link_check_interval_mins;
tokio::spawn(async move {
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await;
});
let aggregator_db = db.clone();
let aggregator_interval = config.aggregation_interval_mins;
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
});
let retention_db = db.clone();
let retention_days = config.data_retention_days;
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
});
// Spawn optional backup scheduler
let backup_db = db.clone();
let backup_config = config.clone();
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
});
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
db: db.clone(),
config: config.clone(),
analytics_queue: queue,
start_time: Instant::now(),
};
// Run axum server
let router = create_router(state);
let addr = format!("{}:{}", config.host, config.port);
let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Listening for requests on http://{}", addr);
axum::serve(listener, router).await?;
Ok(())
}
+49
View File
@@ -0,0 +1,49 @@
use std::path::PathBuf;
use crate::config::Config;
use crate::db::Db;
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); }
let db = Db::init(&config)?;
println!("=== BZOD Database Stats ===");
println!("Storage Directory: {:?}", config.data_dir);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let p = config.data_dir.join(f);
if p.exists() {
let sz = std::fs::metadata(&p)?.len();
println!(" File: {} - Size: {} bytes ({:.2} MB)", f, sz, sz as f64 / 1_048_576.0);
}
}
let users_count = {
let conn = db.admin.lock().unwrap();
crate::db::admin::get_user_count(&conn)?
};
println!("Users Count: {}", users_count);
let (urls_total, urls_active, urls_dead) = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)?
};
println!("Shortened URLs: {} total ({} active / {} dead)", urls_total, urls_active, urls_dead);
let pages_count = {
let conn = db.content.lock().unwrap();
crate::db::content::get_landing_page_count(&conn)?
};
println!("Landing Pages: {}", pages_count);
let total_visits = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_total_clicks(&conn)?
};
println!("Redirect Clicks: {}", total_visits);
Ok(())
}
+26
View File
@@ -0,0 +1,26 @@
use std::path::PathBuf;
use tracing::info;
use reqwest::Client;
use std::time::Duration;
use crate::config::Config;
use crate::db::Db;
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); }
let db = Db::init(&config)?;
info!("Running one-shot link validation...");
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-cli-checker/0.1")
.build()?;
crate::jobs::perform_link_check(&db, &client).await?;
info!("Link validation complete.");
Ok(())
}
+148
View File
@@ -0,0 +1,148 @@
use std::path::PathBuf;
use std::env;
use std::fs;
use serde::Deserialize;
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
pub port: u16,
pub data_dir: PathBuf,
pub admin_username: String,
pub bootstrap_password_sha256: String,
pub session_secret: String,
pub cookie_secure: bool,
pub data_retention_days: Option<i64>,
pub link_check_interval_mins: u64,
pub aggregation_interval_mins: u64,
pub backup_enabled: bool,
pub backup_interval_mins: u64,
pub backup_dir: PathBuf,
}
#[derive(Deserialize, Default)]
struct TomlConfig {
host: Option<String>,
port: Option<u16>,
data_dir: Option<String>,
admin_username: Option<String>,
bootstrap_password_sha256: Option<String>,
session_secret: Option<String>,
cookie_secure: Option<bool>,
data_retention_days: Option<String>,
link_check_interval_mins: Option<u64>,
aggregation_interval_mins: Option<u64>,
backup: Option<TomlBackupConfig>,
}
#[derive(Deserialize, Default)]
struct TomlBackupConfig {
enabled: Option<bool>,
interval_mins: Option<u64>,
out_dir: Option<String>,
}
impl Config {
pub fn load() -> Self {
// 1. Built-in defaults
let mut host = "0.0.0.0".to_string();
let mut port = 8080u16;
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret = "bzod-default-session-secret-change-me-in-production-please-do-it".to_string();
let mut cookie_secure = true;
let mut data_retention_days = None;
let mut link_check_interval_mins = 60u64;
let mut aggregation_interval_mins = 60u64;
let mut backup_enabled = false;
let mut backup_interval_mins = 1440u64; // Default: once per day
let mut backup_dir = PathBuf::from("./backups");
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
toml_path = "config/bzod.toml".to_string();
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host { host = h; }
if let Some(p) = toml_config.port { port = p; }
if let Some(d) = toml_config.data_dir { data_dir = PathBuf::from(d); }
if let Some(u) = toml_config.admin_username { admin_username = u; }
if let Some(s) = toml_config.bootstrap_password_sha256 { bootstrap_password_sha256 = s; }
if let Some(sec) = toml_config.session_secret { session_secret = sec; }
if let Some(c) = toml_config.cookie_secure { cookie_secure = c; }
if let Some(ret) = toml_config.data_retention_days {
if ret.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
} else if let Ok(parsed) = ret.parse::<i64>() {
data_retention_days = Some(parsed);
}
}
if let Some(lc) = toml_config.link_check_interval_mins { link_check_interval_mins = lc; }
if let Some(ag) = toml_config.aggregation_interval_mins { aggregation_interval_mins = ag; }
if let Some(b) = toml_config.backup {
if let Some(be) = b.enabled { backup_enabled = be; }
if let Some(bi) = b.interval_mins { backup_interval_mins = bi; }
if let Some(bo) = b.out_dir { backup_dir = PathBuf::from(bo); }
}
}
}
// 3. Load .env if present
let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env");
}
// 4. Load from Environment Variables (taking highest precedence)
if let Ok(h) = env::var("HOST") { host = h; }
if let Ok(p_str) = env::var("PORT") {
if let Ok(p) = p_str.parse::<u16>() { port = p; }
}
if let Ok(d_str) = env::var("DATA_DIR") { data_dir = PathBuf::from(d_str); }
if let Ok(u) = env::var("ADMIN_USERNAME") { admin_username = u; }
if let Ok(s) = env::var("BOOTSTRAP_PASSWORD_SHA256") { bootstrap_password_sha256 = s; }
if let Ok(sec) = env::var("SESSION_SECRET") { session_secret = sec; }
if let Ok(c_str) = env::var("COOKIE_SECURE") {
if let Ok(c) = c_str.parse::<bool>() { cookie_secure = c; }
}
if let Ok(ret_str) = env::var("DATA_RETENTION_DAYS") {
if ret_str.eq_ignore_ascii_case("unlimited") {
data_retention_days = None;
} else if let Ok(parsed) = ret_str.parse::<i64>() {
data_retention_days = Some(parsed);
}
}
if let Ok(lc_str) = env::var("LINK_CHECK_INTERVAL_MINS") {
if let Ok(lc) = lc_str.parse::<u64>() { link_check_interval_mins = lc; }
}
if let Ok(ag_str) = env::var("AGGREGATION_INTERVAL_MINS") {
if let Ok(ag) = ag_str.parse::<u64>() { aggregation_interval_mins = ag; }
}
if let Ok(be_str) = env::var("BACKUP_ENABLED") {
if let Ok(be) = be_str.parse::<bool>() { backup_enabled = be; }
}
if let Ok(bi_str) = env::var("BACKUP_INTERVAL_MINS") {
if let Ok(bi) = bi_str.parse::<u64>() { backup_interval_mins = bi; }
}
if let Ok(bo_str) = env::var("BACKUP_DIR") { backup_dir = PathBuf::from(bo_str); }
Self {
host,
port,
data_dir,
admin_username,
bootstrap_password_sha256,
session_secret,
cookie_secure,
data_retention_days,
link_check_interval_mins,
aggregation_interval_mins,
backup_enabled,
backup_interval_mins,
backup_dir,
}
}
}
+257
View File
@@ -0,0 +1,257 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc;
use crate::models::{User, Session, ApiKey, AuditLog};
pub fn create_user(conn: &Connection, username: &str, password_hash: &str) -> rusqlite::Result<User> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO users (id, username, password_hash, created_at) VALUES (?1, ?2, ?3, ?4);",
params![id, username, password_hash, created_at],
)?;
Ok(User {
id,
username: username.to_string(),
password_hash: password_hash.to_string(),
created_at,
})
}
pub fn get_user_by_username(conn: &Connection, username: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE username = ?1;")?;
let mut rows = stmt.query(params![username])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
}))
} else {
Ok(None)
}
}
pub fn get_user_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<User>> {
let mut stmt = conn.prepare("SELECT id, username, password_hash, created_at FROM users WHERE id = ?1;")?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
Ok(Some(User {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
created_at: row.get(3)?,
}))
} else {
Ok(None)
}
}
pub fn get_user_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM users;", [], |row| row.get(0))
}
pub fn create_session(
conn: &Connection,
session_id: &str,
user_id: &str,
expires_at_rfc3339: &str,
) -> rusqlite::Result<Session> {
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
params![session_id, user_id, expires_at_rfc3339, created_at],
)?;
Ok(Session {
id: session_id.to_string(),
user_id: user_id.to_string(),
expires_at: expires_at_rfc3339.to_string(),
created_at,
})
}
pub fn get_session(conn: &Connection, session_id: &str) -> rusqlite::Result<Option<Session>> {
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE id = ?1;")?;
let mut rows = stmt.query(params![session_id])?;
if let Some(row) = rows.next()? {
Ok(Some(Session {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
}))
} else {
Ok(None)
}
}
pub fn delete_session(conn: &Connection, session_id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE id = ?1;", params![session_id])?;
Ok(())
}
pub fn cleanup_expired_sessions(conn: &Connection) -> rusqlite::Result<usize> {
let now = Utc::now().to_rfc3339();
let count = conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", params![now])?;
Ok(count)
}
pub fn create_api_key(
conn: &Connection,
user_id: &str,
name: &str,
key_hash: &str,
) -> rusqlite::Result<ApiKey> {
let id = Uuid::new_v4().to_string();
let created_at = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO api_keys (id, user_id, key_hash, name, created_at) VALUES (?1, ?2, ?3, ?4, ?5);",
params![id, user_id, key_hash, name, created_at],
)?;
Ok(ApiKey {
id,
user_id: user_id.to_string(),
key_hash: key_hash.to_string(),
name: name.to_string(),
created_at,
last_used_at: None,
})
}
pub fn get_api_key_by_hash(conn: &Connection, key_hash: &str) -> rusqlite::Result<Option<ApiKey>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE key_hash = ?1;"
)?;
let mut rows = stmt.query(params![key_hash])?;
if let Some(row) = rows.next()? {
Ok(Some(ApiKey {
id: row.get(0)?,
user_id: row.get(1)?,
key_hash: row.get(2)?,
name: row.get(3)?,
created_at: row.get(4)?,
last_used_at: row.get(5)?,
}))
} else {
Ok(None)
}
}
pub fn list_api_keys(conn: &Connection, user_id: &str) -> rusqlite::Result<Vec<ApiKey>> {
let mut stmt = conn.prepare(
"SELECT id, user_id, key_hash, name, created_at, last_used_at FROM api_keys WHERE user_id = ?1 ORDER BY created_at DESC;"
)?;
let rows = stmt.query_map(params![user_id], |row| {
Ok(ApiKey {
id: row.get(0)?,
user_id: row.get(1)?,
key_hash: row.get(2)?,
name: row.get(3)?,
created_at: row.get(4)?,
last_used_at: row.get(5)?,
})
})?;
let mut keys = Vec::new();
for key in rows {
keys.push(key?);
}
Ok(keys)
}
pub fn delete_api_key(conn: &Connection, id: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM api_keys WHERE id = ?1;", params![id])?;
Ok(())
}
pub fn update_api_key_last_used(conn: &Connection, id: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute("UPDATE api_keys SET last_used_at = ?1 WHERE id = ?2;", params![now, id])?;
Ok(())
}
pub fn write_audit_log(
conn: &Connection,
username: &str,
action: &str,
object_type: Option<&str>,
object_id: Option<&str>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<AuditLog> {
let id = Uuid::new_v4().to_string();
let timestamp = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO audit_logs (id, timestamp, username, action, object_type, object_id, ip_address, user_agent)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, timestamp, username, action, object_type, object_id, ip_address, user_agent],
)?;
Ok(AuditLog {
id,
timestamp,
username: username.to_string(),
action: action.to_string(),
object_type: object_type.map(|s| s.to_string()),
object_id: object_id.map(|s| s.to_string()),
ip_address: ip_address.map(|s| s.to_string()),
user_agent: user_agent.map(|s| s.to_string()),
})
}
pub fn list_audit_logs(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<AuditLog>> {
let mut stmt = conn.prepare(
"SELECT id, timestamp, username, action, object_type, object_id, ip_address, user_agent
FROM audit_logs ORDER BY timestamp DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(AuditLog {
id: row.get(0)?,
timestamp: row.get(1)?,
username: row.get(2)?,
action: row.get(3)?,
object_type: row.get(4)?,
object_id: row.get(5)?,
ip_address: row.get(6)?,
user_agent: row.get(7)?,
})
})?;
let mut logs = Vec::new();
for log in rows {
logs.push(log?);
}
Ok(logs)
}
pub fn set_config(conn: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
conn.execute(
"INSERT OR REPLACE INTO config (key, value) VALUES (?1, ?2);",
params![key, value],
)?;
Ok(())
}
pub fn get_config(conn: &Connection, key: &str) -> rusqlite::Result<Option<String>> {
let mut stmt = conn.prepare("SELECT value FROM config WHERE key = ?1;")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
let val: String = row.get(0)?;
Ok(Some(val))
} else {
Ok(None)
}
}
+472
View File
@@ -0,0 +1,472 @@
use rusqlite::{Connection, params};
use std::collections::HashMap;
use crate::models::VisitRecord;
// Custom User-Agent parser to avoid bloated dependencies
pub fn parse_ua(ua: &str) -> (String, String, String) {
let ua_lower = ua.to_lowercase();
let os = if ua_lower.contains("windows") {
"Windows".to_string()
} else if ua_lower.contains("macintosh") || ua_lower.contains("mac os x") {
if ua_lower.contains("iphone") || ua_lower.contains("ipad") {
"iOS".to_string()
} else {
"macOS".to_string()
}
} else if ua_lower.contains("android") {
"Android".to_string()
} else if ua_lower.contains("linux") {
"Linux".to_string()
} else if ua_lower.contains("iphone") || ua_lower.contains("ipad") || ua_lower.contains("ipod") {
"iOS".to_string()
} else {
"Other".to_string()
};
let browser = if ua_lower.contains("firefox") {
"Firefox".to_string()
} else if ua_lower.contains("opr/") || ua_lower.contains("opera") {
"Opera".to_string()
} else if ua_lower.contains("edg/") {
"Edge".to_string()
} else if ua_lower.contains("chrome") {
"Chrome".to_string()
} else if ua_lower.contains("safari") {
"Safari".to_string()
} else {
"Other".to_string()
};
let device = if ua_lower.contains("mobile") || ua_lower.contains("android") || ua_lower.contains("iphone") || ua_lower.contains("ipod") {
"Mobile".to_string()
} else if ua_lower.contains("ipad") || ua_lower.contains("tablet") {
"Tablet".to_string()
} else {
"Desktop".to_string()
};
(browser, os, device)
}
// Clean referer to domain
pub fn clean_referrer(referer: &str) -> String {
if referer.is_empty() || referer == "direct" {
return "Direct".to_string();
}
if let Ok(url) = reqwest::Url::parse(referer) {
if let Some(host) = url.host_str() {
return host.trim_start_matches("www.").to_string();
}
}
// Fallback if not a valid URL
let cleaned = referer.trim_start_matches("https://").trim_start_matches("http://");
let cleaned = cleaned.split('/').next().unwrap_or("Direct");
if cleaned.is_empty() {
"Direct".to_string()
} else {
cleaned.trim_start_matches("www.").to_string()
}
}
pub fn insert_visits_batch(conn: &mut Connection, records: &[VisitRecord]) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare(
"INSERT INTO visits (id, target_type, target_id, timestamp, ip_address, user_agent, referer, accept_language, country, status_code)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);"
)?;
for r in records {
stmt.execute(params![
r.id,
r.target_type,
r.target_id,
r.timestamp,
r.ip_address,
r.user_agent,
r.referer,
r.accept_language,
r.country,
r.status_code
])?;
}
}
tx.commit()?;
Ok(())
}
pub fn get_total_clicks(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'url';", [], |row| row.get(0))
}
pub fn get_total_page_views(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM visits WHERE target_type = 'page';", [], |row| row.get(0))
}
// Get the date range of visits in the DB
pub fn get_visits_date_range(conn: &Connection) -> rusqlite::Result<Option<(String, String)>> {
let mut stmt = conn.prepare("SELECT MIN(date(timestamp)), MAX(date(timestamp)) FROM visits;")?;
let mut rows = stmt.query([])?;
if let Some(row) = rows.next()? {
let min_date: Option<String> = row.get(0)?;
let max_date: Option<String> = row.get(1)?;
if let (Some(min), Some(max)) = (min_date, max_date) {
return Ok(Some((min, max)));
}
}
Ok(None)
}
// Run aggregation for a specific day
pub fn aggregate_day(conn: &mut Connection, date: &str) -> rusqlite::Result<()> {
let mut visits = Vec::new();
{
// 1. Fetch all visits on that day
let mut stmt = conn.prepare(
"SELECT target_type, target_id, user_agent, referer, country, status_code FROM visits WHERE date(timestamp) = ?1;"
)?;
struct RawVisit {
target_type: String,
target_id: String,
user_agent: String,
referer: String,
country: String,
}
let rows = stmt.query_map(params![date], |row| {
Ok(RawVisit {
target_type: row.get(0)?,
target_id: row.get(1)?,
user_agent: row.get(2)?,
referer: row.get(3)?,
country: row.get(4)?,
})
})?;
for r in rows {
visits.push(r?);
}
}
if visits.is_empty() {
return Ok(());
}
// 2. Compute metrics in-memory
// Key structure: (target_type, target_id, metric_type, metric_key) -> count
let mut aggregates: HashMap<(String, String, String, String), i64> = HashMap::new();
// Also track total per day (all targets combined) using target_id = "all"
for v in visits {
let (browser, os, device) = parse_ua(&v.user_agent);
let referrer = clean_referrer(&v.referer);
let country = if v.country.is_empty() { "Unknown".to_string() } else { v.country.clone() };
let targets = vec![
(v.target_type.clone(), v.target_id.clone()),
(v.target_type.clone(), "all".to_string()),
];
for (t_type, t_id) in targets {
// Clicks
*aggregates.entry((t_type.clone(), t_id.clone(), "clicks".to_string(), "".to_string())).or_insert(0) += 1;
// Country
*aggregates.entry((t_type.clone(), t_id.clone(), "country".to_string(), country.clone())).or_insert(0) += 1;
// Browser
*aggregates.entry((t_type.clone(), t_id.clone(), "browser".to_string(), browser.clone())).or_insert(0) += 1;
// OS
*aggregates.entry((t_type.clone(), t_id.clone(), "os".to_string(), os.clone())).or_insert(0) += 1;
// Device
*aggregates.entry((t_type.clone(), t_id.clone(), "device".to_string(), device.clone())).or_insert(0) += 1;
// Referrer
*aggregates.entry((t_type.clone(), t_id.clone(), "referrer".to_string(), referrer.clone())).or_insert(0) += 1;
}
}
// 3. Save to database in a transaction
let tx = conn.transaction()?;
{
// Delete old aggregates for this day
tx.execute("DELETE FROM daily_summaries WHERE date = ?1;", params![date])?;
let mut insert_stmt = tx.prepare(
"INSERT INTO daily_summaries (date, target_type, target_id, metric_type, metric_key, metric_value)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);"
)?;
for ((t_type, t_id, m_type, m_key), value) in aggregates {
insert_stmt.execute(params![
date,
t_type,
t_id,
m_type,
m_key,
value
])?;
}
}
tx.commit()?;
// Update monthly and yearly summaries using the daily summaries
aggregate_month_from_daily(conn, &date[0..7])?;
aggregate_year_from_daily(conn, &date[0..4])?;
Ok(())
}
fn aggregate_month_from_daily(conn: &mut Connection, year_month: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM monthly_summaries WHERE year_month = ?1;", params![year_month])?;
tx.execute(
"INSERT INTO monthly_summaries (year_month, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
FROM daily_summaries
WHERE date LIKE ?2
GROUP BY target_type, target_id, metric_type, metric_key;",
params![year_month, format!("{}-%", year_month)],
)?;
}
tx.commit()?;
Ok(())
}
fn aggregate_year_from_daily(conn: &mut Connection, year: &str) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
tx.execute("DELETE FROM yearly_summaries WHERE year = ?1;", params![year])?;
tx.execute(
"INSERT INTO yearly_summaries (year, target_type, target_id, metric_type, metric_key, metric_value)
SELECT ?1, target_type, target_id, metric_type, metric_key, SUM(metric_value)
FROM daily_summaries
WHERE date LIKE ?2
GROUP BY target_type, target_id, metric_type, metric_key;",
params![year, format!("{}-%", year)],
)?;
}
tx.commit()?;
Ok(())
}
// Clean old raw visit records
pub fn retention_cleanup(conn: &Connection, retention_days: i64) -> rusqlite::Result<usize> {
let limit_date = chrono::Utc::now() - chrono::Duration::days(retention_days);
let limit_str = limit_date.to_rfc3339();
let count = conn.execute("DELETE FROM visits WHERE timestamp < ?1;", params![limit_str])?;
Ok(count)
}
// --- Query functions for Dashboard & API ---
pub fn get_clicks_trend(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).format("%Y-%m-%d").to_string();
let mut stmt = conn.prepare(
"SELECT date, SUM(metric_value) FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = 'clicks' AND date >= ?3
GROUP BY date ORDER BY date ASC;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
// Fallback to query raw visits table if daily summaries are not aggregated yet
pub fn get_clicks_trend_raw(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
let mut stmt = conn.prepare(
"SELECT date(timestamp) as d, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY d ORDER BY d ASC;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
pub fn get_hourly_trend_raw(
conn: &Connection,
target_type: &str,
target_id: &str,
limit_days: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let limit_date = (chrono::Utc::now() - chrono::Duration::days(limit_days)).to_rfc3339();
// SQLite strftime('%H', timestamp) extracts the hour
let mut stmt = conn.prepare(
"SELECT strftime('%H', timestamp) as h, COUNT(*) FROM visits
WHERE target_type = ?1 AND target_id = ?2 AND timestamp >= ?3
GROUP BY h ORDER BY h ASC;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit_date], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
pub fn get_metric_rankings(
conn: &Connection,
target_type: &str,
target_id: &str,
metric_type: &str,
limit: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
let mut stmt = conn.prepare(
"SELECT metric_key, SUM(metric_value) as val FROM daily_summaries
WHERE target_type = ?1 AND target_id = ?2 AND metric_type = ?3
GROUP BY metric_key ORDER BY val DESC LIMIT ?4;"
)?;
let rows = stmt.query_map(params![target_type, target_id, metric_type, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
pub fn get_metric_rankings_raw(
conn: &Connection,
target_type: &str,
target_id: &str,
metric_type: &str,
limit: i64,
) -> rusqlite::Result<Vec<(String, i64)>> {
// Falls back to direct query on visits
let mut res = Vec::new();
match metric_type {
"country" => {
let mut stmt = conn.prepare(
"SELECT country, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY country ORDER BY c DESC LIMIT ?3;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
for r in rows { res.push(r?); }
}
"referrer" => {
let mut stmt = conn.prepare(
"SELECT referer, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY referer ORDER BY c DESC LIMIT ?3;"
)?;
let rows = stmt.query_map(params![target_type, target_id, limit], |row| {
let raw_ref: String = row.get(0)?;
Ok((clean_referrer(&raw_ref), row.get::<_, i64>(1)?))
})?;
// Re-aggregate because clean_referrer might group different referrers
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
let (k, v) = r?;
*grouped.entry(k).or_insert(0) += v;
}
res = grouped.into_iter().collect();
res.sort_by_key(|b| std::cmp::Reverse(b.1));
res.truncate(limit as usize);
}
"browser" | "os" | "device" => {
let mut stmt = conn.prepare(
"SELECT user_agent, COUNT(*) as c FROM visits
WHERE target_type = ?1 AND target_id = ?2
GROUP BY user_agent;"
)?;
let rows = stmt.query_map(params![target_type, target_id], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
})?;
let mut grouped: HashMap<String, i64> = HashMap::new();
for r in rows {
let (ua, count) = r?;
let (b, o, d) = parse_ua(&ua);
let key = match metric_type {
"browser" => b,
"os" => o,
_ => d,
};
*grouped.entry(key).or_insert(0) += count;
}
res = grouped.into_iter().collect();
res.sort_by_key(|b| std::cmp::Reverse(b.1));
res.truncate(limit as usize);
}
_ => {}
}
Ok(res)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_ua_browsers() {
let firefox_linux = "Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/119.0";
let chrome_win = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36";
let safari_mac = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15";
let android_phone = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Mobile Safari/537.36";
assert_eq!(parse_ua(firefox_linux), ("Firefox".to_string(), "Linux".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(chrome_win), ("Chrome".to_string(), "Windows".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(safari_mac), ("Safari".to_string(), "macOS".to_string(), "Desktop".to_string()));
assert_eq!(parse_ua(android_phone), ("Chrome".to_string(), "Android".to_string(), "Mobile".to_string()));
}
#[test]
fn test_clean_referrer() {
assert_eq!(clean_referrer("direct"), "Direct");
assert_eq!(clean_referrer(""), "Direct");
assert_eq!(clean_referrer("https://github.com/rust-lang/rust"), "github.com");
assert_eq!(clean_referrer("http://www.google.com/search?q=rust"), "google.com");
assert_eq!(clean_referrer("reddit.com/r/rust"), "reddit.com");
}
}
+384
View File
@@ -0,0 +1,384 @@
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc;
use crate::models::{Url, LandingPage};
// Helper: Associate tags with a URL
fn associate_tags(conn: &Connection, url_id: &str, tags: &[String]) -> rusqlite::Result<()> {
conn.execute("DELETE FROM url_tags WHERE url_id = ?1;", params![url_id])?;
for tag_name in tags {
let tag_name = tag_name.trim().to_lowercase();
if tag_name.is_empty() {
continue;
}
// Insert tag if it doesn't exist
conn.execute(
"INSERT OR IGNORE INTO tags (id, name) VALUES (?1, ?2);",
params![Uuid::new_v4().to_string(), tag_name],
)?;
// Get tag id
let tag_id: String = conn.query_row(
"SELECT id FROM tags WHERE name = ?1;",
params![tag_name],
|row| row.get(0),
)?;
// Insert association
conn.execute(
"INSERT OR IGNORE INTO url_tags (url_id, tag_id) VALUES (?1, ?2);",
params![url_id, tag_id],
)?;
}
Ok(())
}
// Helper: Get tags for a URL
pub fn get_tags_for_url(conn: &Connection, url_id: &str) -> rusqlite::Result<Vec<String>> {
let mut stmt = conn.prepare(
"SELECT t.name FROM tags t JOIN url_tags ut ON t.id = ut.tag_id WHERE ut.url_id = ?1 ORDER BY t.name;"
)?;
let rows = stmt.query_map(params![url_id], |row| row.get::<_, String>(0))?;
let mut tags = Vec::new();
for tag in rows {
tags.push(tag?);
}
Ok(tags)
}
pub fn create_url(
conn: &Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
) -> rusqlite::Result<Url> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let status = "healthy".to_string();
conn.execute(
"INSERT INTO urls (id, code, destination, title, description, status, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, code, destination, title, description, status, now, now],
)?;
associate_tags(conn, &id, tags)?;
Ok(Url {
id,
code: code.to_string(),
destination: destination.to_string(),
title: title.map(|s| s.to_string()),
description: description.map(|s| s.to_string()),
status,
created_at: now.clone(),
updated_at: now,
tags: tags.to_vec(),
})
}
pub fn get_url_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE id = ?1;"
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
} else {
Ok(None)
}
}
pub fn get_url_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<Url>> {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at FROM urls WHERE code = ?1;"
)?;
let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? {
let url_id: String = row.get(0)?;
let tags = get_tags_for_url(conn, &url_id)?;
Ok(Some(Url {
id: url_id,
code: row.get(1)?,
destination: row.get(2)?,
title: row.get(3)?,
description: row.get(4)?,
status: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
tags,
}))
} else {
Ok(None)
}
}
pub fn update_url(
conn: &Connection,
id: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
status: &str,
tags: &[String],
) -> rusqlite::Result<Option<Url>> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE urls SET destination = ?1, title = ?2, description = ?3, status = ?4, updated_at = ?5 WHERE id = ?6;",
params![destination, title, description, status, now, id],
)?;
if count == 0 {
return Ok(None);
}
associate_tags(conn, id, tags)?;
get_url_by_id(conn, id)
}
pub fn delete_url(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let count = conn.execute("DELETE FROM urls WHERE id = ?1;", params![id])?;
Ok(count > 0)
}
pub fn list_urls(
conn: &Connection,
limit: i64,
offset: i64,
tag_filter: Option<&str>,
) -> rusqlite::Result<Vec<Url>> {
let mut urls = Vec::new();
if let Some(tag) = tag_filter {
let tag_name = tag.trim().to_lowercase();
let mut stmt = conn.prepare(
"SELECT u.id, u.code, u.destination, u.title, u.description, u.status, u.created_at, u.updated_at
FROM urls u
JOIN url_tags ut ON u.id = ut.url_id
JOIN tags t ON ut.tag_id = t.id
WHERE t.name = ?1
ORDER BY u.created_at DESC LIMIT ?2 OFFSET ?3;"
)?;
let rows = stmt.query_map(params![tag_name, limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
}
} else {
let mut stmt = conn.prepare(
"SELECT id, code, destination, title, description, status, created_at, updated_at
FROM urls ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
let url_id: String = row.get(0)?;
Ok((url_id, row.get(1)?, row.get(2)?, row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?, row.get(7)?))
})?;
for r in rows {
let (url_id, code, destination, title, description, status, created_at, updated_at) = r?;
let tags = get_tags_for_url(conn, &url_id)?;
urls.push(Url {
id: url_id,
code,
destination,
title,
description,
status,
created_at,
updated_at,
tags,
});
}
}
Ok(urls)
}
pub fn list_urls_for_health_check(conn: &Connection) -> rusqlite::Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT id, destination FROM urls;")?;
let rows = stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?;
let mut res = Vec::new();
for r in rows {
res.push(r?);
}
Ok(res)
}
pub fn update_url_health(conn: &Connection, id: &str, status: &str) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
conn.execute(
"UPDATE urls SET status = ?1, updated_at = ?2 WHERE id = ?3;",
params![status, now, id],
)?;
Ok(())
}
pub fn get_url_counts(conn: &Connection) -> rusqlite::Result<(i64, i64, i64)> {
let total: i64 = conn.query_row("SELECT COUNT(*) FROM urls;", [], |row| row.get(0))?;
let active: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status IN ('healthy', 'suspect');", [], |row| row.get(0))?;
let dead: i64 = conn.query_row("SELECT COUNT(*) FROM urls WHERE status = 'dead';", [], |row| row.get(0))?;
Ok((total, active, dead))
}
pub fn create_landing_page(
conn: &Connection,
code: &str,
slug: &str,
title: &str,
html_content: &str,
state: &str,
) -> rusqlite::Result<LandingPage> {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
conn.execute(
"INSERT INTO landing_pages (id, code, slug, title, html_content, state, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![id, code, slug, title, html_content, state, now, now],
)?;
Ok(LandingPage {
id,
code: code.to_string(),
slug: slug.to_string(),
title: title.to_string(),
html_content: html_content.to_string(),
state: state.to_string(),
created_at: now.clone(),
updated_at: now,
})
}
pub fn get_landing_page_by_id(conn: &Connection, id: &str) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE id = ?1;"
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
Ok(Some(LandingPage {
id: row.get(0)?,
code: row.get(1)?,
slug: row.get(2)?,
title: row.get(3)?,
html_content: row.get(4)?,
state: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
}))
} else {
Ok(None)
}
}
pub fn get_landing_page_by_code(conn: &Connection, code: &str) -> rusqlite::Result<Option<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at FROM landing_pages WHERE code = ?1;"
)?;
let mut rows = stmt.query(params![code])?;
if let Some(row) = rows.next()? {
Ok(Some(LandingPage {
id: row.get(0)?,
code: row.get(1)?,
slug: row.get(2)?,
title: row.get(3)?,
html_content: row.get(4)?,
state: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
}))
} else {
Ok(None)
}
}
pub fn update_landing_page(
conn: &Connection,
id: &str,
slug: &str,
title: &str,
html_content: &str,
state: &str,
) -> rusqlite::Result<Option<LandingPage>> {
let now = Utc::now().to_rfc3339();
let count = conn.execute(
"UPDATE landing_pages SET slug = ?1, title = ?2, html_content = ?3, state = ?4, updated_at = ?5 WHERE id = ?6;",
params![slug, title, html_content, state, now, id],
)?;
if count == 0 {
return Ok(None);
}
get_landing_page_by_id(conn, id)
}
pub fn delete_landing_page(conn: &Connection, id: &str) -> rusqlite::Result<bool> {
let count = conn.execute("DELETE FROM landing_pages WHERE id = ?1;", params![id])?;
Ok(count > 0)
}
pub fn list_landing_pages(conn: &Connection, limit: i64, offset: i64) -> rusqlite::Result<Vec<LandingPage>> {
let mut stmt = conn.prepare(
"SELECT id, code, slug, title, html_content, state, created_at, updated_at
FROM landing_pages ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
)?;
let rows = stmt.query_map(params![limit, offset], |row| {
Ok(LandingPage {
id: row.get(0)?,
code: row.get(1)?,
slug: row.get(2)?,
title: row.get(3)?,
html_content: row.get(4)?,
state: row.get(5)?,
created_at: row.get(6)?,
updated_at: row.get(7)?,
})
})?;
let mut pages = Vec::new();
for page in rows {
pages.push(page?);
}
Ok(pages)
}
pub fn get_landing_page_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM landing_pages;", [], |row| row.get(0))
}
+294
View File
@@ -0,0 +1,294 @@
use std::sync::Mutex;
use chrono::Utc;
use rusqlite::Connection;
use tracing::info;
use uuid::Uuid;
/// A single versioned migration with a human-readable name.
pub struct Migration {
pub version: u32,
pub name: &'static str,
pub sql: &'static str,
}
/// Run all pending migrations against `conn`, recording audit entries in `system_db_opt`.
///
/// Migrations are applied in order. Each migration runs inside a transaction,
/// and the schema version is bumped only after a successful commit.
pub fn run_migrations(
conn: &mut Connection,
db_name: &str,
migrations: &[Migration],
system_db_opt: Option<&Mutex<Connection>>,
) -> Result<(), Box<dyn std::error::Error>> {
let current_version = crate::db::sqlite::get_user_version(conn)?;
let target_version = migrations.last().map_or(0, |m| m.version);
if current_version < target_version {
for m in migrations.iter().filter(|m| m.version > current_version) {
info!(database = db_name, version = m.version, name = m.name, "Applying migration");
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
tx.commit()?;
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
info!(database = db_name, version = m.version, name = m.name, "Migration completed");
// Write audit record to system.db.migrations
if let Some(sys_db_mutex) = system_db_opt {
if let Ok(sys_db) = sys_db_mutex.lock() {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let _ = sys_db.execute(
"INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![id, db_name, m.version as i32, now],
);
}
} else if db_name == "system" {
// If migrating system.db itself, write directly to its own migrations table
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO migrations (id, db_name, version, applied_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![id, db_name, m.version as i32, now],
);
}
}
} else {
info!(database = db_name, version = current_version, "Database up to date");
}
Ok(())
}
/// Print a dry-run migration plan to stdout without applying any changes.
pub fn print_migration_plan(
conn: &Connection,
db_name: &str,
migrations: &[Migration],
) -> Result<(), Box<dyn std::error::Error>> {
let current_version = crate::db::sqlite::get_user_version(conn)?;
let target_version = migrations.last().map_or(0, |m| m.version);
println!("Database: {db_name}");
println!(" Current version: {current_version}");
println!(" Target version: {target_version}");
let pending: Vec<&Migration> = migrations.iter().filter(|m| m.version > current_version).collect();
if pending.is_empty() {
println!(" Status: up to date");
} else {
for m in pending {
println!(" Would apply: v{} {}", m.version, m.name);
}
}
Ok(())
}
// ---------------------------------------------------------------------------
// Migration definitions
// ---------------------------------------------------------------------------
pub const ADMIN_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS config (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
"#,
},
];
pub const CONTENT_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS urls (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
destination TEXT NOT NULL,
title TEXT,
description TEXT,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS landing_pages (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL,
title TEXT NOT NULL,
html_content TEXT NOT NULL,
state TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS tags (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS url_tags (
url_id TEXT NOT NULL,
tag_id TEXT NOT NULL,
PRIMARY KEY (url_id, tag_id),
FOREIGN KEY(url_id) REFERENCES urls(id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tags(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_urls_code ON urls(code);
CREATE INDEX IF NOT EXISTS idx_pages_code ON landing_pages(code);
"#,
},
];
pub const ANALYTICS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS visits (
id TEXT PRIMARY KEY,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
timestamp TEXT NOT NULL,
ip_address TEXT NOT NULL,
user_agent TEXT NOT NULL,
referer TEXT NOT NULL,
accept_language TEXT NOT NULL,
country TEXT NOT NULL,
status_code INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS daily_summaries (
date TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
metric_key TEXT NOT NULL,
metric_value INTEGER NOT NULL,
PRIMARY KEY (date, target_type, target_id, metric_type, metric_key)
);
CREATE TABLE IF NOT EXISTS monthly_summaries (
year_month TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
metric_key TEXT NOT NULL,
metric_value INTEGER NOT NULL,
PRIMARY KEY (year_month, target_type, target_id, metric_type, metric_key)
);
CREATE TABLE IF NOT EXISTS yearly_summaries (
year TEXT NOT NULL,
target_type TEXT NOT NULL,
target_id TEXT NOT NULL,
metric_type TEXT NOT NULL,
metric_key TEXT NOT NULL,
metric_value INTEGER NOT NULL,
PRIMARY KEY (year, target_type, target_id, metric_type, metric_key)
);
CREATE INDEX IF NOT EXISTS idx_visits_timestamp ON visits(timestamp);
CREATE INDEX IF NOT EXISTS idx_visits_target ON visits(target_type, target_id);
"#,
},
];
pub const SYSTEM_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS migrations (
id TEXT PRIMARY KEY,
db_name TEXT NOT NULL,
version INTEGER NOT NULL,
applied_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS job_history (
id TEXT PRIMARY KEY,
job_name TEXT NOT NULL,
status TEXT NOT NULL,
started_at TEXT NOT NULL,
finished_at TEXT,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS health_checks (
id TEXT PRIMARY KEY,
object_type TEXT NOT NULL,
object_id TEXT NOT NULL,
checked_at TEXT NOT NULL,
status_code INTEGER,
error_message TEXT,
is_healthy INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS backup_history (
id TEXT PRIMARY KEY,
backup_path TEXT NOT NULL,
status TEXT NOT NULL,
created_at TEXT NOT NULL,
size_bytes INTEGER,
error_message TEXT
);
CREATE TABLE IF NOT EXISTS system_events (
id TEXT PRIMARY KEY,
event_type TEXT NOT NULL,
timestamp TEXT NOT NULL,
details TEXT NOT NULL
);
"#,
},
];
+126
View File
@@ -0,0 +1,126 @@
use std::fs;
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::config::Config;
use crate::db::migrations::{run_migrations, ADMIN_MIGRATIONS, CONTENT_MIGRATIONS, ANALYTICS_MIGRATIONS, SYSTEM_MIGRATIONS};
use crate::db::sqlite::{enable_foreign_keys, enable_wal};
pub mod migrations;
pub mod sqlite;
pub mod admin;
pub mod content;
pub mod analytics;
#[derive(Clone)]
pub struct Db {
pub admin: Arc<Mutex<Connection>>,
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
}
let admin_path = config.data_dir.join("admin.db");
let content_path = config.data_dir.join("content.db");
let analytics_path = config.data_dir.join("analytics.db");
let system_path = config.data_dir.join("system.db");
use tracing::info;
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening content.db");
let mut content_conn = Connection::open(content_path)?;
info!("Opening analytics.db");
let mut analytics_conn = Connection::open(analytics_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
// Enable WAL mode for better concurrency and write performance
info!(database = "admin", "Enabling WAL mode on admin.db");
enable_wal(&admin_conn, "admin")?;
info!(database = "content", "Enabling WAL mode on content.db");
enable_wal(&content_conn, "content")?;
info!(database = "analytics", "Enabling WAL mode on analytics.db");
enable_wal(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling WAL mode on system.db");
enable_wal(&system_conn, "system")?;
// Enable foreign key support
info!(database = "admin", "Enabling foreign key enforcement on admin.db");
enable_foreign_keys(&admin_conn, "admin")?;
info!(database = "content", "Enabling foreign key enforcement on content.db");
enable_foreign_keys(&content_conn, "content")?;
info!(database = "analytics", "Enabling foreign key enforcement on analytics.db");
enable_foreign_keys(&analytics_conn, "analytics")?;
info!(database = "system", "Enabling foreign key enforcement on system.db");
enable_foreign_keys(&system_conn, "system")?;
// 1. Run migrations for system.db first, as it receives secondary audit records
info!("Running system migrations");
run_migrations(&mut system_conn, "system", SYSTEM_MIGRATIONS, None)?;
let system_arc = Arc::new(Mutex::new(system_conn));
// 2. Run migrations for other databases with system.db logging
info!("Running admin migrations");
run_migrations(&mut admin_conn, "admin", ADMIN_MIGRATIONS, Some(&system_arc))?;
info!("Running content migrations");
run_migrations(&mut content_conn, "content", CONTENT_MIGRATIONS, Some(&system_arc))?;
info!("Running analytics migrations");
run_migrations(&mut analytics_conn, "analytics", ANALYTICS_MIGRATIONS, Some(&system_arc))?;
Ok(Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
})
}
pub fn compact(&self) -> Result<(), rusqlite::Error> {
let admin = self.admin.lock().unwrap();
admin.execute("VACUUM;", [])?;
let content = self.content.lock().unwrap();
content.execute("VACUUM;", [])?;
let analytics = self.analytics.lock().unwrap();
analytics.execute("VACUUM;", [])?;
let system = self.system.lock().unwrap();
system.execute("VACUUM;", [])?;
Ok(())
}
}
#[cfg(test)]
mod db_init_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_db_init() {
let temp_dir = PathBuf::from("./temp_test_db_dir");
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let db = Db::init(&config);
// Cleanup
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
assert!(db.is_ok(), "Failed to init DB: {:?}", db.err());
}
}
+191
View File
@@ -0,0 +1,191 @@
//! Strongly-typed SQLite PRAGMA and configuration helpers.
//!
//! This module provides safe wrappers around common SQLite PRAGMAs using
//! rusqlite's type-safe APIs (`pragma_update`, `pragma_query_value`, `query_row`)
//! instead of raw `execute` calls. All functions use structured tracing for
//! observability.
use rusqlite::Connection;
use serde::Serialize;
use tracing::info;
/// Enables WAL (Write-Ahead Logging) journal mode on the given connection.
///
/// Uses `query_row` with `PRAGMA journal_mode=WAL` which both sets and returns
/// the actual mode. Returns an error if the database does not confirm WAL mode.
pub fn enable_wal(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let actual_mode: String =
conn.query_row("PRAGMA journal_mode=WAL;", [], |row| row.get::<_, String>(0))?;
info!(database = db_name, mode = %actual_mode, "WAL mode configured");
if actual_mode.to_lowercase() != "wal" {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
/// Enables foreign key constraint enforcement on the given connection.
///
/// Sets `foreign_keys` to ON via `pragma_update`, then verifies the setting
/// was applied by reading it back with `pragma_query_value`.
pub fn enable_foreign_keys(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
conn.pragma_update(None, "foreign_keys", "ON")?;
let enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
info!(database = db_name, foreign_keys = enabled, "Foreign key enforcement configured");
if !enabled {
return Err(rusqlite::Error::QueryReturnedNoRows);
}
Ok(())
}
/// Sets the schema user_version on the given connection.
///
/// Uses `pragma_update` with the type-safe API — no `format!` string, no raw
/// `execute`.
pub fn set_user_version(conn: &Connection, version: i32) -> Result<(), rusqlite::Error> {
conn.pragma_update(None, "user_version", version)
}
/// Returns the current schema user_version from the given connection.
pub fn get_user_version(conn: &Connection) -> Result<u32, rusqlite::Error> {
conn.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
}
/// Runs `PRAGMA integrity_check` and returns `Ok(())` if the database reports "ok".
///
/// If the integrity check returns any other value, the function returns an error
/// containing the integrity check message.
pub fn integrity_check(conn: &Connection, db_name: &str) -> Result<(), rusqlite::Error> {
let result: String =
conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?;
if result == "ok" {
info!(database = db_name, "Integrity check passed");
Ok(())
} else {
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CORRUPT),
Some(format!("Integrity check failed for {db_name}: {result}")),
))
}
}
/// Returns the current journal mode of the given connection.
pub fn get_journal_mode(conn: &Connection) -> Result<String, rusqlite::Error> {
conn.pragma_query_value(None, "journal_mode", |row| row.get::<_, String>(0))
}
/// A snapshot of database health information collected from various PRAGMAs.
#[derive(Debug, Clone, Serialize)]
pub struct DatabaseHealthReport {
/// Name of the database (e.g. "admin", "content").
pub database: String,
/// Current schema version (`user_version` PRAGMA).
pub schema_version: u32,
/// Active journal mode (e.g. "wal", "delete").
pub journal_mode: String,
/// Whether foreign key enforcement is enabled.
pub foreign_keys_enabled: bool,
/// Whether `PRAGMA integrity_check` returned "ok".
pub integrity_ok: bool,
}
/// Collects a [`DatabaseHealthReport`] by querying all relevant PRAGMAs.
///
/// This function queries `user_version`, `journal_mode`, `foreign_keys`, and
/// `integrity_check` to build a comprehensive health snapshot. The report is
/// logged at `info` level with structured fields.
pub fn collect_health_report(
conn: &Connection,
db_name: &str,
) -> Result<DatabaseHealthReport, rusqlite::Error> {
let schema_version = get_user_version(conn)?;
let journal_mode = get_journal_mode(conn)?;
let foreign_keys_enabled: bool =
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?;
let integrity_result: String =
conn.query_row("PRAGMA integrity_check;", [], |row| row.get::<_, String>(0))?;
let integrity_ok = integrity_result == "ok";
let report = DatabaseHealthReport {
database: db_name.to_owned(),
schema_version,
journal_mode,
foreign_keys_enabled,
integrity_ok,
};
info!(
database = %report.database,
version = report.schema_version,
journal_mode = %report.journal_mode,
foreign_keys = report.foreign_keys_enabled,
integrity = report.integrity_ok,
"Database health report collected"
);
Ok(report)
}
#[cfg(test)]
mod tests {
use super::*;
use rusqlite::Connection;
fn memory_conn() -> Connection {
Connection::open_in_memory().expect("Failed to open in-memory database")
}
#[test]
fn test_enable_wal() {
let conn = memory_conn();
// In-memory databases may not support WAL; we just verify no panic.
// On-disk databases would return "wal".
let _ = enable_wal(&conn, "test");
}
#[test]
fn test_enable_foreign_keys() {
let conn = memory_conn();
enable_foreign_keys(&conn, "test").expect("Failed to enable foreign keys");
}
#[test]
fn test_user_version_roundtrip() {
let conn = memory_conn();
set_user_version(&conn, 42).expect("Failed to set user_version");
let v = get_user_version(&conn).expect("Failed to get user_version");
assert_eq!(v, 42);
}
#[test]
fn test_get_journal_mode() {
let conn = memory_conn();
let mode = get_journal_mode(&conn).expect("Failed to get journal_mode");
assert!(!mode.is_empty());
}
#[test]
fn test_integrity_check() {
let conn = memory_conn();
integrity_check(&conn, "test").expect("Integrity check should pass on fresh db");
}
#[test]
fn test_collect_health_report() {
let conn = memory_conn();
let report =
collect_health_report(&conn, "test").expect("Failed to collect health report");
assert_eq!(report.database, "test");
assert!(report.integrity_ok);
}
}
+153
View File
@@ -0,0 +1,153 @@
use axum::{
response::{IntoResponse, Response},
http::StatusCode,
};
use std::fmt;
use std::path::PathBuf;
/// Structured error type for database initialization and migration failures.
///
/// Each variant provides actionable context about what went wrong during startup,
/// making diagnosis possible from logs alone without needing a debugger.
#[derive(Debug)]
pub enum DatabaseInitError {
/// Data directory could not be created or accessed
DataDirCreate { path: PathBuf, source: std::io::Error },
/// SQLite connection could not be opened
ConnectionOpen { database: String, path: PathBuf, source: rusqlite::Error },
/// PRAGMA configuration failed (WAL, foreign_keys, etc.)
PragmaConfig { database: String, pragma: String, source: rusqlite::Error },
/// Migration execution failed
MigrationFailed { database: String, version: u32, name: String, source: Box<dyn std::error::Error + Send + Sync> },
/// Database integrity check failed
IntegrityCheckFailed { database: String, message: String },
/// WAL mode could not be enabled (returned unexpected mode)
WalModeFailed { database: String, actual_mode: String },
}
impl fmt::Display for DatabaseInitError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::DataDirCreate { path, source } => {
write!(f, "Failed to create data directory {:?}: {}", path, source)
}
Self::ConnectionOpen { database, path, source } => {
write!(f, "Failed to open {}.db at {:?}: {}", database, path, source)
}
Self::PragmaConfig { database, pragma, source } => {
write!(f, "PRAGMA {} failed on {}.db: {}", pragma, database, source)
}
Self::MigrationFailed { database, version, name, source } => {
write!(f, "Migration v{} ({}) failed on {}.db: {}", version, name, database, source)
}
Self::IntegrityCheckFailed { database, message } => {
write!(f, "Integrity check failed on {}.db: {}", database, message)
}
Self::WalModeFailed { database, actual_mode } => {
write!(f, "WAL mode not enabled on {}.db (got '{}')", database, actual_mode)
}
}
}
}
impl std::error::Error for DatabaseInitError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::DataDirCreate { source, .. } => Some(source),
Self::ConnectionOpen { source, .. } => Some(source),
Self::PragmaConfig { source, .. } => Some(source),
Self::MigrationFailed { source, .. } => Some(source.as_ref()),
_ => None,
}
}
}
#[derive(Debug)]
pub enum AppError {
Db(rusqlite::Error),
Auth(String),
Template(askama::Error),
Json(serde_json::Error),
Io(std::io::Error),
Http(String),
NotFound(String),
BadRequest(String),
Unauthorized(String),
Internal(String),
}
impl fmt::Display for AppError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppError::Db(e) => write!(f, "Database error: {}", e),
AppError::Auth(e) => write!(f, "Authentication error: {}", e),
AppError::Template(e) => write!(f, "Template render error: {}", e),
AppError::Json(e) => write!(f, "JSON processing error: {}", e),
AppError::Io(e) => write!(f, "IO error: {}", e),
AppError::Http(e) => write!(f, "HTTP request error: {}", e),
AppError::NotFound(e) => write!(f, "Not found: {}", e),
AppError::BadRequest(e) => write!(f, "Bad request: {}", e),
AppError::Unauthorized(e) => write!(f, "Unauthorized: {}", e),
AppError::Internal(e) => write!(f, "Internal error: {}", e),
}
}
}
impl std::error::Error for AppError {}
impl From<rusqlite::Error> for AppError {
fn from(err: rusqlite::Error) -> Self {
AppError::Db(err)
}
}
impl From<askama::Error> for AppError {
fn from(err: askama::Error) -> Self {
AppError::Template(err)
}
}
impl From<serde_json::Error> for AppError {
fn from(err: serde_json::Error) -> Self {
AppError::Json(err)
}
}
impl From<std::io::Error> for AppError {
fn from(err: std::io::Error) -> Self {
AppError::Io(err)
}
}
impl From<argon2::password_hash::Error> for AppError {
fn from(err: argon2::password_hash::Error) -> Self {
AppError::Auth(err.to_string())
}
}
impl From<reqwest::Error> for AppError {
fn from(err: reqwest::Error) -> Self {
AppError::Http(err.to_string())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) => StatusCode::NOT_FOUND,
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
AppError::Auth(_) => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
};
let message = self.to_string();
if status == StatusCode::INTERNAL_SERVER_ERROR {
tracing::error!("AppError encountered: {:?}", self);
}
// Return error details as text. Handlers requiring custom UI/JSON can catch errors
// or map them prior to calling into_response.
(status, message).into_response()
}
}
+53
View File
@@ -0,0 +1,53 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use crate::analytics::aggregate_day;
use super::{log_job_start, log_job_end};
pub async fn run_aggregator(db: Db, interval_mins: u64) {
loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background analytics aggregator...");
let job_id = log_job_start(&db.system, "analytics_aggregator");
match perform_aggregation(&db).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing aggregation: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_aggregation(db: &Db) -> Result<(), Box<dyn std::error::Error>> {
let date_range = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_visits_date_range(&conn)?
};
if let Some((min_date, max_date)) = date_range {
let min = chrono::NaiveDate::parse_from_str(&min_date, "%Y-%m-%d")?;
let max = chrono::NaiveDate::parse_from_str(&max_date, "%Y-%m-%d")?;
let mut curr = min;
while curr <= max {
let date_str = curr.format("%Y-%m-%d").to_string();
{
let mut conn = db.analytics.lock().unwrap();
aggregate_day(&mut conn, &date_str)?;
}
if curr == max {
break;
}
if let Some(next) = curr.succ_opt() {
curr = next;
} else {
break;
}
}
}
Ok(())
}
+81
View File
@@ -0,0 +1,81 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use crate::config::Config;
use super::{log_job_start, log_job_end};
pub async fn run_backup_scheduler(db: Db, config: Config) {
if !config.backup_enabled {
info!("Background backup scheduler is disabled.");
return;
}
info!("Starting background backup scheduler (interval: {} mins)...", config.backup_interval_mins);
loop {
// Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup");
match perform_backup(&db, &config).await {
Ok(path) => {
info!("Backup created successfully at {}", path);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error performing backup: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_backup(db: &Db, config: &Config) -> Result<String, Box<dyn std::error::Error>> {
use std::fs::File;
use flate2::write::GzEncoder;
use flate2::Compression;
use tar::Builder;
use chrono::Utc;
use rusqlite::params;
use uuid::Uuid;
let out_dir = config.backup_dir.clone();
if !out_dir.exists() {
std::fs::create_dir_all(&out_dir)?;
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
let tar_name = format!("{}-bzod-backup.tar.gz", date_str);
let tar_path = out_dir.join(tar_name);
let file = File::create(&tar_path)?;
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
for f in files {
let db_file = config.data_dir.join(f);
if db_file.exists() {
tar.append_path_with_name(&db_file, f)?;
}
}
tar.into_inner()?.finish()?;
let size_bytes = std::fs::metadata(&tar_path)?.len();
let path_str = tar_path.to_string_lossy().to_string();
// Log to system.db.backup_history
{
let conn = db.system.lock().unwrap();
let backup_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO backup_history (id, backup_path, status, created_at, size_bytes, error_message)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
params![backup_id, path_str, "success", now, size_bytes as i64, None::<String>],
);
}
Ok(path_str)
}
+94
View File
@@ -0,0 +1,94 @@
use reqwest::Client;
use std::time::Duration;
use tracing::{info, error};
use uuid::Uuid;
use chrono::Utc;
use rusqlite::params;
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_link_checker(db: Db, interval_mins: u64) {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
.build()
.unwrap_or_default();
loop {
// Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker");
match perform_link_check(&db, &client).await {
Ok(_) => log_job_end(&db.system, &job_id, "success", None),
Err(e) => {
let err_str = e.to_string();
error!("Error performing link health check: {}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
pub async fn perform_link_check(db: &Db, client: &Client) -> Result<(), Box<dyn std::error::Error>> {
let urls = {
let conn = db.content.lock().unwrap();
crate::db::content::list_urls_for_health_check(&conn)?
};
for (id, dest) in urls {
let (status, status_code, err_msg) = check_url_health(client, &dest).await;
{
let conn = db.content.lock().unwrap();
crate::db::content::update_url_health(&conn, &id, &status)?;
}
// Log to system.db.health_checks
{
let conn = db.system.lock().unwrap();
let hc_id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
let is_healthy = if status == "healthy" { 1 } else { 0 };
let _ = conn.execute(
"INSERT INTO health_checks (id, object_type, object_id, checked_at, status_code, error_message, is_healthy)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
params![hc_id, "url", id, now, status_code, err_msg, is_healthy],
);
}
// Rate limiting sleep between external requests
tokio::time::sleep(Duration::from_millis(200)).await;
}
Ok(())
}
async fn check_url_health(client: &Client, url: &str) -> (String, Option<u16>, Option<String>) {
let res = client.get(url)
.timeout(Duration::from_secs(5))
.send()
.await;
match res {
Ok(response) => {
let status = response.status();
let code = status.as_u16();
if status.is_success() || status.is_redirection() {
("healthy".to_string(), Some(code), None)
} else if status == reqwest::StatusCode::NOT_FOUND || status == reqwest::StatusCode::GONE {
("dead".to_string(), Some(code), Some(format!("HTTP {}", code)))
} else {
("suspect".to_string(), Some(code), Some(format!("HTTP {}", code)))
}
}
Err(err) => {
let err_str = err.to_string();
if err.is_timeout() || err.is_connect() {
("suspect".to_string(), None, Some(err_str))
} else {
("dead".to_string(), None, Some(err_str))
}
}
}
}
+35
View File
@@ -0,0 +1,35 @@
use std::sync::Mutex;
use rusqlite::{Connection, params};
use uuid::Uuid;
use chrono::Utc;
pub mod healthcheck;
pub mod retention;
pub mod aggregate;
pub mod backup;
pub use healthcheck::{run_link_checker, perform_link_check};
pub use retention::run_retention_cleaner;
pub use aggregate::{run_aggregator, perform_aggregation};
pub fn log_job_start(conn: &Mutex<Connection>, job_name: &str) -> String {
let id = Uuid::new_v4().to_string();
let now = Utc::now().to_rfc3339();
if let Ok(c) = conn.lock() {
let _ = c.execute(
"INSERT INTO job_history (id, job_name, status, started_at) VALUES (?1, ?2, ?3, ?4);",
params![id, job_name, "running", now],
);
}
id
}
pub fn log_job_end(conn: &Mutex<Connection>, id: &str, status: &str, err_msg: Option<&str>) {
let now = Utc::now().to_rfc3339();
if let Ok(c) = conn.lock() {
let _ = c.execute(
"UPDATE job_history SET status = ?1, finished_at = ?2, error_message = ?3 WHERE id = ?4;",
params![status, now, err_msg, id],
);
}
}
+32
View File
@@ -0,0 +1,32 @@
use std::time::Duration;
use tracing::{info, error};
use crate::db::Db;
use super::{log_job_start, log_job_end};
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
let retention_days = match retention_days_opt {
Some(days) => days,
None => return,
};
loop {
// Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
info!("Running background data retention cleanup...");
let job_id = log_job_start(&db.system, "retention_cleaner");
let conn = db.analytics.lock().unwrap();
match crate::db::analytics::retention_cleanup(&conn, retention_days) {
Ok(count) => {
info!("Cleaned up {} expired visits from database", count);
log_job_end(&db.system, &job_id, "success", None);
}
Err(e) => {
let err_str = e.to_string();
error!("Error running retention cleaner: {:?}", err_str);
log_job_end(&db.system, &job_id, "failed", Some(&err_str));
}
}
}
}
+14
View File
@@ -0,0 +1,14 @@
pub mod config;
pub mod db;
pub mod auth;
pub mod analytics;
pub mod jobs;
pub mod services;
pub mod utils;
pub mod models;
pub mod templates;
pub mod charts;
pub mod state;
pub mod error;
pub mod web;
pub mod cli;
+44
View File
@@ -0,0 +1,44 @@
use clap::Parser;
use tracing_subscriber::EnvFilter;
use bzod::config::Config;
use bzod::cli::{Cli, Commands};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
// Set up tracing subscriber
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let cli = Cli::parse();
let config = Config::load();
match cli.command {
Commands::Serve { host, port, data_dir } => {
bzod::cli::serve::run(host, port, data_dir, config).await?;
}
Commands::Backup { out, data_dir } => {
bzod::cli::backup::run(out, data_dir, config).await?;
}
Commands::Restore { file, data_dir } => {
bzod::cli::restore::run(file, data_dir, config).await?;
}
Commands::Migrate { data_dir, dry_run } => {
bzod::cli::migrate::run(data_dir, dry_run, config).await?;
}
Commands::Stats { data_dir } => {
bzod::cli::stats::run(data_dir, config).await?;
}
Commands::Validate { data_dir } => {
bzod::cli::validate::run(data_dir, config).await?;
}
Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?;
}
Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?;
}
}
Ok(())
}
+11
View File
@@ -0,0 +1,11 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct ApiKey {
pub id: String,
pub user_id: String,
pub key_hash: String,
pub name: String,
pub created_at: String,
pub last_used_at: Option<String>,
}
+13
View File
@@ -0,0 +1,13 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct AuditLog {
pub id: String,
pub timestamp: String,
pub username: String,
pub action: String,
pub object_type: Option<String>,
pub object_id: Option<String>,
pub ip_address: Option<String>,
pub user_agent: Option<String>,
}
+13
View File
@@ -0,0 +1,13 @@
pub mod user;
pub mod url;
pub mod page;
pub mod visit;
pub mod api_key;
pub mod audit;
pub use user::{User, Session};
pub use url::Url;
pub use page::LandingPage;
pub use visit::{VisitRecord, SummaryEntry};
pub use api_key::ApiKey;
pub use audit::AuditLog;
+13
View File
@@ -0,0 +1,13 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LandingPage {
pub id: String,
pub code: String,
pub slug: String,
pub title: String,
pub html_content: String,
pub state: String, // 'draft', 'published', 'archived'
pub created_at: String,
pub updated_at: String,
}
+14
View File
@@ -0,0 +1,14 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Url {
pub id: String,
pub code: String,
pub destination: String,
pub title: Option<String>,
pub description: Option<String>,
pub status: String, // 'healthy', 'suspect', 'dead'
pub created_at: String,
pub updated_at: String,
pub tags: Vec<String>,
}
+17
View File
@@ -0,0 +1,17 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct User {
pub id: String,
pub username: String,
pub password_hash: String,
pub created_at: String,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Session {
pub id: String,
pub user_id: String,
pub expires_at: String,
pub created_at: String,
}
+25
View File
@@ -0,0 +1,25 @@
use serde::{Serialize, Deserialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct VisitRecord {
pub id: String,
pub target_type: String, // 'url' or 'page'
pub target_id: String,
pub timestamp: String,
pub ip_address: String,
pub user_agent: String,
pub referer: String,
pub accept_language: String,
pub country: String,
pub status_code: u16,
}
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct SummaryEntry {
pub date: String,
pub target_type: String,
pub target_id: String,
pub metric_type: String,
pub metric_key: String,
pub metric_value: i64,
}
+14
View File
@@ -0,0 +1,14 @@
use crate::db::Db;
use crate::models::ApiKey;
use crate::error::AppError;
pub fn create_api_key(
db: &Db,
user_id: &str,
name: &str,
key_hash: &str,
) -> Result<ApiKey, AppError> {
let conn = db.admin.lock().unwrap();
let key = crate::db::admin::create_api_key(&conn, user_id, name, key_hash)?;
Ok(key)
}
+25
View File
@@ -0,0 +1,25 @@
use crate::db::Db;
use crate::models::AuditLog;
use crate::error::AppError;
pub fn log_action(
db: &Db,
username: &str,
action: &str,
object_type: Option<&str>,
object_id: Option<&str>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> Result<AuditLog, AppError> {
let conn = db.admin.lock().unwrap();
let log = crate::db::admin::write_audit_log(
&conn,
username,
action,
object_type,
object_id,
ip_address,
user_agent,
)?;
Ok(log)
}
+22
View File
@@ -0,0 +1,22 @@
use crate::db::Db;
use crate::models::LandingPage;
use crate::error::AppError;
pub fn create_landing_page(
db: &Db,
code: &str,
slug: &str,
title: &str,
html_content: &str,
state: &str,
) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
Ok(page)
}
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
Ok(page)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod shortener;
pub mod landing_pages;
pub mod api_keys;
pub mod audit;
+22
View File
@@ -0,0 +1,22 @@
use crate::db::Db;
use crate::models::Url;
use crate::error::AppError;
pub fn create_url(
db: &Db,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url)
}
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url)
}
+28
View File
@@ -0,0 +1,28 @@
use std::sync::{Arc, Mutex};
use std::time::Instant;
use rusqlite::Connection;
use crate::config::Config;
use crate::analytics::queue::AnalyticsQueue;
use crate::db::Db;
#[derive(Clone)]
pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>,
pub db: Db,
pub config: Config,
pub analytics_queue: AnalyticsQueue,
pub start_time: Instant,
}
impl AppState {
pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
Ok(())
}
}
+29
View File
@@ -0,0 +1,29 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
#[derive(Template)]
#[template(path = "dashboard.html")]
pub struct DashboardTemplate {
pub admin_username: String,
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub active_links: i64,
pub dead_links: i64,
pub traffic_chart: String,
pub countries_chart: String,
pub browsers_chart: String,
pub referrers_chart: String,
}
impl IntoResponse for DashboardTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+33
View File
@@ -0,0 +1,33 @@
pub mod dashboard;
pub mod urls;
pub mod pages;
pub mod stats;
pub mod settings;
pub use dashboard::DashboardTemplate;
pub use urls::UrlsTemplate;
pub use pages::PagesTemplate;
pub use stats::{StatusTemplate, AuditTemplate};
pub use settings::SettingsTemplate;
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
#[derive(Template)]
#[template(path = "login.html")]
pub struct LoginTemplate {
pub error: Option<String>,
pub csrf_token: String,
}
impl IntoResponse for LoginTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+24
View File
@@ -0,0 +1,24 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
use crate::models::LandingPage;
#[derive(Template)]
#[template(path = "pages.html")]
pub struct PagesTemplate {
pub admin_username: String,
pub pages: Vec<LandingPage>,
pub csrf_token: String,
pub error: Option<String>,
}
impl IntoResponse for PagesTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+26
View File
@@ -0,0 +1,26 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
use crate::models::ApiKey;
#[derive(Template)]
#[template(path = "settings.html")]
pub struct SettingsTemplate {
pub admin_username: String,
pub api_keys: Vec<ApiKey>,
pub data_retention: String,
pub csrf_token: String,
pub success: Option<String>,
pub error: Option<String>,
}
impl IntoResponse for SettingsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+44
View File
@@ -0,0 +1,44 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
use crate::models::AuditLog;
#[derive(Template)]
#[template(path = "status_ui.html")]
pub struct StatusTemplate {
pub admin_username: String,
pub app_status: &'static str,
pub db_status: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime: String,
pub version: &'static str,
pub git_commit: &'static str,
}
#[derive(Template)]
#[template(path = "audit.html")]
pub struct AuditTemplate {
pub admin_username: String,
pub logs: Vec<AuditLog>,
}
impl IntoResponse for StatusTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
impl IntoResponse for AuditTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+25
View File
@@ -0,0 +1,25 @@
use askama::Template;
use axum::{
response::{IntoResponse, Response, Html},
http::StatusCode,
};
use crate::models::Url;
#[derive(Template)]
#[template(path = "urls.html")]
pub struct UrlsTemplate {
pub admin_username: String,
pub urls: Vec<Url>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
}
impl IntoResponse for UrlsTemplate {
fn into_response(self) -> Response {
match self.render() {
Ok(html) => Html(html).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {}", e)).into_response(),
}
}
}
+8
View File
@@ -0,0 +1,8 @@
use sha2::{Sha256, Digest};
// General SHA-256 hash helper
pub fn sha256_hash(data: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(data.as_bytes());
hex::encode(hasher.finalize())
}
+11
View File
@@ -0,0 +1,11 @@
pub mod time;
pub mod system;
pub mod hashing;
pub mod network;
pub mod random;
pub use time::format_duration;
pub use system::{get_memory_usage, get_db_file_info};
pub use hashing::sha256_hash;
pub use network::get_client_ip;
pub use random::generate_token;
+24
View File
@@ -0,0 +1,24 @@
use std::net::SocketAddr;
use axum::{
extract::ConnectInfo,
http::HeaderMap,
};
// Extract client IP address from proxy headers or connection info
pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<SocketAddr>>) -> String {
if let Some(ip) = headers.get("cf-connecting-ip").and_then(|h| h.to_str().ok()) {
return ip.to_string();
}
if let Some(ip) = headers.get("x-real-ip").and_then(|h| h.to_str().ok()) {
return ip.to_string();
}
if let Some(ips) = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()) {
if let Some(ip) = ips.split(',').next() {
return ip.trim().to_string();
}
}
if let Some(ConnectInfo(addr)) = connect_info {
return addr.ip().to_string();
}
"127.0.0.1".to_string()
}
+8
View File
@@ -0,0 +1,8 @@
use rand::{RngCore, thread_rng};
// Generate a secure random token (hex-encoded)
pub fn generate_token(bytes_len: usize) -> String {
let mut key = vec![0u8; bytes_len];
thread_rng().fill_bytes(&mut key);
hex::encode(key)
}
+40
View File
@@ -0,0 +1,40 @@
use std::path::Path;
// Read memory usage on Linux
pub fn get_memory_usage() -> String {
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
let fields: Vec<&str> = statm.split_whitespace().collect();
if !fields.is_empty() {
if let Ok(pages) = fields[0].parse::<u64>() {
// Page size is usually 4096 bytes
let bytes = pages * 4096;
return format!("{:.2} MB", bytes as f64 / 1_048_576.0);
}
}
}
"N/A".to_string()
}
// Generate diagnostic size report for the SQLite files
pub fn get_db_file_info(data_dir: &Path) -> String {
let mut stats = String::new();
let files = vec![
("admin.db", "Admin DB"),
("content.db", "Content DB"),
("analytics.db", "Analytics DB"),
("system.db", "System DB"),
];
for (f, name) in files {
let path = data_dir.join(f);
if path.exists() {
if let Ok(metadata) = std::fs::metadata(&path) {
let size = metadata.len();
stats.push_str(&format!("{}: {} bytes ({:.2} MB)\n", name, size, size as f64 / 1_048_576.0));
}
} else {
stats.push_str(&format!("{}: File not created yet\n", name));
}
}
stats
}
+12
View File
@@ -0,0 +1,12 @@
use std::time::Duration;
// Formats a duration as "Xd Xh Xm Xs"
pub fn format_duration(d: Duration) -> String {
format!(
"{}d {}h {}m {}s",
d.as_secs() / 86400,
(d.as_secs() % 86400) / 3600,
(d.as_secs() % 3600) / 60,
d.as_secs() % 60
)
}
+911
View File
@@ -0,0 +1,911 @@
use axum::{
extract::{Path, State, Query, ConnectInfo},
http::{HeaderMap, StatusCode},
response::{Redirect, Response, IntoResponse},
Form,
};
use rusqlite::params;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use serde::Deserialize;
use std::net::SocketAddr;
use chrono::Utc;
use tar::Builder;
use flate2::write::GzEncoder;
use flate2::Compression;
use crate::db::admin::{
create_user, get_user_count, get_user_by_username, create_session, delete_session,
write_audit_log, list_audit_logs, list_api_keys, create_api_key, delete_api_key, set_config, get_config
};
use crate::db::content::{
list_urls, create_url, delete_url, get_url_counts, get_landing_page_count,
list_landing_pages, create_landing_page, delete_landing_page
};
use crate::db::analytics::{
get_total_clicks, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw
};
use crate::auth::{
authenticate_session, verify_password, verify_sha256, hash_password, generate_token,
generate_csrf_token, verify_csrf
};
use crate::charts::{generate_line_chart, generate_bar_chart};
use crate::state::AppState;
use crate::models::User;
use crate::utils::{get_client_ip, get_memory_usage, get_db_file_info};
// Helper: Verify session and return user or redirect to login
async fn require_auth(state: &AppState, jar: &CookieJar) -> Result<(User, String), Redirect> {
let conn = state.admin_db.lock().unwrap();
match authenticate_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/admin/login")),
}
}
// 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,
Query(params): Query<std::collections::HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let mut new_jar = jar.clone();
new_jar = new_jar.add(
Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/admin/login")
.secure(state.config.cookie_secure)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.build()
);
let template = crate::templates::LoginTemplate { error, csrf_token };
(new_jar, template).into_response()
}
#[derive(Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
pub csrf_token: String,
}
// 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 user_count = {
let conn = state.admin_db.lock().unwrap();
get_user_count(&conn).unwrap_or(0)
};
let user_opt = if user_count == 0 {
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
if form.username == state.config.admin_username && verify_sha256(&form.password, &state.config.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 = state.admin_db.lock().unwrap();
match create_user(&conn, &form.username, &hash) {
Ok(u) => {
let _ = write_audit_log(&conn, &u.username, "BOOTSTRAP_USER_PROVISIONED", Some("user"), Some(&u.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
Some(u)
}
Err(_) => None,
}
} else {
None
}
} else {
// Standard DB Verification
let conn = state.admin_db.lock().unwrap();
match get_user_by_username(&conn, &form.username) {
Ok(Some(u)) => {
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.admin_db.lock().unwrap();
let _ = create_session(&conn, &session_token, &user.id, &expires);
let _ = write_audit_log(&conn, &user.username, "USER_LOGIN", Some("session"), Some(&session_token), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
}
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(state.config.cookie_secure)
.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 => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, "anonymous", "LOGIN_FAILED", None, None, Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
}
Redirect::to("/admin/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.admin_db.lock().unwrap();
let _ = delete_session(&conn, &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()
}
// 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()
}
// GET /admin/urls
#[derive(Deserialize)]
pub struct UrlsQuery {
pub tag: Option<String>,
pub error: Option<String>,
}
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 = {
let conn = state.content_db.lock().unwrap();
list_urls(&conn, 100, 0, query.tag.as_deref()).unwrap_or_default()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UrlsTemplate {
admin_username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreateUrlForm {
pub destination: String,
pub code: String,
pub title: String,
pub description: String,
pub tags: String,
pub csrf_token: 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 mut 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();
}
}
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 = state.content_db.lock().unwrap();
create_url(&conn, &code, &form.destination, title_opt, desc_opt, &tags_list)
};
match res {
Ok(url) => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
Redirect::to("/admin/urls?error=Short code already exists").into_response()
}
Err(e) => {
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, &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(),
}
}
// GET /admin/pages
#[derive(Deserialize)]
pub struct PagesQuery {
pub error: Option<String>,
}
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 = {
let conn = state.content_db.lock().unwrap();
list_landing_pages(&conn, 100, 0).unwrap_or_default()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::PagesTemplate {
admin_username: user.username,
pages,
csrf_token,
error: query.error,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreatePageForm {
pub title: String,
pub slug: String,
pub code: 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 mut 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();
}
}
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 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 conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn_admin, &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(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
Redirect::to("/admin/pages?error=Short code already exists").into_response()
}
Err(e) => {
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, &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(),
}
}
// GET /admin/settings
#[derive(Deserialize)]
pub struct SettingsQuery {
pub success: Option<String>,
pub error: Option<String>,
}
pub async fn settings_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<SettingsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let api_keys = {
let conn = state.admin_db.lock().unwrap();
list_api_keys(&conn, &user.id).unwrap_or_default()
};
let data_retention = {
let conn = state.admin_db.lock().unwrap();
get_config(&conn, "retention_days")
.unwrap_or(None)
.unwrap_or_else(|| state.config.data_retention_days.map(|d| d.to_string()).unwrap_or_else(|| "unlimited".to_string()))
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SettingsTemplate {
admin_username: user.username,
api_keys,
data_retention,
csrf_token,
success: query.success,
error: query.error,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct ChangePasswordForm {
pub current_password: String,
pub new_password: String,
pub csrf_token: String,
}
// POST /admin/settings/password
pub async fn change_password_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<ChangePasswordForm>,
) -> 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 conn = state.admin_db.lock().unwrap();
if !verify_password(&form.current_password, &user.password_hash) {
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_FAIL", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
return Redirect::to("/admin/settings?error=Incorrect current password").into_response();
}
let new_hash = match hash_password(&form.new_password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/settings?error=Hashing error").into_response(),
};
let res = conn.execute("UPDATE users SET password_hash = ?1 WHERE id = ?2;", params![new_hash, user.id]);
match res {
Ok(_) => {
let _ = write_audit_log(&conn, &user.username, "PASSWORD_CHANGE_SUCCESS", Some("user"), Some(&user.id), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
Redirect::to("/admin/settings?success=Password updated successfully").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Failed to update password: {}", e)).into_response()
}
}
}
#[derive(Deserialize)]
pub struct RetentionForm {
pub retention: String,
pub csrf_token: String,
}
// POST /admin/settings/retention
pub async fn change_retention_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<RetentionForm>,
) -> 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 conn = state.admin_db.lock().unwrap();
match set_config(&conn, "retention_days", &form.retention) {
Ok(_) => {
let _ = write_audit_log(&conn, &user.username, "RETENTION_POLICY_CHANGED", Some("config"), Some("retention_days"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
Redirect::to("/admin/settings?success=Retention policy saved").into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response(),
}
}
// POST /admin/settings/compact
pub async fn compact_db_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response {
let (user, _session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let ip = get_client_ip(&headers, connect_info);
match state.db_compact() {
Ok(_) => {
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &user.username, "DATABASE_COMPACTION", Some("system"), Some("all_dbs"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
Redirect::to("/admin/settings?success=Database files compacted successfully").into_response()
}
Err(e) => Redirect::to(&format!("/admin/settings?error=Failed to compact: {}", e)).into_response(),
}
}
// GET /admin/settings/backup
pub async fn download_backup(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let ip = get_client_ip(&headers, connect_info);
// Create tar.gz in memory
let mut buffer = Vec::new();
let res = {
let enc = GzEncoder::new(&mut buffer, Compression::default());
let mut tar = Builder::new(enc);
let files = vec!["admin.db", "content.db", "analytics.db", "system.db"];
let mut add_err = None;
for f in files {
let path = state.config.data_dir.join(f);
if path.exists() {
if let Err(e) = tar.append_path_with_name(&path, f) {
add_err = Some(e);
break;
}
}
}
match add_err {
Some(e) => Err(e),
None => {
match tar.into_inner().and_then(|encoder| encoder.finish()) {
Ok(_) => Ok(()),
Err(e) => Err(e),
}
}
}
};
match res {
Ok(_) => {
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(&conn, &user.username, "DATABASE_BACKUP", Some("system"), Some("tarball"), Some(&ip), headers.get("user-agent").and_then(|h| h.to_str().ok()));
}
let date_str = Utc::now().format("%Y-%m-%d").to_string();
let filename = format!("{}-bzod-backup.tar.gz", date_str);
(
StatusCode::OK,
[
("Content-Type", "application/gzip"),
("Content-Disposition", &format!("attachment; filename=\"{}\"", filename)),
],
buffer,
).into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Backup failed: {}", e)).into_response()
}
}
}
#[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::{Sha256, Digest};
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, &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, &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 /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.admin_db.lock().unwrap();
list_audit_logs(&conn, 100, 0).unwrap_or_default()
};
let template = crate::templates::AuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
// GET /admin/status
pub async fn status_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 app_status = "Healthy";
let db_status = {
let conn_ok = {
let conn = state.admin_db.lock().unwrap();
get_user_count(&conn).is_ok()
};
if conn_ok {
format!("Operational\n\nDatabase Files:\n{}", get_db_file_info(&state.config.data_dir))
} else {
"Degraded (Database connections failed)".to_string()
}
};
let queue_size = 0;
let memory_usage = get_memory_usage();
let uptime_duration = state.start_time.elapsed();
let uptime = crate::utils::format_duration(uptime_duration);
let template = crate::templates::StatusTemplate {
admin_username: user.username,
app_status,
db_status,
queue_size,
memory_usage,
uptime,
version: "0.1.0",
git_commit: "unknown",
};
template.into_response()
}
+413
View File
@@ -0,0 +1,413 @@
use axum::{
extract::{Path, State, Query, ConnectInfo},
http::{StatusCode, HeaderMap},
response::{IntoResponse, Json, Response},
};
use serde::{Deserialize, Serialize};
use std::net::SocketAddr;
use crate::db::content::{
list_urls, get_url_by_id, create_url, update_url, delete_url,
list_landing_pages, get_landing_page_by_id, create_landing_page, update_landing_page, delete_landing_page,
get_url_counts, get_landing_page_count
};
use crate::db::analytics::{
get_total_clicks, get_total_page_views, get_clicks_trend, get_clicks_trend_raw,
get_metric_rankings, get_metric_rankings_raw
};
use crate::db::admin::write_audit_log;
use crate::utils::get_client_ip;
use crate::auth::generate_token;
use crate::auth::ApiUser;
use crate::state::AppState;
// JSON Payload Structs
#[derive(Deserialize)]
pub struct CreateUrlRequest {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct UpdateUrlRequest {
pub destination: String,
pub title: Option<String>,
pub description: Option<String>,
pub status: String, // 'healthy', 'suspect', 'dead'
pub tags: Option<Vec<String>>,
}
#[derive(Deserialize)]
pub struct CreatePageRequest {
pub slug: String,
pub title: String,
pub html_content: String,
pub state: String, // 'draft', 'published', 'archived'
pub code: Option<String>,
}
#[derive(Deserialize)]
pub struct UpdatePageRequest {
pub slug: String,
pub title: String,
pub html_content: String,
pub state: String,
}
// Error JSON Response
#[derive(Serialize)]
pub struct ApiError {
pub error: String,
}
// --- URL Endpoints ---
// POST /api/v1/urls
pub async fn api_create_url(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Json(payload): Json<CreateUrlRequest>,
) -> Response {
let mut code = payload.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()) {
return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 6 hex characters".to_string() })).into_response();
}
}
let tags = payload.tags.unwrap_or_default();
let conn = state.content_db.lock().unwrap();
match create_url(&conn, &code, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &tags) {
Ok(url) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_CREATION", Some("url"), Some(&url.id), Some(&ip), user_agent);
(StatusCode::CREATED, Json(url)).into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
(StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// GET /api/v1/urls
#[derive(Deserialize)]
pub struct ListQuery {
pub limit: Option<i64>,
pub offset: Option<i64>,
pub tag: Option<String>,
}
pub async fn api_list_urls(
State(state): State<AppState>,
_user: ApiUser,
Query(query): Query<ListQuery>,
) -> Response {
let limit = query.limit.unwrap_or(100);
let offset = query.offset.unwrap_or(0);
let conn = state.content_db.lock().unwrap();
match list_urls(&conn, limit, offset, query.tag.as_deref()) {
Ok(urls) => Json(urls).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// GET /api/v1/urls/:uuid
pub async fn api_get_url(
State(state): State<AppState>,
_user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
let conn = state.content_db.lock().unwrap();
match get_url_by_id(&conn, &uuid) {
Ok(Some(url)) => Json(url).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// PUT /api/v1/urls/:uuid
pub async fn api_update_url(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Path(uuid): Path<String>,
Json(payload): Json<UpdateUrlRequest>,
) -> Response {
let tags = payload.tags.unwrap_or_default();
let conn = state.content_db.lock().unwrap();
match update_url(&conn, &uuid, &payload.destination, payload.title.as_deref(), payload.description.as_deref(), &payload.status, &tags) {
Ok(Some(url)) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_UPDATE", Some("url"), Some(&uuid), Some(&ip), user_agent);
Json(url).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// DELETE /api/v1/urls/:uuid
pub async fn api_delete_url(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
let conn = state.content_db.lock().unwrap();
match delete_url(&conn, &uuid) {
Ok(true) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "URL_DELETION", Some("url"), Some(&uuid), Some(&ip), user_agent);
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// --- Landing Page Endpoints ---
// POST /api/v1/pages
pub async fn api_create_page(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Json(payload): Json<CreatePageRequest>,
) -> Response {
let mut code = payload.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(2); // 4 hex
} else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::BAD_REQUEST, Json(ApiError { error: "Short code must be 4 hex characters".to_string() })).into_response();
}
}
let conn = state.content_db.lock().unwrap();
match create_landing_page(&conn, &code, &payload.slug, &payload.title, &payload.html_content, &payload.state) {
Ok(page) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_CREATION", Some("page"), Some(&page.id), Some(&ip), user_agent);
(StatusCode::CREATED, Json(page)).into_response()
}
Err(rusqlite::Error::SqliteFailure(err, _)) if err.code == rusqlite::ErrorCode::ConstraintViolation => {
(StatusCode::CONFLICT, Json(ApiError { error: "Short code already exists".to_string() })).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// GET /api/v1/pages
pub async fn api_list_pages(
State(state): State<AppState>,
_user: ApiUser,
Query(query): Query<ListQuery>,
) -> Response {
let limit = query.limit.unwrap_or(100);
let offset = query.offset.unwrap_or(0);
let conn = state.content_db.lock().unwrap();
match list_landing_pages(&conn, limit, offset) {
Ok(pages) => Json(pages).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// GET /api/v1/pages/:uuid
pub async fn api_get_page(
State(state): State<AppState>,
_user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
let conn = state.content_db.lock().unwrap();
match get_landing_page_by_id(&conn, &uuid) {
Ok(Some(page)) => Json(page).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// PUT /api/v1/pages/:uuid
pub async fn api_update_page(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Path(uuid): Path<String>,
Json(payload): Json<UpdatePageRequest>,
) -> Response {
let conn = state.content_db.lock().unwrap();
match update_landing_page(&conn, &uuid, &payload.slug, &payload.title, &payload.html_content, &payload.state) {
Ok(Some(page)) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_UPDATE", Some("page"), Some(&uuid), Some(&ip), user_agent);
Json(page).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// DELETE /api/v1/pages/:uuid
pub async fn api_delete_page(
State(state): State<AppState>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
let conn = state.content_db.lock().unwrap();
match delete_landing_page(&conn, &uuid) {
Ok(true) => {
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
let _ = write_audit_log(&state.admin_db.lock().unwrap(), &user.0.username, "PAGE_DELETION", Some("page"), Some(&uuid), Some(&ip), user_agent);
StatusCode::NO_CONTENT.into_response()
}
Ok(false) => (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(ApiError { error: e.to_string() })).into_response(),
}
}
// --- Statistics Endpoints ---
#[derive(Serialize)]
pub struct OverallStatsResponse {
pub total_urls: i64,
pub total_pages: i64,
pub total_clicks: i64,
pub total_page_views: i64,
pub active_links: i64,
pub dead_links: i64,
}
// GET /api/v1/stats
pub async fn api_overall_stats(
State(state): State<AppState>,
_user: ApiUser,
) -> 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, total_page_views) = {
let conn = state.analytics_db.lock().unwrap();
(
get_total_clicks(&conn).unwrap_or(0),
get_total_page_views(&conn).unwrap_or(0)
)
};
Json(OverallStatsResponse {
total_urls,
total_pages,
total_clicks,
total_page_views,
active_links,
dead_links,
}).into_response()
}
#[derive(Serialize)]
pub struct DetailStatsResponse {
pub target_id: String,
pub clicks: Vec<(String, i64)>,
pub top_countries: Vec<(String, i64)>,
pub top_referrers: Vec<(String, i64)>,
pub top_browsers: Vec<(String, i64)>,
}
// GET /api/v1/stats/url/:uuid
pub async fn api_url_stats(
State(state): State<AppState>,
_user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
// Verify URL exists
{
let conn = state.content_db.lock().unwrap();
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return (StatusCode::NOT_FOUND, Json(ApiError { error: "URL not found".to_string() })).into_response();
}
}
let conn = state.analytics_db.lock().unwrap();
let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
.unwrap_or_default();
let top_countries = get_metric_rankings(&conn, "url", &uuid, "country", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "country", 10))
.unwrap_or_default();
let top_referrers = get_metric_rankings(&conn, "url", &uuid, "referrer", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "referrer", 10))
.unwrap_or_default();
let top_browsers = get_metric_rankings(&conn, "url", &uuid, "browser", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "url", &uuid, "browser", 10))
.unwrap_or_default();
Json(DetailStatsResponse {
target_id: uuid,
clicks,
top_countries,
top_referrers,
top_browsers,
}).into_response()
}
// GET /api/v1/stats/page/:uuid
pub async fn api_page_stats(
State(state): State<AppState>,
_user: ApiUser,
Path(uuid): Path<String>,
) -> Response {
// Verify Page exists
{
let conn = state.content_db.lock().unwrap();
if get_landing_page_by_id(&conn, &uuid).unwrap_or(None).is_none() {
return (StatusCode::NOT_FOUND, Json(ApiError { error: "Page not found".to_string() })).into_response();
}
}
let conn = state.analytics_db.lock().unwrap();
let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
.unwrap_or_default();
let top_countries = get_metric_rankings(&conn, "page", &uuid, "country", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "country", 10))
.unwrap_or_default();
let top_referrers = get_metric_rankings(&conn, "page", &uuid, "referrer", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "referrer", 10))
.unwrap_or_default();
let top_browsers = get_metric_rankings(&conn, "page", &uuid, "browser", 10)
.or_else(|_| get_metric_rankings_raw(&conn, "page", &uuid, "browser", 10))
.unwrap_or_default();
Json(DetailStatsResponse {
target_id: uuid,
clicks,
top_countries,
top_referrers,
top_browsers,
}).into_response()
}
+1
View File
@@ -0,0 +1 @@
// General web middleware placeholder
+9
View File
@@ -0,0 +1,9 @@
pub mod routes;
pub mod middleware;
pub mod redirect;
pub mod pages;
pub mod admin;
pub mod api;
pub mod system;
pub use routes::create_router;
+76
View File
@@ -0,0 +1,76 @@
use axum::{
extract::{Path, State, ConnectInfo},
http::{HeaderMap, StatusCode},
response::{Response, Html, IntoResponse},
};
use std::net::SocketAddr;
use uuid::Uuid;
use chrono::Utc;
use crate::state::AppState;
use crate::models::VisitRecord;
use crate::utils::get_client_ip;
use crate::analytics::get_client_country;
use crate::services::landing_pages::get_landing_page_by_code;
// GET /p/:code and GET /p/:code/*slug
// Resolve and render landing page
pub async fn resolve_page(
State(state): State<AppState>,
Path(code): Path<String>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let page_opt = match get_landing_page_by_code(&state.db, &code) {
Ok(page) => page,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
match page_opt {
Some(page) => {
// Check state
if page.state == "archived" {
return (StatusCode::GONE, "This landing page has been archived").into_response();
}
// Record view analytics
let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers);
let user_agent = headers.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let referer = headers.get("referer")
.and_then(|h| h.to_str().ok())
.unwrap_or("Direct")
.to_string();
let accept_language = headers.get("accept-language")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let record = VisitRecord {
id: Uuid::new_v4().to_string(),
target_type: "page".to_string(),
target_id: page.id.clone(),
timestamp: Utc::now().to_rfc3339(),
ip_address: ip,
user_agent,
referer,
accept_language,
country,
status_code: 200,
};
state.analytics_queue.push(record);
// Render raw HTML
Html(page.html_content).into_response()
}
None => (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
}
}
+73
View File
@@ -0,0 +1,73 @@
use axum::{
extract::{Path, State, ConnectInfo},
http::{HeaderMap, StatusCode},
response::{Redirect, Response, IntoResponse},
};
use std::net::SocketAddr;
use uuid::Uuid;
use chrono::Utc;
use crate::state::AppState;
use crate::models::VisitRecord;
use crate::utils::get_client_ip;
use crate::analytics::get_client_country;
use crate::services::shortener::get_url_by_code;
// GET /:code
// Resolve and redirect
pub async fn resolve_redirect(
State(state): State<AppState>,
Path(code): Path<String>,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
) -> Response {
// Basic validation of code (must be 6 hex characters)
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
let url_opt = match get_url_by_code(&state.db, &code) {
Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
match url_opt {
Some(url) => {
// Asynchronously record analytics
let ip = get_client_ip(&headers, connect_info);
let country = get_client_country(&headers);
let user_agent = headers.get("user-agent")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let referer = headers.get("referer")
.and_then(|h| h.to_str().ok())
.unwrap_or("Direct")
.to_string();
let accept_language = headers.get("accept-language")
.and_then(|h| h.to_str().ok())
.unwrap_or("Unknown")
.to_string();
let record = VisitRecord {
id: Uuid::new_v4().to_string(),
target_type: "url".to_string(),
target_id: url.id.clone(),
timestamp: Utc::now().to_rfc3339(),
ip_address: ip,
user_agent,
referer,
accept_language,
country,
status_code: 302,
};
// Push to memory queue (non-blocking)
state.analytics_queue.push(record);
// Perform redirect
Redirect::temporary(&url.destination).into_response()
}
None => (StatusCode::NOT_FOUND, "Short code not found").into_response(),
}
}
+55
View File
@@ -0,0 +1,55 @@
use axum::{
Router,
routing::{get, post},
};
use crate::state::AppState;
use crate::web::{redirect, pages, admin, api, system};
pub fn create_router(state: AppState) -> Router {
Router::new()
// --- Public Redirection Routes ---
.route("/:code", get(redirect::resolve_redirect))
.route("/p/:code", get(pages::resolve_page))
.route("/p/:code/*slug", get(pages::resolve_page))
// --- System Health & Diagnostics ---
.route("/status", get(system::status_endpoint))
.route("/metrics", get(system::metrics_endpoint))
// --- Admin UI Login/Logout ---
.route("/admin", get(admin::admin_index))
.route("/admin/login", get(admin::login_get).post(admin::login_post))
.route("/admin/logout", get(admin::logout))
// --- Admin UI Pages ---
.route("/admin/dashboard", get(admin::dashboard_get))
.route("/admin/urls", get(admin::urls_get))
.route("/admin/urls/create", post(admin::urls_create))
.route("/admin/urls/delete/:id", post(admin::urls_delete))
.route("/admin/pages", get(admin::pages_get))
.route("/admin/pages/create", post(admin::pages_create))
.route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/settings", get(admin::settings_get))
.route("/admin/settings/password", post(admin::change_password_post))
.route("/admin/settings/retention", post(admin::change_retention_post))
.route("/admin/settings/compact", post(admin::compact_db_post))
.route("/admin/settings/backup", get(admin::download_backup))
.route("/admin/settings/api-keys/create", post(admin::create_api_key_post))
.route("/admin/settings/api-keys/revoke/:id", post(admin::revoke_api_key_post))
.route("/admin/audit", get(admin::audit_get))
.route("/admin/status", get(admin::status_get))
// --- REST API v1 JSON Endpoints ---
.route("/api/v1/urls", post(api::api_create_url).get(api::api_list_urls))
.route("/api/v1/urls/:uuid", get(api::api_get_url).put(api::api_update_url).delete(api::api_delete_url))
.route("/api/v1/pages", post(api::api_create_page).get(api::api_list_pages))
.route("/api/v1/pages/:uuid", get(api::api_get_page).put(api::api_update_page).delete(api::api_delete_page))
.route("/api/v1/stats", get(api::api_overall_stats))
.route("/api/v1/stats/url/:uuid", get(api::api_url_stats))
.route("/api/v1/stats/page/:uuid", get(api::api_page_stats))
// --- Static Asset Stub ---
.route("/static/style.css", get(|| async { ([(axum::http::header::CONTENT_TYPE, "text/css")], "") }))
.with_state(state)
}
+172
View File
@@ -0,0 +1,172 @@
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response, Json},
};
use axum_extra::extract::CookieJar;
use serde::Serialize;
use crate::db::admin::get_user_count;
use crate::state::AppState;
use crate::utils::{get_memory_usage, get_db_file_info};
use crate::auth::{authenticate_session, authenticate_api_key};
// Helper: authenticate system request via header or session cookie
fn authenticate_request(state: &AppState, jar: &CookieJar, headers: &HeaderMap) -> bool {
// 1. Try Authorization header
if let Some(auth_header) = headers.get("Authorization").and_then(|h| h.to_str().ok()) {
let conn = state.admin_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_api_key(&conn, auth_header) {
return true;
}
}
// 2. Try cookie session
let conn = state.admin_db.lock().unwrap();
if let Ok(Some(_)) = authenticate_session(&conn, jar) {
return true;
}
false
}
#[derive(Serialize)]
pub struct StatusResponse {
pub application: &'static str,
pub database: String,
pub queue_size: usize,
pub memory_usage: String,
pub uptime_seconds: u64,
pub version: &'static str,
pub git_commit: &'static str,
}
// GET /status
pub async fn status_endpoint(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
) -> Response {
if !authenticate_request(&state, &jar, &headers) {
// Return public basic status for container/load-balancer health checks
return (
StatusCode::OK,
Json(serde_json::json!({ "application": "Healthy" }))
).into_response();
}
let is_db_ok = {
let conn = state.admin_db.lock().unwrap();
get_user_count(&conn).is_ok()
};
let db_status = if is_db_ok {
format!("Connected (WAL Mode enabled). Files Info:\n{}", get_db_file_info(&state.config.data_dir))
} else {
"Disconnected".to_string()
};
let uptime = state.start_time.elapsed().as_secs();
Json(StatusResponse {
application: "Healthy",
database: db_status,
queue_size: 0,
memory_usage: get_memory_usage(),
uptime_seconds: uptime,
version: "0.1.0",
git_commit: "unknown",
}).into_response()
}
// GET /metrics
pub async fn metrics_endpoint(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
) -> Response {
if !authenticate_request(&state, &jar, &headers) {
return StatusCode::UNAUTHORIZED.into_response();
}
// 1. Gather stats from DBs
let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap();
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0))
};
let total_pages = {
let conn = state.content_db.lock().unwrap();
crate::db::content::get_landing_page_count(&conn).unwrap_or(0)
};
let (total_clicks, total_page_views) = {
let conn = state.analytics_db.lock().unwrap();
(
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0)
)
};
// Calculate memory in bytes
let mut mem_bytes = 0;
if let Ok(statm) = std::fs::read_to_string("/proc/self/statm") {
if let Some(pages_str) = statm.split_whitespace().next() {
if let Ok(pages) = pages_str.parse::<u64>() {
mem_bytes = pages * 4096;
}
}
}
let uptime = state.start_time.elapsed().as_secs();
// 2. Format as Prometheus metrics text
let metrics_text = format!(
r#"# HELP bzod_urls_total Total number of registered short URLs
# TYPE bzod_urls_total gauge
bzod_urls_total {total_urls}
# HELP bzod_active_urls Total number of active/healthy short URLs
# TYPE bzod_active_urls gauge
bzod_active_urls {active_links}
# HELP bzod_dead_urls Total number of dead short URLs
# TYPE bzod_dead_urls gauge
bzod_dead_urls {dead_links}
# HELP bzod_pages_total Total number of registered landing pages
# TYPE bzod_pages_total gauge
bzod_pages_total {total_pages}
# HELP bzod_clicks_total Total number of URL clicks recorded
# TYPE bzod_clicks_total counter
bzod_clicks_total {total_clicks}
# HELP bzod_page_views_total Total number of page views recorded
# TYPE bzod_page_views_total counter
bzod_page_views_total {total_page_views}
# HELP bzod_memory_bytes Memory usage of the bzod process in bytes
# TYPE bzod_memory_bytes gauge
bzod_memory_bytes {mem_bytes}
# HELP bzod_uptime_seconds Uptime of the bzod process in seconds
# TYPE bzod_uptime_seconds counter
bzod_uptime_seconds {uptime}
"#,
total_urls = total_urls,
active_links = active_links,
dead_links = dead_links,
total_pages = total_pages,
total_clicks = total_clicks,
total_page_views = total_page_views,
mem_bytes = mem_bytes,
uptime = uptime
);
(
StatusCode::OK,
[("Content-Type", "text/plain; version=0.0.4; charset=utf-8")],
metrics_text,
).into_response()
}
+71
View File
@@ -0,0 +1,71 @@
{% extends "layout.html" %}
{% block title %}Audit Logs - BZOD{% endblock %}
{% block active_audit %}active{% endblock %}
{% block header_title %}Security Audit Logs{% endblock %}
{% block content %}
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>
System Action Log
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Timestamp</th>
<th>Action</th>
<th>Operator (User)</th>
<th>Source IP Address</th>
<th>Log Details / Context</th>
</tr>
</thead>
<tbody>
{% if logs.is_empty() %}
<tr>
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No audit records logged yet.
</td>
</tr>
{% else %}
{% for log in logs %}
<tr>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); white-space: nowrap;">
{{ log.timestamp[0..19].replace("T", " ") }}
</td>
<td>
<span class="badge" style="background-color: rgba(99, 102, 241, 0.15); color: #818cf8; border: 1px solid rgba(99,102,241,0.2);">
{{ log.action }}
</span>
</td>
<td>
<span style="font-weight: 600; color: var(--text-primary);">{{ log.username }}</span>
</td>
<td style="font-family: monospace; font-size: 0.85rem; color: var(--text-secondary);">
{{ log.ip_address.as_deref().unwrap_or("Unknown") }}
</td>
<td style="color: var(--text-secondary); font-size: 0.85rem;">
{% if let Some(obj_type) = log.object_type %}
<span style="font-weight: 500;">Type:</span> {{ obj_type }}
{% endif %}
{% if let Some(obj_id) = log.object_id %}
| <span style="font-weight: 500;">ID:</span> {{ obj_id }}
{% endif %}
{% if let Some(ua) = log.user_agent %}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">UA: {{ ua }}</div>
{% endif %}
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "layout.html" %}
{% block title %}Dashboard - BZOD{% endblock %}
{% block active_dashboard %}active{% endblock %}
{% block header_title %}Dashboard Overview{% endblock %}
{% block content %}
<!-- Overview Cards -->
<div class="grid-stats">
<div class="card stat-card">
<span class="stat-label">Total Short URLs</span>
<span class="stat-val" style="color: #60a5fa;">{{ total_urls }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Landing Pages</span>
<span class="stat-val" style="color: #c084fc;">{{ total_pages }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Total Visits / Clicks</span>
<span class="stat-val" style="color: #34d399;">{{ total_clicks }}</span>
</div>
<div class="card stat-card">
<span class="stat-label">Active / Dead Links</span>
<div style="display: flex; align-items: baseline; gap: 0.5rem; margin-top: 0.25rem;">
<span class="stat-val" style="color: #10b981;">{{ active_links }}</span>
<span style="color: var(--text-muted); font-size: 1.25rem;">/</span>
<span style="font-size: 1.25rem; font-weight: 700; color: #ef4444;">{{ dead_links }}</span>
</div>
</div>
</div>
<!-- Main Traffic Chart -->
<div class="card" style="margin-bottom: 2rem;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; color: var(--text-primary); display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>
Click Traffic Trend (Last 30 Days)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ traffic_chart|safe }}
</div>
</div>
<!-- Secondary Charts Grid -->
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 1.5rem; margin-bottom: 2rem;">
<!-- Countries -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
Geographic Analysis (Top Countries)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ countries_chart|safe }}
</div>
</div>
<!-- Referrers -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Referrer Channels (Top Referrers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ referrers_chart|safe }}
</div>
</div>
<!-- Browsers -->
<div class="card" style="grid-column: span 1;">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/></svg>
User Agent breakdown (Top Browsers)
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1rem; border: 1px solid rgba(255, 255, 255, 0.03);">
{{ browsers_chart|safe }}
</div>
</div>
</div>
{% endblock %}
+441
View File
@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}BZOD Admin{% endblock %}</title>
<style>
:root {
--bg-base: #090d16;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--text-muted: #64748b;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
--danger-color: #ef4444;
--success-color: #10b981;
--warning-color: #f59e0b;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
overflow-x: hidden;
}
/* Sidebar Navigation */
.sidebar {
width: 260px;
background-color: rgba(15, 23, 42, 0.95);
border-right: 1px solid var(--border-color);
padding: 2rem 1.5rem;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
z-index: 100;
}
.logo {
font-size: 1.5rem;
font-weight: 800;
background: var(--primary-grad);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 2.5rem;
letter-spacing: 2px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.logo-dot {
width: 8px;
height: 8px;
background: #818cf8;
border-radius: 50%;
display: inline-block;
}
.nav-links {
list-style: none;
display: flex;
flex-direction: column;
gap: 0.5rem;
flex-grow: 1;
}
.nav-links a {
color: var(--text-secondary);
text-decoration: none;
padding: 0.75rem 1rem;
border-radius: 8px;
display: flex;
align-items: center;
gap: 0.75rem;
font-weight: 500;
transition: all 0.2s ease;
}
.nav-links a:hover {
background-color: rgba(255, 255, 255, 0.03);
color: var(--text-primary);
transform: translateX(3px);
}
.nav-links li.active a {
background: var(--primary-grad);
color: var(--text-primary);
box-shadow: 0 4px 14px 0 rgba(99, 102, 241, 0.3);
}
.sidebar-footer {
border-top: 1px solid var(--border-color);
padding-top: 1.5rem;
margin-top: auto;
}
.admin-user-info {
display: flex;
align-items: center;
gap: 0.75rem;
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 1rem;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: #312e81;
color: #818cf8;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
}
.logout-btn {
display: block;
width: 100%;
padding: 0.75rem;
background: rgba(239, 68, 68, 0.1);
color: var(--danger-color);
border: 1px solid rgba(239, 68, 68, 0.2);
border-radius: 8px;
text-align: center;
text-decoration: none;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s ease;
}
.logout-btn:hover {
background: var(--danger-color);
color: #fff;
}
/* Main Content */
.main-container {
margin-left: 260px;
flex-grow: 1;
padding: 2.5rem;
max-width: 1200px;
width: calc(100% - 260px);
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2.5rem;
}
.header h1 {
font-size: 2rem;
font-weight: 700;
}
/* Cards and Elements */
.card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 1.5rem;
margin-bottom: 1.5rem;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.2);
}
/* Grid */
.grid-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 1px;
}
.stat-val {
font-size: 2rem;
font-weight: 700;
}
/* Forms */
.form-group {
margin-bottom: 1.25rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.9rem;
font-weight: 600;
color: var(--text-secondary);
}
.form-input, select, textarea {
background-color: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
transition: all 0.2s ease;
}
.form-input:focus, select:focus, textarea:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(99, 102, 241, 0.3);
}
.btn-secondary {
background: rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-color);
color: var(--text-primary);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.1);
box-shadow: none;
}
.btn-danger {
background: var(--danger-color);
color: white;
}
.btn-danger:hover {
box-shadow: 0 4px 12px 0 rgba(239, 68, 68, 0.3);
}
/* Tables */
.table-container {
width: 100%;
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
th {
padding: 1rem;
color: var(--text-secondary);
font-size: 0.85rem;
text-transform: uppercase;
border-bottom: 1px solid var(--border-color);
font-weight: 600;
}
td {
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
font-size: 0.95rem;
color: var(--text-primary);
}
tr:hover td {
background-color: rgba(255, 255, 255, 0.01);
}
/* Badges */
.badge {
display: inline-block;
padding: 0.25rem 0.6rem;
border-radius: 50px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-healthy { background: rgba(16, 185, 129, 0.15); color: var(--success-color); border: 1px solid rgba(16, 185, 129, 0.2); }
.badge-suspect { background: rgba(245, 158, 11, 0.15); color: var(--warning-color); border: 1px solid rgba(245, 158, 11, 0.2); }
.badge-dead { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); border: 1px solid rgba(239, 68, 68, 0.2); }
.badge-published { background: rgba(16, 185, 129, 0.15); color: var(--success-color); }
.badge-draft { background: rgba(100, 116, 139, 0.15); color: var(--text-secondary); }
.badge-archived { background: rgba(239, 68, 68, 0.15); color: var(--danger-color); }
/* Alerts */
.alert {
padding: 1rem;
border-radius: 8px;
margin-bottom: 1.5rem;
font-weight: 500;
display: flex;
align-items: center;
gap: 0.5rem;
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
}
.alert-success {
background-color: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: #a7f3d0;
}
/* Tooltip */
.tooltip {
position: relative;
cursor: pointer;
}
/* Custom scrollbar */
::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-track { background: var(--bg-base); }
::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.1); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.2); }
{% block extra_css %}{% endblock %}
</style>
</head>
<body>
<!-- Sidebar -->
<div class="sidebar">
<div class="logo">
BZOD <span class="logo-dot"></span>
</div>
<ul class="nav-links">
<li class="{% block active_dashboard %}{% endblock %}">
<a href="/admin/dashboard">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9"/><rect x="14" y="3" width="7" height="5"/><rect x="14" y="12" width="7" height="9"/><rect x="3" y="16" width="7" height="5"/></svg>
Dashboard
</a>
</li>
<li class="{% block active_urls %}{% endblock %}">
<a href="/admin/urls">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
Short URLs
</a>
</li>
<li class="{% block active_pages %}{% endblock %}">
<a href="/admin/pages">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Landing Pages
</a>
</li>
<li class="{% block active_settings %}{% endblock %}">
<a href="/admin/settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
Settings
</a>
</li>
<li class="{% block active_audit %}{% endblock %}">
<a href="/admin/audit">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>
Audit Log
</a>
</li>
<li class="{% block active_status %}{% endblock %}">
<a href="/admin/status">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><polyline points="10 6 14 12 10 18"/><line x1="18" y1="12" x2="14" y2="12"/><line x1="6" y1="12" x2="10" y2="12"/></svg>
Status
</a>
</li>
</ul>
<div class="sidebar-footer">
<div class="admin-user-info">
<div class="avatar">{{ admin_username[0..1].to_uppercase() }}</div>
<span>{{ admin_username }}</span>
</div>
<a href="/admin/logout" class="logout-btn">Log Out</a>
</div>
</div>
<!-- Main Workspace -->
<div class="main-container">
<div class="header">
<h1>{% block header_title %}{% endblock %}</h1>
<div>
{% block header_actions %}{% endblock %}
</div>
</div>
{% block content %}{% endblock %}
</div>
</body>
</html>
+174
View File
@@ -0,0 +1,174 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login - BZOD Platform</title>
<style>
:root {
--bg-base: #070a13;
--bg-card: rgba(17, 24, 39, 0.7);
--border-color: rgba(255, 255, 255, 0.08);
--text-primary: #f8fafc;
--text-secondary: #94a3b8;
--primary-grad: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
--accent-color: #6366f1;
--danger-color: #ef4444;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
background-color: var(--bg-base);
color: var(--text-primary);
font-family: 'Outfit', 'Inter', system-ui, -apple-system, sans-serif;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.login-card {
background-color: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 16px;
padding: 2.5rem;
width: 100%;
max-width: 420px;
backdrop-filter: blur(12px);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.4);
text-align: center;
}
.logo {
font-size: 1.75rem;
font-weight: 800;
background: var(--primary-grad);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
letter-spacing: 2px;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.logo-dot {
width: 8px;
height: 8px;
background: #818cf8;
border-radius: 50%;
display: inline-block;
}
.subtitle {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 2rem;
}
.form-group {
margin-bottom: 1.25rem;
text-align: left;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.form-group label {
font-size: 0.85rem;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input {
background-color: rgba(15, 23, 42, 0.6);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 0.75rem 1rem;
color: var(--text-primary);
font-family: inherit;
font-size: 0.95rem;
width: 100%;
transition: all 0.2s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(99, 102, 241, 0.2);
}
.btn {
background: var(--primary-grad);
color: #fff;
border: none;
padding: 0.75rem 1.5rem;
border-radius: 8px;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
width: 100%;
margin-top: 1rem;
transition: all 0.2s ease;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
box-shadow: 0 4px 12px 0 rgba(99, 102, 241, 0.35);
}
.alert-error {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: #fca5a5;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
margin-bottom: 1.5rem;
text-align: left;
font-weight: 500;
}
</style>
</head>
<body>
<div class="login-card">
<div class="logo">
BZOD <span class="logo-dot"></span>
</div>
<p class="subtitle">Personal Redirects & Landing Pages</p>
{% if let Some(err) = error %}
<div class="alert-error">
{{ err }}
</div>
{% endif %}
<form action="/admin/login" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-input" required autofocus autocomplete="username">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" required autocomplete="current-password">
</div>
<button type="submit" class="btn">Sign In</button>
</form>
</div>
</body>
</html>
+137
View File
@@ -0,0 +1,137 @@
{% extends "layout.html" %}
{% block title %}Manage Landing Pages - BZOD{% endblock %}
{% block active_pages %}active{% endblock %}
{% block header_title %}Landing Page Registry{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Create Landing Page Form -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Create a New Landing Page
</h3>
<form action="/admin/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label for="title">Page Title *</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
</div>
<div class="form-group">
<label for="slug">SEO Slug *</label>
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
<div class="form-group">
<label for="code">Short Code (4-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
</select>
</div>
</div>
<div class="form-group">
<label for="html_content">Raw HTML Document *</label>
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form>
</div>
<!-- Landing Pages Table -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
Registered Pages
</h3>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Page Title</th>
<th>Short Path</th>
<th>SEO Preview Path</th>
<th>Status</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages registered. Create one to get started!
</td>
</tr>
{% else %}
{% for page in pages %}
<tr>
<td>
<div style="font-weight: 700; color: var(--text-primary);">
{{ page.title }}
</div>
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
UUID: {{ page.id[0..8] }}...
</div>
</td>
<td>
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
/p/{{ page.code }}
</a>
</td>
<td>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
/p/{{ page.code }}/{{ page.slug }}
</a>
</td>
<td>
<span class="badge badge-{{ page.state }}">
{{ page.state }}
</span>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
<td>
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+188
View File
@@ -0,0 +1,188 @@
{% extends "layout.html" %}
{% block title %}Settings - BZOD{% endblock %}
{% block active_settings %}active{% endblock %}
{% block header_title %}System Settings{% endblock %}
{% block content %}
{% if let Some(msg) = success %}
<div class="alert alert-success">
{{ msg }}
</div>
{% endif %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Left Column -->
<div style="display: flex; flex-direction: column; gap: 1.5rem;">
<!-- Change Password -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
Change Password
</h3>
<form action="/admin/settings/password" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="current_password">Current Password</label>
<input type="password" id="current_password" name="current_password" class="form-input" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="new_password">New Password</label>
<input type="password" id="new_password" name="new_password" class="form-input" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Update Password</button>
</form>
</div>
<!-- Data Retention -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
Analytics Retention Policy
</h3>
<form action="/admin/settings/retention" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="retention">Visits Logs Retention</label>
<select id="retention" name="retention">
<option value="30" {% if data_retention == "30" %}selected{% endif %}>30 Days</option>
<option value="90" {% if data_retention == "90" %}selected{% endif %}>90 Days</option>
<option value="365" {% if data_retention == "365" %}selected{% endif %}>365 Days</option>
<option value="unlimited" {% if data_retention == "unlimited" %}selected{% endif %}>Unlimited</option>
</select>
</div>
<p style="font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 1rem; line-height: 1.4;">
Old visits logs are cleaned up daily. Changing this policy does not affect aggregated monthly/yearly summaries.
</p>
<button type="submit" class="btn" style="width: 100%;">Save Retention Policy</button>
</form>
</div>
<!-- Maintenance Operations -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
Maintenance & DB Utilities
</h3>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div>
<form action="/admin/settings/compact" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-secondary" style="width: 100%; text-align: left; justify-content: flex-start;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
Compact Databases (VACUUM)
</button>
</form>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; margin-left: 0.5rem;">
Reclaims unused space and defragments all SQLite database files.
</p>
</div>
<div>
<a href="/admin/settings/backup" class="btn" style="width: 100%; text-align: left; justify-content: flex-start; background: #047857;">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
Download Backup (.tar.gz)
</a>
<p style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem; margin-left: 0.5rem;">
Generates a tarball of admin.db, content.db, and analytics.db.
</p>
</div>
</div>
</div>
</div>
<!-- Right Column: API Keys -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4"/></svg>
REST API Tokens
</h3>
</div>
<div style="padding: 1.5rem; border-bottom: 1px solid var(--border-color);">
<form action="/admin/settings/api-keys/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="key_name">Generate Token Name</label>
<div style="display: flex; gap: 0.5rem;">
<input type="text" id="key_name" name="key_name" class="form-input" placeholder="e.g. HomeAssistant Script" required>
<button type="submit" class="btn">Generate</button>
</div>
</div>
</form>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Key Description</th>
<th>Created</th>
<th>Last Used</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if api_keys.is_empty() %}
<tr>
<td colspan="4" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No active API tokens generated yet.
</td>
</tr>
{% else %}
{% for key in api_keys %}
<tr>
<td style="font-weight: 600; color: var(--text-primary);">
{{ key.name }}
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
Key Prefix: {{ key.key_hash[0..8] }}...
</div>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ key.created_at[0..10] }}
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{% if let Some(last) = key.last_used_at %}
{{ last[0..16].replace("T", " ") }}
{% else %}
Never
{% endif %}
</td>
<td>
<form action="/admin/settings/api-keys/revoke/{{ key.id }}" method="POST" onsubmit="return confirm('Are you sure you want to revoke this API token? Any integrations using it will fail.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Revoke
</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+69
View File
@@ -0,0 +1,69 @@
{% extends "layout.html" %}
{% block title %}System Diagnostics - BZOD{% endblock %}
{% block active_status %}active{% endblock %}
{% block header_title %}Server Status & Diagnostics{% endblock %}
{% block content %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Left Column: Core Performance Metrics -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>
System Status
</h3>
<div style="display: flex; flex-direction: column; gap: 1.25rem;">
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Application Status</span>
<span class="badge badge-healthy" style="font-size: 0.85rem;">{{ app_status }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Uptime</span>
<span style="font-weight: 600; font-family: monospace;">{{ uptime }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Memory Usage</span>
<span style="font-weight: 600; font-family: monospace;">{{ memory_usage }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Analytics Memory Queue Size</span>
<span style="font-weight: 600; font-family: monospace; color: {% if queue_size > 100 %}var(--warning-color){% else %}var(--success-color){% endif %};">
{{ queue_size }} items
</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 0.75rem; border-bottom: 1px solid rgba(255,255,255,0.03);">
<span style="color: var(--text-secondary);">Build Version</span>
<span style="font-weight: 600; font-family: monospace;">v{{ version }}</span>
</div>
<div style="display: flex; justify-content: space-between; align-items: center;">
<span style="color: var(--text-secondary);">Git Commit Hash</span>
<span style="font-weight: 600; font-family: monospace; color: var(--text-muted);">{{ git_commit }}</span>
</div>
</div>
</div>
<!-- Right Column: Database Storage info -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22c5.523 0 10-2.239 10-5V5c0-2.761-4.477-5-10-5S2 2.239 2 5v12c0 2.761 4.477 5 10 5z"/><path d="M2 5c0 2.761 4.477 5 10 5s10-2.761 10-5"/><path d="M2 11c0 2.761 4.477 5 10 5s10-2.761 10-5"/></svg>
Database Status
</h3>
<div style="background-color: rgba(15, 23, 42, 0.4); border-radius: 12px; padding: 1.25rem; border: 1px solid rgba(255, 255, 255, 0.03); white-space: pre-wrap; font-family: monospace; font-size: 0.85rem; color: var(--text-secondary); line-height: 1.6;">{{ db_status }}</div>
<p style="font-size: 0.8rem; color: var(--text-muted); margin-top: 1rem; line-height: 1.4;">
All SQLite databases are running with Write-Ahead Logging (WAL) enabled, providing concurrent reads and high transactional throughput.
</p>
</div>
</div>
{% endblock %}
+146
View File
@@ -0,0 +1,146 @@
{% extends "layout.html" %}
{% block title %}Manage Short URLs - BZOD{% endblock %}
{% block active_urls %}active{% endblock %}
{% block header_title %}Short URL Registry{% endblock %}
{% block content %}
{% if let Some(err) = error %}
<div class="alert alert-error">
{{ err }}
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Link Form -->
<div class="card">
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
Shorten a New URL
</h3>
<form action="/admin/urls/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="form-group">
<label for="destination">Destination URL *</label>
<input type="url" id="destination" name="destination" class="form-input" placeholder="https://example.com/very-long-path" required>
</div>
<div class="form-group">
<label for="code">Short Code (6-Hex, optional)</label>
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. 4f8c1a (auto-generated if empty)" pattern="[0-9a-fA-F]{6}" title="Must be exactly 6 hex characters (0-9, a-f)">
</div>
<div class="form-group">
<label for="title">Title (optional)</label>
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. My Blog Post">
</div>
<div class="form-group">
<label for="description">Description (optional)</label>
<textarea id="description" name="description" class="form-input" placeholder="Notes or summary..." rows="2"></textarea>
</div>
<div class="form-group">
<label for="tags">Tags (comma-separated, optional)</label>
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
</form>
</div>
<!-- Links Table -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Registered Links
</h3>
{% if let Some(tag) = tag_filter %}
<span class="badge badge-healthy" style="text-transform: none;">
Filtered by tag: {{ tag }}
<a href="/admin/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">&times;</a>
</span>
{% endif %}
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Short Link</th>
<th>Destination</th>
<th>Health</th>
<th>Tags</th>
<th>Created</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No shortened URLs registered. Create one to get started!
</td>
</tr>
{% else %}
{% for url in urls %}
<tr>
<td>
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
bzo.in/{{ url.code }}
</a>
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
{{ url.title.as_deref().unwrap_or("") }}
</div>
</td>
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.destination }}">
{{ url.destination }}
</a>
{% if let Some(desc) = url.description.as_deref() %}
{% if !desc.is_empty() %}
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">
{{ desc }}
</div>
{% endif %}
{% endif %}
</td>
<td>
<span class="badge badge-{{ url.status }}">
{{ url.status }}
</span>
</td>
<td>
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;">
{% for t in url.tags %}
<a href="/admin/urls?tag={{ t }}" class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); text-decoration: none; font-size: 0.7rem; border: 1px solid var(--border-color);">
{{ t }}
</a>
{% endfor %}
</div>
</td>
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ url.created_at[0..10] }}
</td>
<td>
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
Delete
</button>
</form>
</td>
</tr>
{% endfor %}
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}
+150
View File
@@ -0,0 +1,150 @@
use rusqlite::Connection;
use chrono::Utc;
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::Cookie;
use bzod::auth::{
verify_csrf, generate_csrf_token, authenticate_session, authenticate_api_key,
hash_password, verify_sha256, verify_password
};
use bzod::db::admin::{
create_user, create_session, get_user_count, create_api_key
};
use bzod::db::migrations::{run_migrations, ADMIN_MIGRATIONS};
// Helper to set up an in-memory admin.db connection with migrations applied
fn setup_test_db() -> Connection {
let mut conn = Connection::open_in_memory().unwrap();
run_migrations(&mut conn, "admin", ADMIN_MIGRATIONS, None).unwrap();
conn
}
#[test]
fn test_csrf_tampering_prevention() {
let session_id = "secret_session_id_123456";
let valid_token = generate_csrf_token(session_id);
// Mismatched token must fail
assert!(!verify_csrf(session_id, "different_token_value"));
// Valid token must pass
assert!(verify_csrf(session_id, &valid_token));
// Mismatched session id must fail even if token matches the original session id
assert!(!verify_csrf("different_session_id_789", &valid_token));
}
#[test]
fn test_api_key_sql_injection_resistance() {
let conn = setup_test_db();
// Create an API key
let user_hash = hash_password("admin_pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// Generate valid API key
let key_secret = "bzo_validkey1234567890abcdef";
use sha2::{Sha256, Digest};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
create_api_key(&conn, &user.id, "my-key", &hashed_key).unwrap();
// 1. Test valid key passes
let valid_auth = format!("Bearer {}", key_secret);
let auth_res = authenticate_api_key(&conn, &valid_auth).unwrap();
assert!(auth_res.is_some());
assert_eq!(auth_res.unwrap().username, "admin");
// 2. Test SQL Injection attempt in the header does not succeed or crash
let sql_inj_auth1 = "Bearer ' OR 1=1 --";
let res = authenticate_api_key(&conn, sql_inj_auth1).unwrap();
assert!(res.is_none());
let sql_inj_auth2 = "Bearer ' UNION SELECT id, username FROM users --";
let res = authenticate_api_key(&conn, sql_inj_auth2).unwrap();
assert!(res.is_none());
// 3. Test malformed header
let malformed_auth = "Bearer";
let res = authenticate_api_key(&conn, malformed_auth).unwrap();
assert!(res.is_none());
let wrong_scheme = "Basic admin:pass";
let res = authenticate_api_key(&conn, wrong_scheme).unwrap();
assert!(res.is_none());
}
#[test]
fn test_expired_session_invalidation() {
let conn = setup_test_db();
let user_hash = hash_password("pass").unwrap();
let user = create_user(&conn, "admin", &user_hash).unwrap();
// 1. Session in the future must be valid
let future_expiry = (Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let session_id_future = "future_session_token";
create_session(&conn, session_id_future, &user.id, &future_expiry).unwrap();
let jar_future = CookieJar::new().add(Cookie::new("bzod_session", session_id_future));
let auth_future = authenticate_session(&conn, &jar_future).unwrap();
assert!(auth_future.is_some());
assert_eq!(auth_future.unwrap().0.id, user.id);
// 2. Session in the past must be rejected
let past_expiry = (Utc::now() - chrono::Duration::hours(1)).to_rfc3339();
let session_id_past = "expired_session_token";
create_session(&conn, session_id_past, &user.id, &past_expiry).unwrap();
let jar_past = CookieJar::new().add(Cookie::new("bzod_session", session_id_past));
let auth_past = authenticate_session(&conn, &jar_past).unwrap();
assert!(auth_past.is_none());
}
#[test]
fn test_bootstrap_credentials_deactivation() {
let conn = setup_test_db();
let bootstrap_sha = "8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918"; // SHA-256 of "admin"
// 1. Initially, no users exist in database
assert_eq!(get_user_count(&conn).unwrap(), 0);
// Bootstrap validation is allowed
assert!(verify_sha256("admin", bootstrap_sha));
// 2. Provision a user in database (either via bootstrap login or CLI)
let user_hash = hash_password("new_secure_admin_password").unwrap();
create_user(&conn, "admin", &user_hash).unwrap();
// Check that database now has users
assert_eq!(get_user_count(&conn).unwrap(), 1);
// Standard credential validation must pass
let user_opt = bzod::db::admin::get_user_by_username(&conn, "admin").unwrap();
assert!(user_opt.is_some());
assert!(verify_password("new_secure_admin_password", &user_opt.unwrap().password_hash));
// The bootstrap credentials MUST be ignored now (the application logic checks users count,
// which is 1, so it bypasses the bootstrap check and verifies ONLY against the database).
}
#[test]
fn test_path_traversal_rejection() {
// Standard Axum router exact path matching prevents path traversal on endpoints.
// If a request has /../admin, standard HTTP parsers and Axum router resolve it as /admin
// (which checks session cookies) or return 404 for unresolved paths.
// Here we verify that code inputs containing traversal strings are parsed as invalid codes.
let invalid_codes = vec!["../foo", "..%2ff", "/admin", "a/b/c", "1234567"];
for code in invalid_codes {
// Validate redirect code must be exactly 6 hex digits
let is_valid_redirect_code = code.len() == 6 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_redirect_code, "Code '{}' should be rejected as a valid redirect shortcode", code);
// Validate landing page code must be exactly 4 hex digits
let is_valid_page_code = code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit());
assert!(!is_valid_page_code, "Code '{}' should be rejected as a valid landing page shortcode", code);
}
}