Initial public release
This commit is contained in:
commit
c1107147b4
92 files changed
+10562
No files matched your search
@@ -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
|
||||
@@ -0,0 +1,7 @@
|
||||
/target
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
.env
|
||||
|
||||
Generated
+2770
File diff suppressed because it is too large.
Load diff
+30
@@ -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
@@ -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"]
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
},
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
pub mod shortener;
|
||||
pub mod landing_pages;
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
// General web middleware placeholder
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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;">×</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 %}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user