17 Commits
Author SHA1 Message Date
thakares f138a645f8 fix: configure production base URL
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-13 12:23:39 +05:30
thakares ac66945c93 docs: refresh v0.7.1 UI screenshots
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 20:02:40 +05:30
thakares 763a17f8dc fix: update CasaOS production deployment
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 19:08:06 +05:30
thakares 8e0bcbe580 feat: add Docker first-start admin bootstrap
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 14:13:09 +05:30
thakares 25577b4b83 feat: finalize v0.7.0 Docker deployment and image routing
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-12 13:02:44 +05:30
thakares 2cd3c2d965 Release v0.7.0 documentation and version update
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-11 11:33:33 +05:30
thakares b66703082f Refactor responsive UI and build metadata
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-10 17:46:49 +05:30
thakares f49698bb5c Release v0.6.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-09 17:17:57 +05:30
thakares 7069ca9db7 docs(web): update social preview image 2026-07-01 15:15:08 +05:30
thakares faf8fc0eda docs(web): update social preview image 2026-07-01 15:12:47 +05:30
thakares bf29ccab56 fix(web): correct Open Graph preview image URL 2026-07-01 14:51:27 +05:30
thakares 667503c8f6 fix(web): add social preview image assets 2026-07-01 14:42:10 +05:30
thakares 83218ba602 docs: update documentation for v0.5.3
- Refresh administrator guide
- Update architecture documentation
- Document Registry Validator
- Document Registry Repair Framework
- Update backup and restore guide
- Refresh CLI documentation
- Update Docker deployment example
- Bump version to v0.5.3
2026-06-29 17:44:10 +05:30
thakares 58c0af6510 docs: refresh README for v0.5.3
- Rewrite project overview
- Update architecture documentation
- Document Registry Validator and Repair Framework
- Refresh CLI reference
- Expand installation and deployment guides
- Update feature matrix
- Add roadmap and operational tooling
- Update badges and version
2026-06-29 17:12:43 +05:30
thakares f4947489af feat: add registry repair framework
- add shared registry validation service
- add transaction-safe repair CLI
- refactor doctor to use validator
- improve restore integrity checks
- add registry repair integration tests
- strengthen global slug consistency
2026-06-29 16:53:05 +05:30
thakares 2761863c14 Release v0.5.2
- Add admin content consistency diagnostics
- Add admin-migrate CLI
- Harden RBAC for API endpoints
- Normalize multi-tenant storage paths
- Improve backup and restore compatibility
- Fix administrator routing consistency
- Improve doctor and stats commands
2026-06-29 16:11:43 +05:30
thakares a32c0fd7ca docs: update README and v0.5.1 release notes 2026-06-20 20:58:49 +05:30
116 changed files with 15907 additions and 9017 deletions

No files matched your search

+3 -3
View File
@@ -8,10 +8,10 @@ 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
# REQUIRED for a fresh deployment.
# Use a strong unique password.
ADMIN_PASSWORD=
# Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60
+2 -1
View File
@@ -1,4 +1,4 @@
/target
/target/
data/
*.db
*.db-wal
@@ -6,3 +6,4 @@ data/
.env
.idea/
bzod.env
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.5.1"
version = "0.7.0"
dependencies = [
"argon2",
"askama",
+1 -1
View File
@@ -1,7 +1,7 @@
[package]
name = "bzod"
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
version = "0.5.1"
version = "0.7.0"
edition = "2021"
license = "MIT OR Apache-2.0"
repository = "https://github.com/thakares/nx9-url-shortener"
+55 -33
View File
@@ -1,73 +1,95 @@
# ==========================================
# Stage 1: Builder (with optimized caching)
# Stage 1: Builder
# ==========================================
FROM rust:1.89-bookworm AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y \
# Build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
pkg-config \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Copy only Cargo files first (best caching)
# Dependency metadata first for Docker layer caching
COPY Cargo.toml Cargo.lock ./
# Create dummy source for dependency caching
# Dummy build to cache Rust dependencies
RUN mkdir -p src && \
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
cargo build --release && \
rm -rf src target/release/deps/bzod*
printf 'fn main() {}\n' > src/main.rs && \
cargo build --release --locked && \
rm -rf src
# Copy real source code + assets
# Actual application source and runtime assets
COPY src ./src
COPY templates ./templates
COPY www ./www
# Build the real application
RUN cargo build --release
# Reproducible production build
RUN cargo build --release --locked
# ==========================================
# Stage 2: Runtime (slim)
# Stage 2: Runtime
# ==========================================
FROM debian:bookworm-slim
FROM debian:bookworm-slim AS runtime
WORKDIR /app
# Runtime dependencies
RUN apt-get update && apt-get install -y \
openssl \
# Runtime dependencies only
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# Copy binary from builder
# Create unprivileged runtime user
RUN groupadd --gid 1000 bzod && \
useradd --uid 1000 --gid 1000 \
--create-home \
--shell /usr/sbin/nologin \
bzod
# Application binary
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Copy assets
COPY --from=builder /app/templates ./templates
COPY --from=builder /app/www ./www
# Application-owned immutable assets
COPY --from=builder /app/templates /app/templates
COPY --from=builder /app/www /app/www
# Create non-root user
RUN groupadd -g 1000 bzod && \
useradd -u 1000 -g bzod -m -s /bin/bash bzod
# Create data directory
RUN mkdir -p /app/data && \
chown -R bzod:bzod /app
USER bzod
# Persistent runtime directories.
# /app/images is intentionally external/persistent in Compose.
RUN mkdir -p \
/app/data \
/app/config \
/app/images && \
chown -R bzod:bzod \
/app/data \
/app/config \
/app/images \
/app/templates \
/app/www \
/usr/local/bin/bzod \
/usr/local/bin/docker-entrypoint.sh
# Runtime configuration
ENV DATA_DIR=/app/data \
CONFIG_DIR=/app/config \
IMAGES_DIR=/app/images \
PORT=8654 \
HOST=0.0.0.0 \
COOKIE_SECURE=true
EXPOSE 8654
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:${PORT}/status || exit 1
HEALTHCHECK \
--interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
ENTRYPOINT ["bzod"]
CMD ["serve"]
USER bzod
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["serve"]
+1192 -883
View File
File diff suppressed because it is too large. Load diff
+18
View File
@@ -0,0 +1,18 @@
use std::process::Command;
fn main() {
println!("cargo:rerun-if-changed=.git/HEAD");
println!("cargo:rerun-if-changed=.git/refs");
if let Ok(output) = Command::new("git")
.args(["rev-parse", "--short=12", "HEAD"])
.output()
{
if output.status.success() {
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !commit.is_empty() {
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
}
}
}
}
+425 -176
View File
@@ -1,226 +1,475 @@
#!/usr/bin/env bash
# BZOD Production Deployment Script
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
#
# BZOD Production Docker Deployment
#
# Privacy-First URL Shortener & Landing Page Platform
#
# Usage:
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
#
# Or:
# sudo bash deploy.sh
#
# Environment overrides:
# BZOD_VERSION=0.7.1
# BZOD_IMAGE=nx9-url-shortener
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
# BZOD_PORT=8654
#
set -euo pipefail
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"
# ============================================================
# Configuration
# ============================================================
BZOD_VERSION="${BZOD_VERSION:-0.7.1}"
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
BZOD_PORT="${BZOD_PORT:-8654}"
BASE_URL="${BASE_URL:-https://bzo.in}"
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
DATA_DIR="${BZOD_ROOT}/data"
CONFIG_DIR="${BZOD_ROOT}/config"
IMAGES_DIR="${BZOD_ROOT}/images"
COMPOSE_DIR="${BZOD_ROOT}/compose"
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
ENV_FILE="${COMPOSE_DIR}/bzod.env"
BACKUP_ROOT="${BZOD_ROOT}/backups"
IMAGE="${BZOD_IMAGE}:${BZOD_VERSION}"
# ============================================================
# Output
# ============================================================
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
NC='\033[0m'
# Temporary file cleanup
TMP_BINARY=""
cleanup() {
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
info() {
echo -e "${BLUE}$*${NC}"
}
trap cleanup EXIT
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
echo -e "Production deployment started...\n"
success() {
echo -e "${GREEN}$*${NC}"
}
if [ "$EUID" -ne 0 ]; then
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
exit 1
fi
warning() {
echo -e "${YELLOW}$*${NC}"
}
# 1. Install Base Dependencies
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
apt-get update -qq
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
error() {
echo -e "${RED}$*${NC}" >&2
}
# 2. Install Binary (safe atomic download)
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
ARCH="$(uname -m)"
case $ARCH in
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
esac
REPO="thakares/nx9-url-shortener"
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
TMP_BINARY=$(mktemp)
echo "Trying GitHub Releases..."
if curl --retry 5 --retry-delay 2 --retry-connrefused \
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
else
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
if command -v docker >/dev/null 2>&1; then
TMP_GHCR=$(mktemp)
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
mv "${TMP_GHCR}" "${TMP_BINARY}"
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
fi
fi
if [ ! -s "${TMP_BINARY}" ]; then
echo -e "${BLUE}Falling back to local build...${NC}"
if ! command -v cargo >/dev/null 2>&1; then
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
exit 1
fi
apt-get install -y pkg-config build-essential
cargo build --release
cp target/release/bzod "${TMP_BINARY}"
echo -e "${GREEN}✓ Built from source${NC}"
fi
fi
# Atomic replace with backup
if [ -f "${INSTALL_PATH}" ]; then
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
fi
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
# Verify
if [ ! -x "${INSTALL_PATH}" ]; then
echo -e "${RED}Binary installation failed${NC}"
exit 1
fi
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
echo -e "${RED}Binary verification failed${NC}"
die() {
error "$*"
exit 1
}
# Show installed version
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
# ============================================================
# Root check
# ============================================================
# 3. Create System User
echo -e "\n${BLUE}[3/8] Creating 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}"
if [[ "${EUID}" -ne 0 ]]; then
die "This script must be run as root. Use: sudo bash deploy.sh"
fi
# 4. Setup Directories
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
echo
echo -e "${BLUE}============================================================${NC}"
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
echo -e "${BLUE}============================================================${NC}"
echo
echo "Version: ${BZOD_VERSION}"
echo "Image: ${IMAGE}"
echo "Application: ${BZOD_ROOT}"
echo "Data: ${DATA_DIR}"
echo "Config: ${CONFIG_DIR}"
echo "Images: ${IMAGES_DIR}"
echo "Port: ${BZOD_PORT}"
echo
# ============================================================
# 1. Install Docker
# ============================================================
info "[1/8] Checking Docker..."
if ! command -v docker >/dev/null 2>&1; then
info "Docker is not installed. Installing Docker..."
apt-get update -qq
apt-get install -y \
ca-certificates \
curl
install -m 0755 -d /etc/apt/keyrings
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
curl -fsSL \
https://download.docker.com/linux/debian/gpg \
-o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
fi
. /etc/os-release
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
https://download.docker.com/linux/debian \
${VERSION_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y \
docker-ce \
docker-ce-cli \
containerd.io \
docker-buildx-plugin \
docker-compose-plugin
fi
if ! docker info >/dev/null 2>&1; then
systemctl enable --now docker
fi
if ! docker compose version >/dev/null 2>&1; then
die "Docker Compose plugin is unavailable."
fi
success "✓ Docker and Docker Compose available"
# ============================================================
# 2. Create persistent directories
# ============================================================
info "[2/8] Creating persistent application directories..."
mkdir -p \
"${DATA_DIR}" \
"${CONFIG_DIR}" \
"${IMAGES_DIR}" \
"${COMPOSE_DIR}" \
"${BACKUP_ROOT}"
chmod 700 "${CONFIG_DIR}"
chmod 755 "${IMAGES_DIR}"
success "✓ Persistent directories ready"
# ============================================================
# 3. Configuration
# ============================================================
info "[3/8] Preparing configuration..."
if [[ ! -f "${ENV_FILE}" ]]; then
cat > "${ENV_FILE}" <<EOF
BZOD_VERSION=${BZOD_VERSION}
BZOD_IMAGE=${BZOD_IMAGE}
# 5. Configuration (preserve on upgrades)
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
if [ ! -f "${ENV_FILE}" ]; then
echo -e "${BLUE}Generating new secure configuration...${NC}"
cat <<EOF > "${ENV_FILE}"
HOST=0.0.0.0
PORT=8654
DATA_DIR=${DATA_DIR}
DATA_DIR=/app/data
CONFIG_DIR=/app/config
IMAGES_DIR=/app/images
COOKIE_SECURE=true
RUST_LOG=info
SESSION_SECRET=$(openssl rand -hex 32)
BASE_URL=${BASE_URL}
EOF
chmod 600 "${ENV_FILE}"
chown root:"${SERVICE_USER}" "${ENV_FILE}"
success "✓ New Docker configuration created"
else
echo -e "${GREEN}Existing configuration preserved${NC}"
warning "Existing Docker configuration preserved"
# Update image/version while preserving all other settings.
sed -i \
-E "s#^BZOD_VERSION=.*#BZOD_VERSION=${BZOD_VERSION}#" \
"${ENV_FILE}" || true
sed -i \
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
"${ENV_FILE}" || true
if grep -q '^BASE_URL=' "${ENV_FILE}"; then
sed -i \
-E "s#^BASE_URL=.*#BASE_URL=${BASE_URL}#" \
"${ENV_FILE}" || true
else
echo "BASE_URL=${BASE_URL}" >> "${ENV_FILE}"
fi
fi
# 6. Systemd Service
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
cat <<EOF > "${SYSTEMD_UNIT}"
[Unit]
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
After=network-online.target
Wants=network-online.target
# ============================================================
# 4. Create Compose definition
# ============================================================
[Service]
Type=simple
User=${SERVICE_USER}
Group=${SERVICE_USER}
WorkingDirectory=/var/lib/bzod
EnvironmentFile=${ENV_FILE}
ExecStart=${INSTALL_PATH} serve
info "[4/8] Writing Docker Compose configuration..."
Restart=on-failure
RestartSec=5s
cat > "${COMPOSE_FILE}" <<'EOF'
services:
# Security Hardening
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
LockPersonality=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/bzod
bzod:
image: ${BZOD_IMAGE}:${BZOD_VERSION}
container_name: bzod
[Install]
WantedBy=multi-user.target
restart: unless-stopped
ports:
- "${PORT:-8654}:8654"
environment:
HOST: "${HOST:-0.0.0.0}"
PORT: "${PORT:-8654}"
DATA_DIR: "/app/data"
CONFIG_DIR: "/app/config"
IMAGES_DIR: "/app/images"
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
RUST_LOG: "${RUST_LOG:-info}"
BASE_URL: "${BASE_URL:-https://bzo.in}"
volumes:
# Persistent application databases.
- ${BZOD_ROOT}/data:/app/data
# Persistent application configuration.
- ${BZOD_ROOT}/config:/app/config
# User-uploaded / application images.
#
# IMPORTANT:
# /app/images is required by the image router.
- ${BZOD_ROOT}/images:/app/images
healthcheck:
test:
[
"CMD",
"curl",
"-fsS",
"http://127.0.0.1:8654/status"
]
interval: 30s
timeout: 5s
start_period: 10s
retries: 3
security_opt:
- no-new-privileges:true
EOF
chmod 644 "${SYSTEMD_UNIT}"
systemctl daemon-reload
# 7. Initialize & Start
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
echo -e "${GREEN}✓ Databases initialized${NC}"
else
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
# Append BZOD_ROOT because compose needs it.
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
fi
systemctl enable --now bzod
# Port variable expected by compose.
if ! grep -q '^PORT=' "${ENV_FILE}"; then
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
fi
# 8. Validation + Rollback
sleep 3
success "✓ Docker Compose configuration written"
# ============================================================
# 5. Backup existing installation
# ============================================================
info "[5/8] Creating pre-upgrade backup..."
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
mkdir -p "${BACKUP_DIR}"
if [[ -d "${DATA_DIR}" ]]; then
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data"
fi
if [[ -d "${CONFIG_DIR}" ]]; then
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
fi
if [[ -d "${IMAGES_DIR}" ]]; then
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
fi
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
success "✓ Backup created:"
echo " ${BACKUP_DIR}"
# ============================================================
# 6. Pull new image
# ============================================================
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
# Build locally from the current deployment tree. The package/repository is
# nx9-url-shortener; the application binary and container remain named bzod.
if ! docker build \
--tag "${IMAGE}" \
--file "${BZOD_ROOT}/Dockerfile" \
"${BZOD_ROOT}"; then
die "Unable to build ${IMAGE}"
fi
success "✓ Docker image built locally"
# ============================================================
# 7. Deploy
# ============================================================
info "[7/8] Deploying BZOD..."
cd "${COMPOSE_DIR}"
# Stop/remove the existing container through Compose.
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
down \
--remove-orphans
# Start the requested image.
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
up -d
success "✓ BZOD container started"
# ============================================================
# 8. Validation
# ============================================================
info "[8/8] Validating deployment..."
sleep 5
if ! docker inspect \
--format '{{.State.Running}}' \
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
error "BZOD container failed to start."
echo
docker compose \
--env-file "${ENV_FILE}" \
-f "${COMPOSE_FILE}" \
logs --tail=100
error
error "Deployment failed. Existing data was not removed."
error "Backup: ${BACKUP_DIR}"
if ! systemctl is-active --quiet bzod; then
echo -e "${RED}Service failed to start! Rolling back...${NC}"
if [ -f "${INSTALL_PATH}.bak" ]; then
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
systemctl restart bzod || true
fi
journalctl -u bzod -n 50 --no-pager
exit 1
fi
# Clean up backup on success
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
success "✓ Container is running"
# Soft health check
if command -v curl >/dev/null 2>&1; then
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
echo -e "${GREEN}✓ HTTP health check passed${NC}"
else
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
# ------------------------------------------------------------
# Health check
# ------------------------------------------------------------
HEALTH_OK=0
for _ in {1..12}; do
if curl -fsS \
"http://127.0.0.1:${BZOD_PORT}/status" \
>/dev/null 2>&1; then
HEALTH_OK=1
break
fi
sleep 2
done
if [[ "${HEALTH_OK}" -eq 1 ]]; then
success "✓ HTTP health check passed"
else
warning "⚠ HTTP health check did not respond yet"
warning "The container is running; inspect logs if necessary:"
echo
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
fi
# Final Message
IP=$(hostname -I | awk '{print $1}' | head -n1)
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
echo -e "🌐 Web UI: http://${IP}:8654"
echo -e "🔑 Admin: http://${IP}:8654/admin"
echo -e "🖥 Architecture: ${ARCH}"
echo -e "📦 Version: ${VERSION}"
echo -e "\nNext step (first install):"
echo -e " sudo -u bzod bzod create-admin"
echo -e "\nCommands:"
echo -e " journalctl -u bzod -f"
echo -e " bzod doctor"
echo -e " systemctl status bzod"
# ============================================================
# Verify image and binary
# ============================================================
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
echo
info "Installed image:"
docker image inspect "${IMAGE}" \
--format ' {{.RepoTags}} ({{.Id}})' \
2>/dev/null || true
echo
info "Container:"
docker inspect "${CONTAINER_NAME}" \
--format ' {{.Name}} {{.Config.Image}}' \
2>/dev/null || true
echo
info "Persistent mounts:"
docker inspect "${CONTAINER_NAME}" \
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
2>/dev/null || true
# ============================================================
# Final status
# ============================================================
echo
echo -e "${GREEN}============================================================${NC}"
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
echo -e "${GREEN}============================================================${NC}"
echo
echo "Web UI:"
echo " http://<server-ip>:${BZOD_PORT}"
echo
echo "Persistent data:"
echo " ${DATA_DIR}"
echo
echo "Persistent images:"
echo " ${IMAGES_DIR}"
echo
echo "Docker Compose:"
echo " ${COMPOSE_FILE}"
echo
echo "Backup:"
echo " ${BACKUP_DIR}"
echo
echo "Useful commands:"
echo " docker compose -f ${COMPOSE_FILE} ps"
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
echo
success "Deployment complete."
+43 -8
View File
@@ -1,52 +1,87 @@
name: app-bzod
services:
bzod:
build:
context: /DATA/AppData/bzod
context: /DATA/AppData/nx9-url-shortener
dockerfile: Dockerfile
cpu_shares: 90
command: []
container_name: bzod
deploy:
resources:
limits:
memory: 31940M
environment:
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- CONFIG_DIR=/app/config
- COOKIE_SECURE=false
- DATA_DIR=/app/data
- HOST=0.0.0.0
- IMAGES_DIR=/app/images
- PORT=8654
- RUST_LOG=info
- BASE_URL=${BASE_URL}
hostname: bzod
image: nx9-url-shortener:v0.4.0
image: nx9-url-shortener:v0.7.0
ports:
- mode: ingress
target: 8654
published: "8654"
protocol: tcp
restart: unless-stopped
security_opt:
- no-new-privileges:true
volumes:
- type: bind
source: /DATA/AppData/bzod/data
source: /DATA/AppData/nx9-url-shortener/data
target: /app/data
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/bzod/config
source: /DATA/AppData/nx9-url-shortener/config
target: /app/config
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/bzod/www
source: /DATA/AppData/nx9-url-shortener/www
target: /app/www
bind:
create_host_path: true
- type: bind
source: /DATA/AppData/nx9-url-shortener/images
target: /app/images
bind:
create_host_path: true
devices: []
cap_add: []
command: []
networks:
- default
privileged: false
cpu_shares: 90
networks:
default:
name: app_default
x-casaos:
author: self
category: self
@@ -57,4 +92,4 @@ x-casaos:
port_map: "8654"
scheme: http
title:
custom: nx9-url-shortener
custom: nx9-url-shortener
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -e
if [ "$1" = 'serve' ]; then
/usr/local/bin/bzod init-admin
exec /usr/local/bin/bzod serve
fi
exec /usr/local/bin/bzod "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide
Version: v0.5.1
Version: v0.7.0
---
+22 -5
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide
Version: v0.5.1
Version: v0.7.0
---
@@ -89,7 +89,22 @@ Responsible for:
Major modules:
```text
admin.rs
admin/ (modular feature directory)
auth.rs (authentication and session handling)
dashboard.rs (dashboard rendering)
urls.rs (URL management handlers)
pages.rs (landing page management handlers)
analytics.rs (analytics and export handlers)
settings.rs (settings and configuration handlers)
users.rs (user management handlers)
sessions.rs (session administration)
quotas.rs (quota management)
health.rs (health diagnostics)
backups.rs (backup and restore handlers)
api_keys.rs (API key management)
audit.rs (audit log handlers)
moderation.rs (content moderation handlers)
mod.rs (module exports and shared helpers)
api.rs
pages.rs
redirect.rs
@@ -339,9 +354,11 @@ Locate owner database
↓
Resolve URL
↓
Validate destination
↓
Record analytics
↓
302 Redirect
301 Redirect (with safe Location header construction)
```
---
@@ -590,7 +607,7 @@ Coverage includes:
* Upgrade validation
* Multi-user isolation
v0.5.0 includes more than 90 automated tests.
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
---
@@ -635,7 +652,7 @@ Planned for future releases:
# Summary
BZOD v0.5.0 is built around a simple principle:
BZOD is built around a simple principle:
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
+1 -1
View File
@@ -1,6 +1,6 @@
# Backup & Restore Guide
Version: v0.5.1
Version: v0.7.0
Applies To: BZOD Multi-User Platform
---
+116
View File
@@ -6,6 +6,122 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
---
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
## Added
### Responsive UI
* Responsive layouts for admin and user URL registry panels
* Responsive layouts for admin and user landing page registry panels
* Desktop, laptop, tablet, and mobile layout support
* Table-to-card responsive behavior for registry panels
* Resolved horizontal scrolling issues in registry panels
### Theme Support
* Dark/light theme toggle
* Theme persistence across sessions
* Responsive theme behavior across device sizes
### Build Metadata
* Introduced `build.rs` build script for compile-time metadata
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
* Graceful fallback to `"unknown"` when Git metadata is unavailable
### Public Landing Page
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
* Public/runtime www assets supported by the deployment layout
## Changed
* Documentation updated to reflect v0.7.0 current state
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
## Notes
* No API behavior changes
* No database schema changes
* No authentication or security behavior changes
* Existing redirect, routing, and tenant isolation behavior preserved
---
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
# v0.5.3 — Architecture Refinement & Redirect Hardening
---
## Changed
### Architecture
* Eliminated the monolithic `admin.rs` handler file
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
* Extracted shared authentication and authorization helpers
* Extracted common export and helper functionality
### Redirect Handling
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
* Added destination URL validation (scheme validation, control character rejection)
* Added safe HTTP Location header construction
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
* Removed synchronous expiration writes from the redirect hot path
---
## Improved
* Database lock scoping across admin handlers
* Error handling consistency and observability
* Handler decomposition for oversized functions
* Reduced duplicated handler logic across admin operations
---
## Verified
* Root landing page (GET /) confirmed as intentional route serving www/index.html
* Release binary built successfully
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
* SQLite WAL mode and foreign-key enforcement initialized successfully
* All existing migrations reported as up to date
* Comprehensive automated test suite passed, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
---
## Notes
* This release is an internal architecture and quality improvement
* No new user-facing features were introduced
* Existing API and route behavior was preserved
* Existing redirect security and tenant isolation behavior was preserved
---
# v0.5.1 - General Availability (GA)
Release Date: 2026-06-20
+16 -1
View File
@@ -2,7 +2,7 @@
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
The current command list for BZOD v0.5.1 is:
The current command list for BZOD v0.7.0 is:
```text
$ bzod --help
@@ -30,6 +30,8 @@ Commands:
list-users List all standard/system users
backup-user Backup a standard user's databases to a .tar.zst package
restore-user Restore a standard user's databases from a .tar.zst package
admin-migrate FUTURE: Migrate legacy admin content to a specific admin tenant database
repair Repair registry and database inconsistencies
help Print this message or the help of the given subcommand(s)
Options:
@@ -119,6 +121,19 @@ Performs:
* Database availability checks
* Storage verification
* System health diagnostics
* Global registry integrity validation
## Registry Repair
```bash
bzod repair registry --dry-run
```
Provides a transaction-safe repair utility for fixing global slug registry inconsistencies detected by `bzod doctor`.
* Use `--dry-run` to preview changes safely.
* Use `--force` to execute changes and remove orphaned entries.
* Use `--slug <slug>` to target a single missing entry.
---
+1 -1
View File
@@ -1,4 +1,4 @@
# BZOD v0.5.1 vs Self-Hosted URL Management Platforms
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
+1 -1
View File
@@ -2,7 +2,7 @@
# BZOD Database Architecture
BZOD v0.5.1 uses SQLite exclusively.
BZOD v0.7.0 uses SQLite exclusively.
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
+15 -1
View File
@@ -45,7 +45,21 @@ cd nx9-url-shortener
docker compose up -d --build
```
## Create Administrator
## Automated Administrator Bootstrap (First Start Only)
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
```yaml
environment:
ADMIN_USERNAME: "admin"
ADMIN_PASSWORD: "<your-secure-password>"
```
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
## Manual Administrator Creation
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
```bash
docker exec -it bzod bzod create-admin
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide
Version: v0.5.1
Version: v0.7.0
---
@@ -183,13 +183,13 @@ sudo pacman -S \
Example:
```bash
wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
```
Install:
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Multi-User Architecture Guide
Version: v0.5.1
Version: v0.7.0
---
+192
View File
@@ -1,3 +1,178 @@
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
Release Date: 2026-08-11
## Highlights
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
## User-Visible Changes
- Admin URL registry panel is responsive across all device sizes
- Admin landing page registry panel is responsive across all device sizes
- User URL registry panel is responsive across all device sizes
- User landing page registry panel is responsive across all device sizes
- Dark/light theme toggle available in the UI
- Theme preference persists across sessions
- Registry tables switch to card layouts on tablet and mobile viewports
- Horizontal scrolling eliminated from registry panels
## Technical Changes
- Introduced `build.rs` build script for compile-time metadata extraction
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
- Version and Git commit now available to system status and admin settings endpoints
- Root `/` serves `www/index.html` with runtime/embedded fallback
## Breaking Changes
None.
## Upgrade Notes
- Direct upgrade from v0.6.0 with no migration required
- No database schema changes
- No API changes
- No configuration changes
- No breaking changes to existing functionality
---
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
Release Date: 2026-08-09
## Highlights
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
## Breaking Changes
None.
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
---
# Highlights
## Modular Admin Architecture
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
Feature modules:
* `auth.rs` — authentication and session handling
* `dashboard.rs` — dashboard rendering
* `urls.rs` — URL management handlers
* `pages.rs` — landing page management handlers
* `analytics.rs` — analytics and export handlers
* `settings.rs` — settings and configuration handlers
* `users.rs` — user management handlers
* `sessions.rs` — session administration
* `quotas.rs` — quota management
* `health.rs` — health diagnostics
* `backups.rs` — backup and restore handlers
* `api_keys.rs` — API key management
* `audit.rs` — audit log handlers
* `moderation.rs` — content moderation handlers
Benefits:
* Improved code organization and navigability
* Reduced coupling between feature areas
* Improved database lock scoping
* Reduced duplicated handler logic
* Better error handling consistency and observability
* Simplified future extension
---
## Redirect Handler Hardening
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
Changes:
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
* Added destination URL validation (scheme enforcement, control character rejection)
* Added safe Location header construction that handles malformed values gracefully
* Improved database error logging with structured fields
* Reduced unnecessary database mutex lock acquisitions
* Removed synchronous expiration writes from the redirect hot path
Existing redirect security and tenant isolation behavior was preserved.
---
## Root Landing Page Verification
* Confirmed `GET /` as an intentional application route serving `www/index.html`
* Resolved a runtime path-resolution issue affecting static landing-page resolution
* Verified `GET /` returns HTTP 200
* Verified `GET /login` returns HTTP 200
* Verified `GET /admin/login` returns HTTP 200
---
# Testing & Validation
BZOD v0.5.3 passed:
* Release build (`cargo build --release`)
* Comprehensive automated test suite, including:
* Authentication and migration tests
* Redirect security tests
* Root landing page test
* Backup and restore tests
* Business workflow tests
* Security tests
* Slug namespace, registry, and transfer tests
* User management and isolation tests
* WAL recovery tests
* HTTP end-to-end tests
* Runtime smoke tests against the release binary
* SQLite WAL mode and foreign-key enforcement initialization
* Database migration verification (all migrations up to date)
---
# Compatibility
* No breaking changes
* No API changes
* No route changes
* No database schema changes
* No configuration changes
* Direct upgrade from v0.5.1 with no migration required
---
# Repository
* Clean source tree established
* Build artifacts, temporary reports, and IDE metadata removed
* Existing BZOD Git history preserved
* Refactoring baseline merged with existing history
---
---
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
**Release Date:** 2026-06-20
@@ -9,6 +184,23 @@ While v0.5.0 introduced the multi-user architecture, v0.5.1 strengthens the foun
---
# Highlights
## Runtime Efficiency (v0.5.1)
| Metric | Value |
|---------------------|------------|
| Binary Size | 11 MB |
| RSS Memory | 11.8 MB |
| Peak RSS | 11.8 MB |
| CPU Idle | 0.02% |
| Swap Usage | 0 KB |
| PIDs | 7 |
**On a typical 32 GB server:**
- Memory usage: ~0.04%
- No swapping
- Plenty of headroom
BZOD runs closer to a lightweight infrastructure service than a typical web application.
## Global Slug Registry
+27 -4
View File
@@ -1,6 +1,6 @@
# BZOD Security Guide
Version: v0.5.1
Version: v0.7.0
---
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
* Disaster recovery
* Operational simplicity
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0.
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
---
@@ -432,6 +432,27 @@ for:
---
# Redirect Security
The redirect handler validates destination URLs before constructing HTTP Location headers.
Protections include:
* URL scheme validation (only http and https destinations are permitted)
* Control character rejection
* CRLF injection prevention
* Safe Location header construction (no panics on malformed values)
Invalid redirect destinations return:
```http
500 Internal Server Error
```
with structured server-side logging. Full destination values are not exposed to clients.
---
# Audit Logging
Security-sensitive actions are logged.
@@ -599,7 +620,7 @@ If compromise is suspected:
# Security Testing
BZOD v0.5.0 includes tests covering:
BZOD v0.7.0 includes tests covering:
* Authentication
* Authorization
@@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering:
* Upgrade migrations
* Backup integrity
* Disaster recovery
* Redirect destination validation
* HTTP Location header safety
These tests are executed during CI and release validation.
@@ -642,7 +665,7 @@ These may be addressed in future releases.
# Summary
BZOD v0.5.0 provides:
BZOD v0.7.0 provides:
* Centralized authentication
* Secure session management
+32
View File
@@ -325,6 +325,38 @@ and:
for final landing page render.
Root landing page:
```text
GET /
```
must serve the static landing page.
Expected:
```http
200 OK
Content-Type: text/html
```
Redirect security:
Redirect destinations are validated against:
* Invalid URL schemes
* CRLF injection attempts
* Control character injection
* Malformed HTTP Location header values
Invalid destinations must return:
```http
500 Internal Server Error
```
and must not panic or produce malformed HTTP responses.
---
# 12. Backup Validation
+2 -2
View File
@@ -1,8 +1,8 @@
# Upgrade Guide
Version: v0.5.1
Version: v0.7.0
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
---
Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 199 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 331 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

+8 -4
View File
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
}
impl AnalyticsQueue {
pub fn new(db: Db, capacity: usize) -> Self {
pub fn new(
db: Db,
capacity: usize,
shutdown_rx: tokio::sync::watch::Receiver<bool>,
) -> (Self, tokio::task::JoinHandle<()>) {
let (sender, receiver) = mpsc::channel(capacity);
// Spawn background worker to batch-write records
tokio::spawn(async move {
super::worker::run_worker(db, receiver).await;
let handle = tokio::spawn(async move {
super::worker::run_worker(db, receiver, shutdown_rx).await;
});
Self { sender }
(Self { sender }, handle)
}
// Attempt to queue a visit. Non-blocking.
+10 -1
View File
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
use crate::db::Db;
use crate::models::VisitRecord;
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
pub async fn run_worker(
db: Db,
mut receiver: mpsc::Receiver<VisitRecord>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let mut batch = Vec::new();
let batch_size = 50;
let flush_interval = Duration::from_secs(2);
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
flush_batch(&db, &mut batch);
}
}
_ = shutdown_rx.changed() => {
info!("Analytics worker flushing pending records");
flush_batch(&db, &mut batch);
break;
}
}
}
}
+31
View File
@@ -4,11 +4,42 @@ use crate::state::AppState;
use axum::{
extract::{FromRef, FromRequestParts},
http::{request::Parts, StatusCode},
Json,
};
// Extractor: Authenticate API requests using Bearer token
pub struct ApiUser(pub ApiActor);
impl ApiUser {
pub fn require_admin(
&self,
) -> Result<&crate::models::User, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::Admin(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Admin privileges required".to_string(),
}),
)),
}
}
pub fn require_tenant(
&self,
) -> Result<&crate::models::TenantUser, (StatusCode, Json<crate::web::api::ApiError>)> {
match &self.0 {
ApiActor::User(u) => Ok(u),
_ => Err((
StatusCode::FORBIDDEN,
Json(crate::web::api::ApiError {
error: "Tenant privileges required".to_string(),
}),
)),
}
}
}
#[axum::async_trait]
impl<S> FromRequestParts<S> for ApiUser
where
+5
View File
@@ -0,0 +1,5 @@
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
Some(commit) => commit,
None => "unknown",
};
+152
View File
@@ -0,0 +1,152 @@
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::{error, info};
pub async fn run(
target_admin_id: i64,
data_dir: Option<String>,
dry_run: bool,
force: bool,
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)?;
// 1. Verify target admin exists and is an admin
let target_user = {
let conn = db.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, target_admin_id)?
};
let target_user = match target_user {
Some(u) => u,
None => {
error!("Target admin ID {} not found", target_admin_id);
return Ok(());
}
};
if target_user.account_type != "admin" {
error!(
"Target user '{}' (ID {}) is not an admin account.",
target_user.username, target_admin_id
);
return Ok(());
}
if target_admin_id == 1 {
error!("Target admin ID cannot be 1 (legacy admin).");
return Ok(());
}
// 2. Open databases
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
if !legacy_content_path.exists() {
info!(
"No legacy admin content database found at {:?}",
legacy_content_path
);
return Ok(());
}
db.init_user_databases(target_admin_id)?;
let target_content_path = config
.data_dir
.join("users")
.join(target_admin_id.to_string())
.join("content.db");
let mut legacy_conn = Connection::open(&legacy_content_path)?;
let mut target_conn = Connection::open(&target_content_path)?;
let mut system_conn = db.system.lock().unwrap();
println!("Scanning legacy admin content database...");
// 3. Count items
let urls = {
let mut stmt = legacy_conn.prepare("SELECT * FROM urls;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let url_count = urls.len();
let pages = {
let mut stmt = legacy_conn.prepare("SELECT * FROM landing_pages;")?;
let mut rows = stmt.query([])?;
let mut data = Vec::new();
while let Ok(Some(_)) = rows.next() {
data.push(1);
}
data
};
let page_count = pages.len();
println!(
"Found {} URLs and {} Landing Pages owned by legacy admin (ID 1).",
url_count, page_count
);
if dry_run {
println!("Dry run mode enabled. No changes will be made.");
return Ok(());
}
if !force {
println!("Migration requires the --force flag to execute. Aborting.");
return Ok(());
}
println!(
"Starting migration to Admin '{}' (ID {})...",
target_user.username, target_admin_id
);
// 4. Perform Migration (using ATTACH DATABASE for fast copy)
// We attach the legacy db to the target db to do INSERT INTO ... SELECT * FROM
target_conn.execute(
"ATTACH DATABASE ?1 AS legacy;",
rusqlite::params![legacy_content_path.to_string_lossy()],
)?;
let tx = target_conn.transaction()?;
tx.execute("INSERT OR IGNORE INTO urls SELECT * FROM legacy.urls;", [])?;
tx.execute(
"INSERT OR IGNORE INTO landing_pages SELECT * FROM legacy.landing_pages;",
[],
)?;
tx.commit()?;
target_conn.execute("DETACH DATABASE legacy;", [])?;
// 5. Update global registry
let sys_tx = system_conn.transaction()?;
let updated_slugs = sys_tx.execute(
"UPDATE global_slugs SET owner_user_id = ?1 WHERE owner_user_id = 1;",
rusqlite::params![target_admin_id],
)?;
sys_tx.commit()?;
// 6. Delete from legacy
let legacy_tx = legacy_conn.transaction()?;
legacy_tx.execute("DELETE FROM urls;", [])?;
legacy_tx.execute("DELETE FROM landing_pages;", [])?;
legacy_tx.commit()?;
println!("Migration Complete!");
println!("-------------------");
println!("Migrated {} URLs.", url_count);
println!("Migrated {} Landing Pages.", page_count);
println!("Updated {} slugs in global registry.", updated_slugs);
println!("Cleared legacy content database.");
Ok(())
}
+33
View File
@@ -0,0 +1,33 @@
use crate::config::Config;
use crate::db::Db;
use crate::services::destination_audit::{audit_all_destinations, format_report};
use std::path::PathBuf;
use tracing::info;
/// Read-only audit of all stored redirect destinations.
///
/// Does not rewrite, delete, or "repair" any records.
pub async fn run(
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
info!("Starting read-only destination audit...");
let db = Db::init(&config)?;
let report = audit_all_destinations(&db)?;
print!("{}", format_report(&report));
if report.invalid > 0 {
// Non-zero exit so automation can detect findings without treating them as crashes.
Err(format!(
"destination audit found {} invalid stored URL(s)",
report.invalid
)
.into())
} else {
Ok(())
}
}
+56 -17
View File
@@ -26,12 +26,14 @@ pub async fn run(
// Define target databases in the new layout
let admin_dir = config.data_dir.join("admin");
let legacy_user_dir = config.data_dir.join("users").join("1");
let dbs = vec![
("admin", admin_dir.join("admin.db")),
("system", admin_dir.join("system.db")),
("users", admin_dir.join("users.db")),
("legacy content", config.data_dir.join("content.db")),
("legacy analytics", config.data_dir.join("analytics.db")),
("legacy content", legacy_user_dir.join("content.db")),
("legacy analytics", legacy_user_dir.join("analytics.db")),
];
for (db_name, db_path) in dbs {
@@ -99,32 +101,69 @@ pub async fn run(
Connection::open(&users_db_path),
) {
(Ok(sys_conn), Ok(usr_conn)) => {
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&sys_conn,
&usr_conn,
&config.data_dir,
None,
) {
Ok((errors, warnings)) => {
if errors.is_empty() && warnings.is_empty() {
Ok(issues) => {
if issues.is_empty() {
println!(" Status: HEALTHY (no issues found)");
} else {
if !errors.is_empty() {
println!(" Errors (Action Required):");
for err in &errors {
println!(" - {}", err);
}
all_healthy = false;
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
println!(" Status: ISSUES DETECTED");
all_healthy = false;
for issue in &issues {
println!();
println!("ERROR");
println!();
println!("Slug:");
println!(" {}", issue.slug);
println!();
println!("Type:");
println!(
" {}",
if issue.target_type == "url" {
"URL"
} else if issue.target_type == "page" {
"Landing Page"
} else {
&issue.target_type
}
);
println!();
println!("Owner:");
println!(" User ID {}", issue.owner_user_id);
println!();
println!("Database:");
println!(" {}", issue.database_path.display());
println!();
println!("Target UUID:");
println!(" {}", issue.target_id);
println!();
println!("Issue:");
println!(" {:?}", issue.issue_type);
println!();
println!("Description:");
println!(" {}", issue.description);
println!();
println!("Suggested Repair:");
println!();
if issue.slug != "*" {
println!(
" bzod repair registry --slug {} --dry-run",
issue.slug
);
} else {
println!(" bzod repair registry --dry-run");
}
println!("--------------------");
}
}
}
Err(e) => {
println!(" Status: ERROR running integrity check: {}", e);
println!(" Status: ERROR running registry scan: {}", e);
all_healthy = false;
}
}
+53
View File
@@ -0,0 +1,53 @@
use crate::auth::hash_password;
use crate::config::Config;
use crate::db::Db;
use std::env;
use std::path::PathBuf;
use tracing::{error, info};
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)?;
let conn = db.users.lock().unwrap();
let admin_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|row| row.get(0),
)?;
if admin_count > 0 {
info!("Administrator already exists; initialization skipped.");
return Ok(());
}
let username = match env::var("ADMIN_USERNAME") {
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let password = match env::var("ADMIN_PASSWORD") {
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
_ => {
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
error!("{}", msg);
return Err(msg.into());
}
};
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
db.init_user_databases(u.id)?;
info!("Administrator initialized successfully.");
Ok(())
}
+56 -9
View File
@@ -1,27 +1,31 @@
use clap::{Parser, Subcommand};
pub mod admin_migrate;
pub mod audit_destinations;
pub mod backup;
pub mod backup_user;
pub mod create_admin;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod doctor;
pub mod enable_user;
pub mod expand;
pub mod init_admin;
pub mod list_users;
pub mod migrate;
pub mod repair;
pub mod reset_password;
pub mod restore;
pub mod restore_user;
pub mod serve;
pub mod shorten;
pub mod stats;
pub mod validate;
pub mod backup_user;
pub mod create_user;
pub mod delete_user;
pub mod disable_user;
pub mod enable_user;
pub mod list_users;
pub mod reset_password;
pub mod restore_user;
#[derive(Parser)]
#[command(name = "bzod")]
#[command(version)]
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
pub struct Cli {
#[command(subcommand)]
@@ -71,6 +75,11 @@ pub enum Commands {
#[arg(long)]
data_dir: Option<String>,
},
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
AuditDestinations {
#[arg(long)]
data_dir: Option<String>,
},
/// Create a new administrator user in the database
CreateAdmin {
#[arg(long)]
@@ -78,6 +87,11 @@ pub enum Commands {
#[arg(long)]
data_dir: Option<String>,
},
/// Initialize the first administrator for automated/container deployments
InitAdmin {
#[arg(long)]
data_dir: Option<String>,
},
/// Run database diagnostics and health checks
Doctor {
#[arg(long)]
@@ -165,4 +179,37 @@ pub enum Commands {
#[arg(long)]
data_dir: Option<String>,
},
/// FUTURE: Migrate legacy admin content to a specific admin tenant database
AdminMigrate {
/// Target Admin ID
target_admin_id: i64,
#[arg(long)]
data_dir: Option<String>,
/// Preview what would be moved without making changes
#[arg(long)]
dry_run: bool,
/// Force the migration to execute
#[arg(long)]
force: bool,
},
/// Repair registry and database inconsistencies
Repair {
#[command(subcommand)]
command: RepairCommands,
},
}
#[derive(clap::Subcommand)]
pub enum RepairCommands {
/// Repair Global Slug Registry inconsistencies
Registry {
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
data_dir: Option<String>,
},
}
+178
View File
@@ -0,0 +1,178 @@
use crate::cli::RepairCommands;
use crate::config::Config;
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
use rusqlite::Connection;
use std::path::PathBuf;
use tracing::info;
pub async fn run(
command: RepairCommands,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
match command {
RepairCommands::Registry {
dry_run,
force,
slug,
data_dir,
} => {
if let Some(d) = data_dir {
config.data_dir = PathBuf::from(d);
}
if !dry_run && !force {
println!("Error: You must specify either --dry-run or --force");
return Ok(());
}
if dry_run && force {
println!("Error: Cannot specify both --dry-run and --force");
return Ok(());
}
let start_time = std::time::Instant::now();
let admin_dir = config.data_dir.join("admin");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
if !system_db_path.exists() || !users_db_path.exists() {
println!("Error: system.db or users.db not found.");
return Ok(());
}
let mut sys_conn = Connection::open(&system_db_path)?;
let usr_conn = Connection::open(&users_db_path)?;
let slug_filter = slug.as_deref();
if dry_run {
println!("BZOD Registry Repair\n");
println!("Scanning Global Slug Registry...");
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
println!("\nDetected:");
println!("\nPages:\n {} orphaned", orphaned_pages);
println!("\nURLs:\n {} orphaned", orphaned_urls);
if !orphaned.is_empty() {
println!("\nThe following entries would be removed:");
for issue in &orphaned {
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
}
}
println!("\nNo changes have been made.");
println!(
"\nRun again with:\n\n bzod repair registry --force{}",
if let Some(s) = slug_filter {
format!(" --slug {}", s)
} else {
"".to_string()
}
);
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, start_time.elapsed()
);
} else if force {
let tx = sys_conn.transaction()?;
let issues =
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
let orphaned = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.collect::<Vec<_>>();
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
if orphaned.is_empty() {
println!("No repairs required.");
return Ok(());
}
if let Some(s) = slug_filter {
println!("Checking slug:\n\n{}\n", s);
if let Some(issue) = orphaned.first() {
println!("Owner:\n\n{}\n", issue.owner_user_id);
println!("Status:\n\nOrphaned\n");
}
}
let mut removed_count = 0;
for issue in &orphaned {
let rows = tx.execute(
"DELETE FROM global_slugs WHERE slug = ?1",
rusqlite::params![issue.slug],
)?;
removed_count += rows;
}
tx.commit()?;
if slug_filter.is_some() {
println!("Removed:\n\nSUCCESS");
} else {
println!("Repair Complete\n");
println!("Removed:\n");
println!("Pages:\n {}\n", orphaned_pages);
println!("URLs:\n {}\n", orphaned_urls);
let remaining: i64 =
sys_conn
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
println!("Remaining Registry Entries:\n {}\n", remaining);
let post_issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
let post_orphaned = post_issues
.iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::MissingDatabase
| RegistryIssueType::MissingOwner
)
})
.count();
println!(
"Integrity:\n {}",
if post_orphaned == 0 { "PASS" } else { "FAIL" }
);
}
info!(
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
);
}
}
}
Ok(())
}
+301 -24
View File
@@ -1,21 +1,250 @@
use crate::config::Config;
use crate::services::registry_validator::RegistryIssueType;
use flate2::read::GzDecoder;
use std::fs::File;
use std::io::{self, Write};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tar::Archive;
use tracing::{error, info};
use tracing::{error, info, warn};
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
let manifest_path = temp_dir.join("backup_manifest.json");
if !manifest_path.exists() {
return false;
}
match std::fs::read_to_string(&manifest_path) {
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
Err(_) => false,
},
Err(_) => false,
}
}
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
fn is_flat_layout(temp_dir: &Path) -> bool {
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
}
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
///
/// This function:
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
/// 2. Reads the actual administrator identity from admin.db (preserving
/// the original username and argon2id password hash — no manufacturing).
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
/// 4. Creates an admin account with the original credentials.
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
/// accounts for any missing tenants.
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
let users_db_path = temp_dir.join("admin").join("users.db");
let admin_db_path = temp_dir.join("admin").join("admin.db");
let system_db_path = temp_dir.join("admin").join("system.db");
// Check if users.db already has the users table (i.e., not a legacy backup)
{
let conn = rusqlite::Connection::open(&users_db_path)?;
let has_users_table: bool = conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_users_table {
info!("users.db already has users table; skipping legacy bootstrap");
return Ok(());
}
}
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
// Step 1: Run migrations to create the users.db schema
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
crate::db::sqlite::enable_wal(&users_conn, "users")?;
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
// Step 2: Read the actual administrator identity from admin.db
let (admin_username, admin_password_hash) = {
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
// The legacy admin.db users table has schema:
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
// Read the actual admin — typically the first (and often only) user.
let result: Result<(String, String), _> = admin_conn.query_row(
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
);
match result {
Ok((username, hash)) => {
info!(
"Preserved administrator identity from legacy admin.db: username='{}'",
username
);
(username, hash)
}
Err(e) => {
return Err(format!(
"Failed to read administrator credentials from legacy admin.db: {}",
e
)
.into());
}
}
};
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
// This account owns the content.db/analytics.db from the flat backup (users/1/).
// It uses the original admin's password hash so no synthetic credentials are introduced.
let now = chrono::Utc::now().to_rfc3339();
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![
1i64,
"legacy_admin",
&admin_password_hash,
"disabled",
&now,
"system"
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
info!("Created legacy_admin system account (id=1) for tenant content ownership");
// Step 4: Create the actual admin account with original credentials
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![
&admin_username,
&admin_password_hash,
"active",
&now,
"admin"
],
)?;
let admin_id = users_conn.last_insert_rowid();
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
info!(
"Created admin account '{}' (id={}) with original credentials",
admin_username, admin_id
);
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
if system_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let has_global_slugs: bool = system_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
[],
|r| r.get(0),
)
.unwrap_or(false);
if has_global_slugs {
let mut stmt =
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let owner_id: i64 = row.get(0)?;
// Skip user 1 (legacy_admin) and the admin we just created
if owner_id == 1 || owner_id == admin_id {
continue;
}
// Check if this user already exists in users.db
let exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_id],
|r| r.get(0),
)
.unwrap_or(false);
if !exists {
// Create a disabled placeholder so RegistryValidator can resolve ownership.
// The tenant's actual databases were not included in the flat backup.
let placeholder_name = format!("restored_user_{}", owner_id);
users_conn.execute(
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
owner_id,
&placeholder_name,
&admin_password_hash,
"disabled",
&now,
"standard",
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
],
)?;
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
warn!(
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
owner_id
);
}
}
}
}
Ok(())
}
/// Classify registry issues into hard errors vs warnings for legacy restore.
///
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
/// TenantAdminHasIsolatedContent
fn classify_registry_issues(
issues: &[crate::services::registry_validator::RegistryIssue],
is_legacy: bool,
) -> (
Vec<&crate::services::registry_validator::RegistryIssue>,
Vec<&crate::services::registry_validator::RegistryIssue>,
) {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
match issue.issue_type {
RegistryIssueType::DuplicateSlug
| RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => {
errors.push(issue);
}
RegistryIssueType::MissingOwner if !is_legacy => {
errors.push(issue);
}
_ => {
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
// These represent pre-existing inconsistencies in the backup data,
// not restore corruption.
warnings.push(issue);
}
}
}
(errors, warnings)
}
pub fn perform_restore(
file_path: &std::path::Path,
data_dir: &std::path::Path,
file_path: &Path,
data_dir: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
// 1. Open the archive
// 1. Open and unpack the archive to a temporary directory
let f = File::open(file_path)?;
let tar_gz = GzDecoder::new(f);
let mut archive = Archive::new(tar_gz);
// 2. Unpack to temporary directory first
let temp_dir =
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&temp_dir)?;
@@ -25,7 +254,33 @@ pub fn perform_restore(
return Err(e.into());
}
// 3. Run validation on temp_dir
// 2. Detect backup format
let is_legacy = is_legacy_flat_backup(&temp_dir);
let needs_normalization = is_flat_layout(&temp_dir);
if is_legacy {
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
}
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
if needs_normalization {
info!("Normalizing flat database layout into multi-tenant structure...");
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to normalize legacy layout: {}", e).into());
}
}
// 4. For legacy backups: bootstrap the empty users.db with the current schema
// and populate it from admin.db credentials
if is_legacy {
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
}
}
// 5. Run validation on the normalized temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
@@ -46,29 +301,51 @@ pub fn perform_restore(
}
// Registry integrity check
let system_db_path = if temp_dir.join("admin/system.db").exists() {
temp_dir.join("admin/system.db")
} else {
temp_dir.join("system.db")
};
let users_db_path = if temp_dir.join("admin/users.db").exists() {
temp_dir.join("admin/users.db")
} else {
temp_dir.join("users.db")
};
let system_db_path = temp_dir.join("admin").join("system.db");
let users_db_path = temp_dir.join("admin").join("users.db");
if system_db_path.exists() && users_db_path.exists() {
let system_conn = rusqlite::Connection::open(&system_db_path)?;
let users_conn = rusqlite::Connection::open(&users_db_path)?;
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&temp_dir,
None,
) {
Ok((errors, _warnings)) => {
if !errors.is_empty() {
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
Ok(issues) => {
if !issues.is_empty() {
let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
// Log all warnings
for w in &warnings {
warn!(
"Legacy restore warning: {:?} — {}",
w.issue_type, w.description
);
}
// Abort only on hard errors
if !hard_errors.is_empty() {
let descriptions: Vec<String> = hard_errors
.iter()
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
.collect();
let _ = std::fs::remove_dir_all(&temp_dir);
return Err(format!(
"Registry integrity errors in backup ({} critical): {}",
hard_errors.len(),
descriptions.join("; ")
)
.into());
}
if !warnings.is_empty() {
info!(
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
warnings.len()
);
}
}
}
Err(e) => {
@@ -78,13 +355,13 @@ pub fn perform_restore(
}
}
// 4. If validation succeeds, copy temp_dir contents to data_dir
// 6. If validation succeeds, atomically replace data_dir contents
if data_dir.exists() {
let _ = std::fs::remove_dir_all(data_dir);
}
std::fs::create_dir_all(data_dir)?;
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
+96 -20
View File
@@ -7,6 +7,30 @@ use std::path::PathBuf;
use std::time::Instant;
use tracing::info;
async fn shutdown_signal() {
let ctrl_c = async {
tokio::signal::ctrl_c()
.await
.expect("failed to install Ctrl+C handler");
};
#[cfg(unix)]
let terminate = async {
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.expect("failed to install signal handler")
.recv()
.await;
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! {
_ = ctrl_c => {},
_ = terminate => {},
}
}
pub async fn run(
host: Option<String>,
port: Option<u16>,
@@ -29,39 +53,63 @@ pub async fn run(
// Init DBs
let db = Db::init(&config)?;
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
let mut join_handles = Vec::new();
// Init Queue
let queue = AnalyticsQueue::new(db.clone(), 1000);
let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
join_handles.push(("analytics_worker", analytics_handle));
// Spawn background tasks
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 rx = shutdown_rx.clone();
join_handles.push((
"link_checker",
tokio::spawn(async move {
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
}),
));
let aggregator_db = db.clone();
let aggregator_interval = config.aggregation_interval_mins;
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
});
let rx = shutdown_rx.clone();
join_handles.push((
"aggregator",
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
}),
));
let retention_db = db.clone();
let retention_days = config.data_retention_days;
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
});
let rx = shutdown_rx.clone();
join_handles.push((
"retention_cleaner",
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).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 rx = shutdown_rx.clone();
join_handles.push((
"backup_scheduler",
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
}),
));
let expiry_db = db.clone();
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
let rx = shutdown_rx.clone();
join_handles.push((
"expiry_checker",
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db, rx).await;
}),
));
let reconcile_db = db.clone();
let reconcile_interval_hours = {
@@ -75,9 +123,13 @@ pub async fn run(
.and_then(|val| val.parse::<u64>().ok())
.unwrap_or(24)
};
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
});
let rx = shutdown_rx.clone();
join_handles.push((
"quota_reconciliation",
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
}),
));
let state = AppState {
admin_db: db.admin.clone(),
@@ -98,7 +150,31 @@ pub async fn run(
let listener = tokio::net::TcpListener::bind(&addr).await?;
info!("Listening for requests on http://{}", addr);
axum::serve(listener, router).await?;
axum::serve(listener, router)
.with_graceful_shutdown(async move {
shutdown_signal().await;
info!("Shutdown signal received");
info!("Stopping HTTP server...");
let _ = shutdown_tx.send(true);
})
.await?;
info!("Stopping background workers...");
let timeout_duration = std::time::Duration::from_secs(10);
let deadline = tokio::time::Instant::now() + timeout_duration;
for (name, handle) in join_handles {
match tokio::time::timeout_at(deadline, handle).await {
Ok(Ok(_)) => {}
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
}
}
info!("Background workers stopped");
info!("BZOD shutdown complete");
Ok(())
}
+18 -6
View File
@@ -14,14 +14,26 @@ pub async fn run(
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();
let files = vec![
("admin.db", config.data_dir.join("admin/admin.db")),
("system.db", config.data_dir.join("admin/system.db")),
("users.db", config.data_dir.join("admin/users.db")),
(
"legacy content.db",
config.data_dir.join("users/1/content.db"),
),
(
"legacy analytics.db",
config.data_dir.join("users/1/analytics.db"),
),
];
for (name, path) in files {
if path.exists() {
let sz = std::fs::metadata(&path)?.len();
println!(
" File: {} - Size: {} bytes ({:.2} MB)",
f,
name,
sz,
sz as f64 / 1_048_576.0
);
+12 -14
View File
@@ -50,7 +50,7 @@ impl Db {
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
if legacy_admin_db.exists() {
info!("Legacy admin.db found at root. Moving administrative databases to admin/ subfolder...");
tracing::warn!("LEGACY DETECTED: admin.db found at root. Moving administrative databases to multi-tenant admin/ subfolder...");
let files = vec![
"admin.db",
"admin.db-wal",
@@ -219,7 +219,7 @@ impl Db {
fs::create_dir_all(&legacy_user_dir)?;
if legacy_content_db.exists() || legacy_analytics_db.exists() {
info!("Legacy content/analytics databases found at root. Moving to user ID 1 directory...");
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
@@ -356,17 +356,19 @@ impl Db {
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&config.data_dir,
None,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
Ok(issues) => {
for issue in issues {
tracing::error!(
"Global registry integrity issue: {:?} for slug {}",
issue.issue_type,
issue.slug
);
}
}
Err(e) => {
@@ -461,11 +463,7 @@ impl Db {
for user_id in user_ids {
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let content_path = if user_id == 1 {
config.data_dir.join("content.db") // legacy admin content db path
} else {
user_dir.join("content.db")
};
let content_path = user_dir.join("content.db");
if content_path.exists() {
let content_conn = Connection::open(&content_path)?;
-143
View File
@@ -722,149 +722,6 @@ pub fn cleanup_stale_reservations(
Ok(cleaned_count)
}
pub fn verify_global_slug_registry_integrity(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &std::path::Path,
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut errors = Vec::new();
let mut warnings = Vec::new();
// 1. Check duplicate slugs
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 =
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
r.get(0)
})?;
if total_count != distinct_count {
errors.push(format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
));
}
// 2. Scan all global slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
// Target type check
if target_type != "url" && target_type != "page" {
errors.push(format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
));
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
errors.push(format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
));
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
warnings.push(format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
));
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
} else {
data_dir.join("content.db")
}
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
if !content_db_path.exists() {
errors.push(format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
));
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
errors.push(format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id));
}
}
Err(e) => {
errors.push(format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
));
}
}
}
}
}
Ok((errors, warnings))
}
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
+12 -2
View File
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
use crate::analytics::aggregate_day;
use crate::db::Db;
pub async fn run_aggregator(db: Db, interval_mins: u64) {
pub async fn run_aggregator(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop {
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Analytics aggregator shutting down...");
break;
}
}
info!("Running background analytics aggregator...");
let user_ids: Vec<i64> = {
+12 -2
View File
@@ -4,7 +4,11 @@ use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_backup_scheduler(db: Db, config: Config) {
pub async fn run_backup_scheduler(
db: Db,
config: Config,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
if !config.backup_enabled {
info!("Background backup scheduler is disabled.");
return;
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
);
loop {
// Run backup every configured interval
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Backup scheduler shutting down...");
break;
}
}
info!("Running background database backup...");
let job_id = log_job_start(&db.system, "database_backup");
+49 -9
View File
@@ -1,33 +1,73 @@
use crate::db::Db;
use std::time::Duration;
use tracing::info;
use tracing::{error, info, warn};
/// Background job that marks expired URLs.
///
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
/// gets flipped to `expired = 1`.
pub async fn run_expiry_checker(db: Db) {
///
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
/// authoritative and returns 410 without depending on this sweeper. The sweeper
/// is maintenance (persist `expired=1`) and must remain idempotent.
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
loop {
tokio::time::sleep(Duration::from_secs(60)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
_ = shutdown_rx.changed() => {
info!("Expiry checker shutting down...");
break;
}
}
let user_ids: Vec<i64> = {
let conn = db.users.lock().unwrap();
let conn = match db.users.lock() {
Ok(c) => c,
Err(e) => {
error!(error = %e, "expiry job: users_db mutex poisoned");
continue;
}
};
let mut stmt = match conn.prepare("SELECT id FROM users;") {
Ok(s) => s,
Err(_) => continue,
Err(e) => {
error!(error = %e, "expiry job: failed to list users");
continue;
}
};
let rows = match stmt.query_map([], |row| row.get(0)) {
Ok(r) => r,
Err(_) => continue,
Err(e) => {
error!(error = %e, "expiry job: failed to map user ids");
continue;
}
};
rows.filter_map(|r| r.ok()).collect()
};
let mut total_expired = 0;
for user_id in user_ids {
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
total_expired += count;
match super::open_user_content_conn(&db, user_id) {
Ok(conn) => match crate::db::content::expire_urls(&conn) {
Ok(count) => total_expired += count,
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: expire_urls failed"
);
}
},
Err(e) => {
// Missing content.db for a user is common; only log open errors that are unexpected.
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
warn!(
owner_user_id = user_id,
error = %e,
"expiry job: could not open content.db"
);
}
}
}
}
+13 -2
View File
@@ -8,7 +8,11 @@ use uuid::Uuid;
use super::{log_job_end, log_job_start};
use crate::db::Db;
pub async fn run_link_checker(db: Db, interval_mins: u64) {
pub async fn run_link_checker(
db: Db,
interval_mins: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let client = Client::builder()
.timeout(Duration::from_secs(10))
.user_agent("bzod-link-checker/0.1")
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
loop {
// Sleep first to give server time to start up
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
_ = shutdown_rx.changed() => {
info!("Link checker shutting down...");
break;
}
}
info!("Running background link health check...");
let job_id = log_job_start(&db.system, "link_checker");
+12 -2
View File
@@ -2,10 +2,20 @@ use crate::db::Db;
use std::time::Duration;
use tracing::{error, info};
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
pub async fn run_quota_reconciliation(
db: Db,
interval_hours: u64,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
loop {
// Sleep first
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Quota reconciliation shutting down...");
break;
}
}
info!("Running background quota reconciliation...");
let user_ids: Vec<i64> = {
+12 -2
View File
@@ -4,7 +4,11 @@ use tracing::{error, info};
use super::{log_job_end, log_job_start};
use crate::db::Db;
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
pub async fn run_retention_cleaner(
db: Db,
retention_days_opt: Option<i64>,
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
) {
let retention_days = match retention_days_opt {
Some(days) => days,
None => return,
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
loop {
// Check once every 24 hours
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
tokio::select! {
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
_ = shutdown_rx.changed() => {
info!("Retention cleaner shutting down...");
break;
}
}
info!("Running background data retention cleanup...");
let user_ids: Vec<i64> = {
+1
View File
@@ -1,5 +1,6 @@
pub mod analytics;
pub mod auth;
pub mod build_info;
pub mod charts;
pub mod cli;
pub mod config;
+18
View File
@@ -38,9 +38,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::Validate { data_dir } => {
bzod::cli::validate::run(data_dir, config).await?;
}
Commands::AuditDestinations { data_dir } => {
bzod::cli::audit_destinations::run(data_dir, config).await?;
}
Commands::CreateAdmin { username, data_dir } => {
bzod::cli::create_admin::run(username, data_dir, config).await?;
}
Commands::InitAdmin { data_dir } => {
bzod::cli::init_admin::run(data_dir, config).await?;
}
Commands::Doctor { data_dir } => {
bzod::cli::doctor::run(data_dir, config).await?;
}
@@ -94,6 +100,18 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
Commands::RestoreUser { file, data_dir } => {
bzod::cli::restore_user::run(file, data_dir, config).await?;
}
Commands::AdminMigrate {
target_admin_id,
data_dir,
dry_run,
force,
} => {
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
.await?;
}
Commands::Repair { command } => {
bzod::cli::repair::run(command, config).await?;
}
}
Ok(())
+121
View File
@@ -0,0 +1,121 @@
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
//!
//! Extracted from admin restore handlers so path moves are testable without HTTP.
use std::path::{Path, PathBuf};
use tracing::warn;
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
///
/// Layout:
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let admin_dir = data_dir.join("admin");
let users_1_dir = data_dir.join("users").join("1");
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
let admin_files = [
"admin.db",
"admin.db-wal",
"admin.db-shm",
"system.db",
"system.db-wal",
"system.db-shm",
"users.db",
"users.db-wal",
"users.db-shm",
];
for f in admin_files {
let src = data_dir.join(f);
if src.exists() {
let dst = admin_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored admin file into admin/"
);
return Err(e);
}
}
}
let content_files = [
"content.db",
"content.db-wal",
"content.db-shm",
"analytics.db",
"analytics.db-wal",
"analytics.db-shm",
];
for f in content_files {
let src = data_dir.join(f);
if src.exists() {
let dst = users_1_dir.join(f);
if let Err(e) = std::fs::rename(&src, &dst) {
warn!(
file = f,
error = %e,
"failed to move restored content file into users/1/"
);
return Err(e);
}
}
}
Ok(())
}
/// Paths used when reopening connections after restore.
#[derive(Debug, Clone)]
pub struct RestoredDbPaths {
pub admin: PathBuf,
pub system: PathBuf,
pub users: PathBuf,
pub content: PathBuf,
pub analytics: PathBuf,
}
impl RestoredDbPaths {
pub fn from_data_dir(data_dir: &Path) -> Self {
Self {
admin: data_dir.join("admin/admin.db"),
system: data_dir.join("admin/system.db"),
users: data_dir.join("admin/users.db"),
content: data_dir.join("users/1/content.db"),
analytics: data_dir.join("users/1/analytics.db"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn moves_flat_files_into_tenant_layout() {
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("admin.db"), b"a").unwrap();
fs::write(dir.join("system.db"), b"s").unwrap();
fs::write(dir.join("users.db"), b"u").unwrap();
fs::write(dir.join("content.db"), b"c").unwrap();
fs::write(dir.join("analytics.db"), b"an").unwrap();
normalize_restored_layout(&dir).unwrap();
assert!(dir.join("admin/admin.db").exists());
assert!(dir.join("admin/system.db").exists());
assert!(dir.join("admin/users.db").exists());
assert!(dir.join("users/1/content.db").exists());
assert!(dir.join("users/1/analytics.db").exists());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+204
View File
@@ -0,0 +1,204 @@
//! Bulk URL creation business logic (transaction + slug reservation).
//!
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::models::Url;
use crate::utils::validation::validate_redirect_destination;
use rusqlite::{Connection, Transaction};
use std::sync::Mutex;
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
#[derive(Debug, Clone)]
pub struct BulkUrlCreateItem {
pub destination: String,
pub code: Option<String>,
pub title: Option<String>,
pub description: Option<String>,
pub tags: Option<Vec<String>>,
pub expires_at: Option<String>,
pub password: Option<String>,
pub max_access_count: Option<i64>,
}
#[derive(Debug)]
pub enum BulkUrlError {
BadRequest(String),
Conflict(String),
Forbidden(String),
Internal(String),
}
impl BulkUrlError {
pub fn message(&self) -> &str {
match self {
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
}
}
}
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
for slug in slugs {
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
}
}
/// Check that the tenant can accept `additional` new URLs.
pub fn ensure_url_quota(
users_db: &Mutex<Connection>,
user_id: i64,
additional: i64,
) -> Result<(), BulkUrlError> {
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
match crate::db::users::get_user_quotas(&users_conn, user_id) {
Ok(Some(quotas)) => {
if quotas.current_urls + additional > quotas.max_urls {
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
} else {
Ok(())
}
}
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
}
}
/// Create many URLs inside a single content transaction with global slug reservation.
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
system_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
items: Vec<BulkUrlCreateItem>,
) -> Result<Vec<Url>, BulkUrlError> {
let mut conn = crate::utils::lock_db(content_db, "content_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let tx = conn.transaction().map_err(|e| {
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
})?;
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in items {
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = crate::utils::lock_db(users_db, "users_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
}
}
Ok(created_urls)
}
fn create_one_in_tx(
tx: &Transaction<'_>,
system_db: &Mutex<Connection>,
owner_user_id: i64,
item: BulkUrlCreateItem,
reserved_slugs: &mut Vec<String>,
) -> Result<Url, BulkUrlError> {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(BulkUrlError::BadRequest(format!(
"Short code '{code}' must be 6 hex characters"
)));
}
{
let system_conn = crate::utils::lock_db(system_db, "system_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
return Err(BulkUrlError::Conflict(format!(
"Short code '{code}' already exists"
)));
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
owner_user_id,
"url",
"",
"reserving",
) {
return Err(BulkUrlError::Internal(format!(
"Failed to reserve slug '{code}': {e}"
)));
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
return Err(BulkUrlError::Internal(format!(
"Password hashing error: {e}"
)));
}
}
} else {
None
};
if !validate_redirect_destination(&item.destination) {
return Err(BulkUrlError::BadRequest(format!(
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
)));
}
let tags = item.tags.unwrap_or_default();
crate::db::content::create_url_extended(
tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
)
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
}
+261
View File
@@ -0,0 +1,261 @@
//! Read-only audit of stored redirect destinations.
//!
//! Scans tenant content databases and classifies each `urls.destination` using
//! the same rules as write-path validation. Never rewrites or deletes data.
use crate::db::Db;
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
use rusqlite::Connection;
use std::path::Path;
use tracing::{error, info, warn};
/// Summary counters for a destination audit run.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DestinationAuditReport {
pub scanned_users: usize,
pub total_urls: usize,
pub valid_http: usize,
pub valid_https: usize,
pub invalid: usize,
pub control_characters: usize,
pub unsupported_scheme: usize,
pub malformed: usize,
pub empty: usize,
pub too_long: usize,
pub non_ascii: usize,
/// Safe sample of invalid records: (owner_user_id, code, class_label).
/// Destination bodies are never included (may contain control chars / secrets).
pub invalid_samples: Vec<InvalidDestinationSample>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidDestinationSample {
pub owner_user_id: i64,
pub code: String,
pub url_id: String,
pub class: &'static str,
pub destination_len: usize,
}
const MAX_SAMPLES: usize = 50;
fn class_label(c: DestinationClass) -> &'static str {
match c {
DestinationClass::ValidHttp => "valid_http",
DestinationClass::ValidHttps => "valid_https",
DestinationClass::Empty => "empty",
DestinationClass::TooLong => "too_long",
DestinationClass::ControlCharacters => "control_characters",
DestinationClass::NonAscii => "non_ascii",
DestinationClass::UnsupportedScheme => "unsupported_scheme",
DestinationClass::Malformed => "malformed",
}
}
/// Classify a single destination and update report counters.
pub fn record_destination(
report: &mut DestinationAuditReport,
owner_user_id: i64,
code: &str,
url_id: &str,
destination: &str,
) {
report.total_urls += 1;
let class = classify_redirect_destination(destination);
match class {
DestinationClass::ValidHttp => report.valid_http += 1,
DestinationClass::ValidHttps => report.valid_https += 1,
DestinationClass::Empty => {
report.empty += 1;
report.invalid += 1;
}
DestinationClass::TooLong => {
report.too_long += 1;
report.invalid += 1;
}
DestinationClass::ControlCharacters => {
report.control_characters += 1;
report.invalid += 1;
}
DestinationClass::NonAscii => {
report.non_ascii += 1;
report.invalid += 1;
}
DestinationClass::UnsupportedScheme => {
report.unsupported_scheme += 1;
report.invalid += 1;
}
DestinationClass::Malformed => {
report.malformed += 1;
report.invalid += 1;
}
}
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
report.invalid_samples.push(InvalidDestinationSample {
owner_user_id,
code: code.to_string(),
url_id: url_id.to_string(),
class: class_label(class),
destination_len: destination.len(),
});
}
}
/// Scan one content database connection for URL destinations.
pub fn audit_content_conn(
conn: &Connection,
owner_user_id: i64,
report: &mut DestinationAuditReport,
) -> rusqlite::Result<()> {
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
let rows = stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?;
for row in rows {
let (id, code, destination) = row?;
record_destination(report, owner_user_id, &code, &id, &destination);
}
Ok(())
}
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
let path = data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
let conn = Connection::open(path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
Ok(conn)
}
/// Audit all tenant content databases found under the configured data directory.
///
/// Read-only: does not modify any records.
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
let mut report = DestinationAuditReport::default();
let user_ids: Vec<i64> = {
let users = db
.users
.lock()
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
let mut stmt = users
.prepare("SELECT id FROM users;")
.map_err(|e| e.to_string())?;
let rows = stmt
.query_map([], |row| row.get(0))
.map_err(|e| e.to_string())?;
rows.filter_map(|r| r.ok()).collect()
};
for user_id in user_ids {
match open_user_content(&db.data_dir, user_id) {
Ok(conn) => {
report.scanned_users += 1;
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
error!(
owner_user_id = user_id,
error = %e,
"destination audit failed for user content.db"
);
return Err(format!("audit user {} content.db: {}", user_id, e));
}
}
Err(rusqlite::Error::InvalidPath(_)) => {
// User has no content DB yet — skip.
}
Err(e) => {
warn!(
owner_user_id = user_id,
error = %e,
"could not open user content.db for destination audit"
);
}
}
}
info!(
total_urls = report.total_urls,
valid = report.valid_http + report.valid_https,
invalid = report.invalid,
"destination audit complete"
);
Ok(report)
}
/// Format a human-readable report for CLI output.
pub fn format_report(report: &DestinationAuditReport) -> String {
let mut out = String::new();
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
out.push_str("===========================================\n");
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
out.push_str(&format!(
" control characters: {}\n",
report.control_characters
));
out.push_str(&format!(
" unsupported scheme: {}\n",
report.unsupported_scheme
));
out.push_str(&format!(" malformed: {}\n", report.malformed));
out.push_str(&format!(" empty: {}\n", report.empty));
out.push_str(&format!(" too long: {}\n", report.too_long));
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
if !report.invalid_samples.is_empty() {
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
for s in &report.invalid_samples {
out.push_str(&format!(
" user={} code={} id={} class={} dest_len={}\n",
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn records_control_character_destination() {
let mut report = DestinationAuditReport::default();
record_destination(
&mut report,
1,
"ab12cd",
"id-1",
"https://evil.example/\r\nX:1",
);
assert_eq!(report.total_urls, 1);
assert_eq!(report.invalid, 1);
assert_eq!(report.control_characters, 1);
assert_eq!(report.invalid_samples.len(), 1);
assert_eq!(report.invalid_samples[0].class, "control_characters");
// Ensure we never store the destination body in the sample.
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
}
#[test]
fn records_valid_https() {
let mut report = DestinationAuditReport::default();
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
assert_eq!(report.valid_https, 1);
assert_eq!(report.invalid, 0);
assert!(report.invalid_samples.is_empty());
}
}
+6
View File
@@ -1,6 +1,12 @@
pub mod api_keys;
pub mod audit;
pub mod backup_layout;
pub mod bulk;
pub mod bulk_urls;
pub mod destination_audit;
pub mod landing_pages;
pub mod qr;
pub mod registry_validator;
pub mod shortener;
pub mod slug_transfer;
pub mod urls;
+285
View File
@@ -0,0 +1,285 @@
use rusqlite::Connection;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq)]
pub enum RegistryIssueType {
DuplicateSlug,
InvalidTargetType,
InvalidStatus,
MissingOwner,
MissingDatabase,
MissingTarget,
StaleReservation,
TenantAdminHasIsolatedContent,
}
#[derive(Debug, Clone)]
pub struct RegistryIssue {
pub slug: String,
pub target_type: String,
pub owner_user_id: i64,
pub database_path: PathBuf,
pub target_id: String,
pub issue_type: RegistryIssueType,
pub description: String,
}
pub struct RegistryValidator;
impl RegistryValidator {
/// Scans the global_slugs registry and returns a list of detected issues.
pub fn scan(
system_conn: &Connection,
users_conn: &Connection,
data_dir: &Path,
slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
use chrono::{DateTime, Utc};
let mut issues = Vec::new();
// 1. Check duplicate slugs (only if not filtering by single slug)
if slug_filter.is_none() {
let total_count: i64 =
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
let distinct_count: i64 = system_conn.query_row(
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
[],
|r| r.get(0),
)?;
if total_count != distinct_count {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: 0,
database_path: data_dir.join("admin/system.db"),
target_id: "".to_string(),
issue_type: RegistryIssueType::DuplicateSlug,
description: format!(
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
total_count, distinct_count
),
});
}
}
// 2. Scan global slugs
let (query, params_string) = if let Some(slug) = slug_filter {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
vec![slug.to_string()],
)
} else {
(
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
vec![],
)
};
let mut stmt = system_conn.prepare(query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_user_id: i64 = row.get(1)?;
let target_type: String = row.get(2)?;
let target_id: String = row.get(3)?;
let created_at_str: String = row.get(4)?;
let status: String = row.get(5)?;
let content_db_path = if owner_user_id == 1 {
data_dir.join("users").join("1").join("content.db")
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
};
// Target type check
if target_type != "url" && target_type != "page" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidTargetType,
description: format!(
"Slug '{}' has invalid target_type '{}'",
slug, target_type
),
});
}
// Status check
if status != "active" && status != "disabled" && status != "reserving" {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status),
});
}
// Check owner
let owner_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[owner_user_id],
|r| r.get(0),
)
.unwrap_or(false);
if !owner_exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingOwner,
description: format!(
"Slug '{}' references missing owner user ID {}",
slug, owner_user_id
),
});
continue;
}
// Stale warning check
if status == "reserving" {
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::StaleReservation,
description: format!(
"Reserving slug '{}' has been stale for over 15 minutes",
slug
),
});
}
}
}
// Check target record exists for active / disabled (and reserving with target_id)
if status == "active"
|| status == "disabled"
|| (status == "reserving" && !target_id.is_empty())
{
if !content_db_path.exists() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
),
});
} else {
match Connection::open(&content_db_path) {
Ok(conn) => {
let exists = if target_type == "url" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else if target_type == "page" {
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
false
};
if !exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTarget,
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
});
}
}
Err(e) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.clone(),
owner_user_id,
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingDatabase,
description: format!(
"Slug '{}' owner content database could not be opened: {}",
slug, e
),
});
}
}
}
}
}
// 3. Admin Content Reverse Consistency Check (Legacy DB)
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
if slug_filter.is_none() {
let mut stmt = users_conn.prepare(
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
)?;
let mut admin_rows = stmt.query([])?;
while let Some(row) = admin_rows.next()? {
let id: i64 = row.get(0)?;
let username: String = row.get(1)?;
let tenant_db_path = data_dir
.join("users")
.join(id.to_string())
.join("content.db");
if tenant_db_path.exists() {
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
let url_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
.unwrap_or(0);
let page_count: i64 = tenant_conn
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
.unwrap_or(0);
if url_count > 0 || page_count > 0 {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_user_id: id,
database_path: tenant_db_path.clone(),
target_id: "".to_string(),
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
});
}
}
}
}
}
Ok(issues)
}
}
+13 -2
View File
@@ -10,13 +10,24 @@ pub fn create_url(
description: Option<&str>,
tags: &[String],
) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap();
if !crate::utils::validation::validate_redirect_destination(destination) {
return Err(AppError::BadRequest(
"Destination must be a valid http(s) URL without control characters".into(),
));
}
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
Ok(url)
}
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap();
let conn = db
.content
.lock()
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
let url = crate::db::content::get_url_by_code(&conn, code)?;
Ok(url)
}
+243
View File
@@ -0,0 +1,243 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates global_slugs
//! ownership. Handlers own admin auth and HTTP mapping.
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
use rusqlite::OptionalExtension;
#[derive(Debug)]
pub enum TransferError {
NotFound(&'static str),
BadRequest(String),
Internal(String),
}
impl TransferError {
pub fn message(&self) -> String {
match self {
Self::NotFound(m) => (*m).to_string(),
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
}
}
}
#[derive(Debug, Clone)]
pub struct SlugTransferRequest {
pub slug: String,
pub new_owner_user_id: i64,
}
#[derive(Debug)]
pub struct SlugTransferResult {
pub old_owner_user_id: i64,
pub new_owner_user_id: i64,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in `global_slugs`.
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let mut stmt = system_conn
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let row_opt = stmt
.query_row([slug], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})
.optional()
.map_err(|e| TransferError::Internal(e.to_string()))?;
match row_opt {
Some(r) => Ok(r),
None => Err(TransferError::NotFound("Slug not found")),
}
}
/// Copy content row between tenants and return the new target id.
fn copy_content(
state: &AppState,
old_dbs: &UserDbs,
new_dbs: &UserDbs,
slug: &str,
target_type: &str,
new_owner_user_id: i64,
) -> Result<String, TransferError> {
let old_conn = lock_db(&old_dbs.content, "old_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let new_conn = lock_db(&new_dbs.content, "new_content_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if target_type == "url" {
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
Ok(Some(u)) => u,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_urls >= quota.max_urls {
return Err(TransferError::BadRequest(
"New owner has exceeded URL quota limit".into(),
));
}
}
}
let new_url = crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
let _ = crate::db::content::delete_url(&old_conn, &url.id);
Ok(new_url.id)
} else if target_type == "page" {
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
Ok(Some(p)) => p,
Ok(None) => {
return Err(TransferError::NotFound(
"Content not found in owner database",
))
}
Err(e) => return Err(TransferError::Internal(e.to_string())),
};
{
let new_users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
if let Ok(Some(quota)) =
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
{
if quota.current_landings >= quota.max_landings {
return Err(TransferError::BadRequest(
"New owner has exceeded landing page quota limit".into(),
));
}
}
}
let new_page = crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
)
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
Ok(new_page.id)
} else {
Err(TransferError::NotFound(
"Content not found in owner database",
))
}
}
/// Perform a full slug transfer (content + registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
if old_owner_user_id == req.new_owner_user_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.get_user_dbs(old_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.get_user_dbs(req.new_owner_user_id)
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
let new_target_id = copy_content(
state,
&old_dbs,
&new_dbs,
&req.slug,
&target_type,
req.new_owner_user_id,
)?;
{
let system_conn = lock_db(&state.system_db, "system_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let now = Utc::now().to_rfc3339();
let _ = system_conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
);
let _ = system_conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![
req.slug,
old_owner_user_id,
req.new_owner_user_id,
now,
admin_username
],
);
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let field = if target_type == "url" {
"urls"
} else {
"landings"
};
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
let _ =
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
admin_username,
"SLUG_TRANSFER",
"slug",
&req.slug,
Some(&format!(
"From owner {} to owner {}",
old_owner_user_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
target_type,
new_target_id,
})
}
+117
View File
@@ -0,0 +1,117 @@
//! Shared URL write-path helpers used by admin UI, tenant UI, and REST API.
//!
//! Handlers remain responsible for auth/CSRF/quotas; this module owns pure
//! destination preparation that must stay consistent across entry points.
use crate::utils::validation::validate_redirect_destination;
/// Optional UTM parameters applied to a destination URL.
#[derive(Debug, Default, Clone)]
pub struct UtmParams<'a> {
pub source: Option<&'a str>,
pub medium: Option<&'a str>,
pub campaign: Option<&'a str>,
}
/// Normalize and optionally append UTM parameters to a destination.
///
/// Returns `Err` when the base destination fails canonical validation.
/// UTM appending only runs when the base parses as a URL (same as prior handlers).
pub fn prepare_destination(raw: &str, utm: UtmParams<'_>) -> Result<String, &'static str> {
let mut dest = raw.trim().to_string();
if !validate_redirect_destination(&dest) {
return Err("Destination must be a valid http(s) URL without control characters");
}
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(src) = utm.source {
let src = src.trim();
if !src.is_empty() {
query.append_pair("utm_source", src);
has_utm = true;
}
}
if let Some(med) = utm.medium {
let med = med.trim();
if !med.is_empty() {
query.append_pair("utm_medium", med);
has_utm = true;
}
}
if let Some(camp) = utm.campaign {
let camp = camp.trim();
if !camp.is_empty() {
query.append_pair("utm_campaign", camp);
has_utm = true;
}
}
}
if has_utm {
dest = parsed.to_string();
}
}
Ok(dest)
}
/// Parse HTML datetime-local / partial RFC3339 expiry input into RFC3339 if present.
pub fn parse_expires_at_input(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
let mut rfc = trimmed.to_string();
if rfc.len() == 16 {
// HTML datetime-local → assume UTC seconds
rfc.push_str(":00Z");
}
Some(rfc)
}
/// Parse optional max-access-count form field.
pub fn parse_max_access_count(raw: &str) -> Option<i64> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
trimmed.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn prepare_rejects_crlf() {
let err = prepare_destination("https://x/\r\nY:1", UtmParams::default()).unwrap_err();
assert!(err.contains("valid http"));
}
#[test]
fn prepare_appends_utm() {
let dest = prepare_destination(
"https://example.com/path",
UtmParams {
source: Some("newsletter"),
medium: Some("email"),
campaign: Some("spring"),
},
)
.unwrap();
assert!(dest.contains("utm_source=newsletter"));
assert!(dest.contains("utm_medium=email"));
assert!(dest.contains("utm_campaign=spring"));
}
#[test]
fn parse_expires_datetime_local() {
assert_eq!(
parse_expires_at_input("2030-01-01T12:00"),
Some("2030-01-01T12:00:00Z".to_string())
);
assert_eq!(parse_expires_at_input(" "), None);
}
}
+7 -7
View File
@@ -29,7 +29,7 @@ pub struct AppState {
impl AppState {
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = self.user_dbs.lock().unwrap();
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone());
}
@@ -87,12 +87,12 @@ impl AppState {
Ok(dbs)
}
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;", [])?;
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
Ok(())
}
}
+61
View File
@@ -0,0 +1,61 @@
//! Poison-safe helpers for `std::sync::Mutex` around SQLite connections.
//!
//! Prefer these on request paths so a poisoned mutex returns a controlled error
//! instead of panicking the worker thread.
use crate::error::AppError;
use std::sync::{Mutex, MutexGuard};
use tracing::error;
/// Acquire a database mutex, mapping poison to [`AppError::Internal`].
///
/// Logs the mutex name (not connection contents or secrets).
pub fn lock_db<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, AppError> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
AppError::Internal(format!("{name} mutex poisoned"))
})
}
/// Acquire a database mutex, mapping poison to a plain error string.
///
/// Useful for handlers that return `(StatusCode, String)` rather than `AppError`.
pub fn lock_db_str<'a, T>(
mutex: &'a Mutex<T>,
name: &'static str,
) -> Result<MutexGuard<'a, T>, String> {
mutex.lock().map_err(|e| {
error!(mutex = name, error = %e, "database mutex poisoned");
format!("{name} mutex poisoned")
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn lock_db_succeeds_on_healthy_mutex() {
let m = Mutex::new(42);
let g = lock_db(&m, "test").unwrap();
assert_eq!(*g, 42);
}
#[test]
fn lock_db_maps_poison() {
let m = Mutex::new(1);
let _ = std::panic::catch_unwind(|| {
let _g = m.lock().unwrap();
panic!("poison");
});
let err = lock_db(&m, "poisoned_db").unwrap_err();
match err {
AppError::Internal(msg) => assert!(msg.contains("poisoned_db")),
other => panic!("unexpected {other:?}"),
}
}
}
+3 -1
View File
@@ -1,3 +1,4 @@
pub mod db_lock;
pub mod hashing;
pub mod network;
pub mod random;
@@ -5,8 +6,9 @@ pub mod system;
pub mod time;
pub mod validation;
pub use db_lock::{lock_db, lock_db_str};
pub use hashing::sha256_hash;
pub use network::get_client_ip;
pub use network::{get_client_ip, resolve_cookie_secure};
pub use random::generate_token;
pub use system::{get_db_file_info, get_memory_usage};
pub use time::format_duration;
+116
View File
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
}
"127.0.0.1".to_string()
}
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
pub(crate) fn extract_hostname(host_header: &str) -> &str {
if host_header.starts_with('[') {
if let Some(end_idx) = host_header.find(']') {
return &host_header[1..end_idx];
}
}
host_header.split(':').next().unwrap_or(host_header)
}
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
///
/// Policy:
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
/// the cookie during local development over cleartext HTTP.
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
// 1. Explicit HTTPS via reverse proxy
if let Some(proto) = headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
{
if proto.eq_ignore_ascii_case("https") {
return true;
}
}
// 2. Exact loopback development (prevent cookie drop)
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
let hostname = extract_hostname(host_hdr);
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
return false;
}
}
// 3. Global config (normally true)
config_secure
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::HeaderValue;
#[test]
fn test_extract_hostname() {
assert_eq!(extract_hostname("localhost"), "localhost");
assert_eq!(extract_hostname("localhost:8080"), "localhost");
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
assert_eq!(extract_hostname("[::1]"), "::1");
assert_eq!(extract_hostname("[::1]:8080"), "::1");
assert_eq!(extract_hostname("example.com"), "example.com");
assert_eq!(extract_hostname("example.com:443"), "example.com");
assert_eq!(
extract_hostname("localhost.example.com"),
"localhost.example.com"
);
}
#[test]
fn test_resolve_cookie_secure() {
let mut headers = HeaderMap::new();
// No headers, global config is true -> Secure=true
assert!(resolve_cookie_secure(true, &headers));
// No headers, global config is false -> Secure=false
assert!(!resolve_cookie_secure(false, &headers));
// Exact loopback matches -> Secure=false
let loopback_hosts = [
"localhost",
"localhost:8080",
"127.0.0.1",
"127.0.0.1:8080",
"[::1]",
"[::1]:8080",
];
for host in loopback_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
!resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// Non-loopback localhost subdomains -> Secure=true (relying on config)
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
for host in public_hosts {
headers.insert("host", HeaderValue::from_static(host));
assert!(
resolve_cookie_secure(true, &headers),
"Failed for host: {}",
host
);
}
// X-Forwarded-Proto = https overrides loopback
headers.insert("host", HeaderValue::from_static("localhost"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
assert!(resolve_cookie_secure(true, &headers));
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
// X-Forwarded-Proto = http DOES NOT override global config
headers.insert("host", HeaderValue::from_static("example.com"));
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
}
}
+5 -4
View File
@@ -19,10 +19,11 @@ pub fn get_memory_usage() -> String {
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"),
("admin/admin.db", "Admin DB"),
("admin/system.db", "System DB"),
("admin/users.db", "Users DB"),
("users/1/content.db", "Legacy Content DB"),
("users/1/analytics.db", "Legacy Analytics DB"),
];
for (f, name) in files {
+141
View File
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
pub fn validate_page_code(code: &str) -> bool {
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
}
/// Classification of a stored or proposed redirect destination.
///
/// Used by write-path validation and read-only legacy data audits. Order of checks
/// matches `validate_redirect_destination` so both share the same rules.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DestinationClass {
ValidHttp,
ValidHttps,
Empty,
TooLong,
ControlCharacters,
NonAscii,
UnsupportedScheme,
Malformed,
}
impl DestinationClass {
pub fn is_valid(self) -> bool {
matches!(self, Self::ValidHttp | Self::ValidHttps)
}
}
fn scheme_prefix(dest: &str) -> Option<&str> {
let end = dest.find(':')?;
let scheme = &dest[..end];
if scheme.is_empty() {
return None;
}
if scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
{
Some(scheme)
} else {
None
}
}
/// Classify a destination using the same rules as write-path validation.
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
let dest = destination.trim();
if dest.is_empty() {
return DestinationClass::Empty;
}
if dest.len() > 2048 {
return DestinationClass::TooLong;
}
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
return DestinationClass::ControlCharacters;
}
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
if !dest.is_ascii() {
return DestinationClass::NonAscii;
}
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
if let Some(scheme) = scheme_prefix(dest) {
let scheme_l = scheme.to_ascii_lowercase();
if scheme_l != "http" && scheme_l != "https" {
return DestinationClass::UnsupportedScheme;
}
}
match reqwest::Url::parse(dest) {
Ok(url) => {
if url.host_str().is_none() {
return DestinationClass::Malformed;
}
match url.scheme() {
"http" => DestinationClass::ValidHttp,
"https" => DestinationClass::ValidHttps,
_ => DestinationClass::UnsupportedScheme,
}
}
Err(_) => DestinationClass::Malformed,
}
}
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
///
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
/// values already exist in older data.
pub fn validate_redirect_destination(destination: &str) -> bool {
classify_redirect_destination(destination).is_valid()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_crlf_destination() {
assert!(!validate_redirect_destination(
"https://example.com/\r\nX-Injected: 1"
));
assert!(!validate_redirect_destination(
"https://example.com/\nX-Injected: 1"
));
}
#[test]
fn rejects_non_http_schemes() {
assert!(!validate_redirect_destination("javascript:alert(1)"));
assert!(!validate_redirect_destination("data:text/html,hi"));
assert!(!validate_redirect_destination("/relative/path"));
}
#[test]
fn accepts_normal_https() {
assert!(validate_redirect_destination(
"https://example.com/path?q=1#frag"
));
assert!(validate_redirect_destination("http://localhost:8080/x"));
}
#[test]
fn classifies_destination_categories() {
assert_eq!(
classify_redirect_destination("https://ok.example/"),
DestinationClass::ValidHttps
);
assert_eq!(
classify_redirect_destination("http://ok.example/"),
DestinationClass::ValidHttp
);
assert_eq!(
classify_redirect_destination("javascript:alert(1)"),
DestinationClass::UnsupportedScheme
);
assert_eq!(
classify_redirect_destination("https://x/\r\nX:1"),
DestinationClass::ControlCharacters
);
assert_eq!(
classify_redirect_destination("not a url"),
DestinationClass::Malformed
);
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
}
}
-6965
View File
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+236
View File
@@ -0,0 +1,236 @@
use super::*;
#[derive(Deserialize)]
pub struct CreateApiKeyForm {
pub key_name: String,
pub csrf_token: String,
}
// POST /admin/settings/api-keys/create
pub async fn create_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateApiKeyForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let key_secret = format!("bzo_{}", generate_token(16));
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(key_secret.as_bytes());
let hashed_key = hex::encode(hasher.finalize());
let conn = state.admin_db.lock().unwrap();
match create_api_key(&conn, &user.id, &form.key_name, &hashed_key) {
Ok(api_key) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_CREATED",
Some("api_key"),
Some(&api_key.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to(&format!(
"/admin/settings?success=Token generated successfully. **IMPORTANT: Copy your token now, it will never be shown again!** Token value: {}",
key_secret
)).into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/settings?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/settings/api-keys/revoke/:id
pub async fn revoke_api_key_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/settings?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.admin_db.lock().unwrap();
match delete_api_key(&conn, &id) {
Ok(_) => {
let _ = write_audit_log(
&conn,
&state,
&user.username,
"API_KEY_REVOKED",
Some("api_key"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/settings?success=API Token revoked").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/settings?error=Failed to revoke key: {}",
e
))
.into_response(),
}
}
// GET /api-tokens
pub async fn api_tokens_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let tokens = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
)
.unwrap();
let rows = stmt
.query_map([user.id], |row| {
Ok(crate::models::UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ApiTokensTemplate {
admin_username: user.username.clone(),
username: user.username,
tokens,
new_token: params.get("new_token").cloned(),
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /api-tokens/create
pub async fn api_tokens_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
use sha2::Digest;
let raw_token = format!("key_{}", generate_token(32));
let mut hasher = sha2::Sha256::new();
hasher.update(raw_token.as_bytes());
let hashed_token = hex::encode(hasher.finalize());
{
let conn = state.users_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"INSERT INTO api_tokens (user_id, token_hash, created_at) VALUES (?1, ?2, ?3);",
rusqlite::params![user.id, hashed_token, now],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_CREATED",
"api_token",
"new",
None,
);
}
Redirect::to(&format!(
"/api-tokens?new_token={}&success=Token generated successfully",
raw_token
))
.into_response()
}
// POST /api-tokens/revoke/:id
pub async fn api_tokens_revoke_post(
State(state): State<AppState>,
jar: CookieJar,
Path(token_id): Path<i64>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/api-tokens?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute(
"DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2;",
[token_id, user.id],
);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"API_TOKEN_REVOKED",
"api_token",
&token_id.to_string(),
None,
);
}
Redirect::to("/api-tokens?success=API token revoked").into_response()
}
+126
View File
@@ -0,0 +1,126 @@
use super::*;
// GET /admin/audit
pub async fn audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events = crate::db::audit_events::list_audit_events(&conn, 100, 0, None, None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::AuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
// GET /user/audit
pub async fn user_audit_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let logs = {
let conn = state.system_db.lock().unwrap();
let events =
crate::db::audit_events::list_audit_events(&conn, 100, 0, Some(&user.username), None)
.unwrap_or_default();
events
.into_iter()
.map(|e| {
let (ip, ua) = if let Some(ref m) = e.metadata {
if m.starts_with("IP: ") {
let parts: Vec<&str> = m.split(", UA: ").collect();
let ip = parts[0]
.trim_start_matches("IP: ")
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "");
let ua = if parts.len() > 1 {
parts[1]
.trim_matches('"')
.trim_matches('\'')
.replace("Some(", "")
.replace(")", "")
} else {
"Unknown".to_string()
};
(Some(ip), Some(ua))
} else {
(None, None)
}
} else {
(None, None)
};
crate::models::AuditLog {
id: e.id,
timestamp: e.timestamp,
username: e.actor,
action: e.action,
object_type: Some(e.object_type),
object_id: Some(e.object_id),
ip_address: ip,
user_agent: ua,
}
})
.collect()
};
let template = crate::templates::UserAuditTemplate {
admin_username: user.username,
logs,
};
template.into_response()
}
+517
View File
@@ -0,0 +1,517 @@
use super::*;
// GET /admin
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
match require_auth(&state, &jar).await {
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
Err(redir) => redir.into_response(),
}
}
// GET /admin/login
pub async fn login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/admin/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/admin/login".to_string(),
title: "Admin Login".to_string(),
subtitle: "Administrative Access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
#[derive(Deserialize)]
pub struct LoginForm {
pub username: String,
pub password: String,
pub csrf_token: String,
}
/// Bootstrap is allowed only when the system has no real admin yet.
pub(crate) fn is_bootstrap_allowed(
user_count: i64,
admin_count: i64,
active_session_count: i64,
) -> bool {
user_count <= 1 && admin_count == 0 && active_session_count == 0
}
fn count_login_bootstrap_state(
conn: &rusqlite::Connection,
) -> Result<(i64, i64, i64), rusqlite::Error> {
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
let a_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
[],
|r| r.get(0),
)?;
let now = Utc::now().to_rfc3339();
let s_count: i64 = conn.query_row(
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
[now],
|r| r.get(0),
)?;
Ok((u_count, a_count, s_count))
}
/// Verify an existing admin tenant user may log into the admin UI.
///
/// Does not log the password. Rejection reasons are structured for observability.
pub(crate) fn verify_admin_credentials(
user: &crate::models::TenantUser,
password: &str,
) -> Result<(), &'static str> {
if user.status != "active" {
return Err("account_disabled");
}
if user.account_type != "admin" {
return Err("insufficient_privileges");
}
if !verify_password(password, &user.password_hash) {
return Err("invalid_credentials");
}
Ok(())
}
fn load_tenant_user_by_username(
conn: &rusqlite::Connection,
username: &str,
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
[username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
},
)
.optional()
}
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
}
}
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
)
}
// POST /admin/login
pub async fn login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let bootstrap_allowed = {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match count_login_bootstrap_state(&conn) {
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
Err(e) => {
tracing::error!(error = %e, "login bootstrap state query failed");
false
}
}
};
let user_opt = if bootstrap_allowed
&& form.username == state.config.admin_username
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
{
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => {
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
}
};
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
Ok(u) => {
if let Err(e) = state.db.init_user_databases(u.id) {
tracing::error!(
"Failed to init user databases during admin bootstrap: {:?}",
e
);
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&u.username,
"BOOTSTRAP_USER_PROVISIONED",
"user",
&u.id.to_string(),
Some(&metadata),
);
}
Some(User {
id: u.id.to_string(),
username: u.username,
password_hash: u.password_hash,
created_at: u.created_at,
})
}
Err(e) => {
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
None
}
}
} else {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
match load_tenant_user_by_username(&conn, &form.username) {
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
Ok(()) => Some(tenant_user_to_admin_user(u)),
Err(reason) => {
tracing::warn!(username = form.username, reason, "login rejected");
None
}
},
Ok(None) => {
tracing::warn!(
username = form.username,
reason = "user_not_found",
"login rejected"
);
None
}
Err(e) => {
tracing::error!(error = %e, "login user lookup failed");
None
}
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
let now = Utc::now().to_rfc3339();
if let Err(e) = conn.execute(
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
rusqlite::params![session_token, user_id_i64, expires, now],
) {
tracing::error!(error = %e, "failed to insert admin session");
return Redirect::to("/admin/login?error=Internal error").into_response();
}
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/admin/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response()
}
None => {
if let Ok(system_conn) = state.system_db.lock() {
let metadata = audit_meta(&ip, &headers);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
}
Redirect::to("/admin/login?error=Invalid username or password").into_response()
}
}
}
// GET /logout
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
let cookie = Cookie::build("bzod_user_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/login")).into_response()
}
// GET /login
pub async fn public_login_get(
State(state): State<AppState>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let error = params.get("error").cloned();
let csrf_token = generate_token(16);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
.path("/login")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::minutes(10))
.build();
let new_jar = jar.add(cookie);
let template = crate::templates::LoginTemplate {
error,
csrf_token,
action: "/login".to_string(),
title: "User Login".to_string(),
subtitle: "Standard account access".to_string(),
button_text: "Sign In".to_string(),
};
(new_jar, template).into_response()
}
// POST /login
pub async fn public_login_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<LoginForm>,
) -> Response {
let temp_csrf = jar
.get("bzod_temp_csrf")
.map(|c| c.value().to_string())
.unwrap_or_default();
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
return Redirect::to("/login?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let user_opt: Option<crate::models::TenantUser> = {
let conn = state.users_db.lock().unwrap();
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE username = ?1;",
[&form.username],
|row| {
Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
}
).optional();
match user_res {
Ok(Some(u)) => {
if u.status != "active" {
None
} else if verify_password(&form.password, &u.password_hash) {
Some(u)
} else {
None
}
}
_ => None,
}
};
match user_opt {
Some(user) => {
let session_token = generate_token(32);
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
{
let conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"USER_LOGIN",
"session",
&session_token,
Some(&metadata),
);
}
let secure_flag =
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build(("bzod_user_session", session_token))
.path("/")
.secure(secure_flag)
.http_only(true)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.max_age(time::Duration::days(30))
.build();
let clear_temp = Cookie::build("bzod_temp_csrf")
.path("/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/user/dashboard")).into_response()
}
None => {
let system_conn = state.system_db.lock().unwrap();
let metadata = format!(
"IP: {:?}, UA: {:?}",
ip,
headers.get("user-agent").and_then(|h| h.to_str().ok())
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"anonymous",
"LOGIN_FAILED",
"login",
"",
Some(&metadata),
);
Redirect::to("/login?error=Invalid username or password").into_response()
}
}
}
// GET /admin/logout
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
}
let cookie = Cookie::build("bzod_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = response_jar.add(cookie);
(response_jar, Redirect::to("/admin/login")).into_response()
}
#[cfg(test)]
mod login_helpers_tests {
use super::is_bootstrap_allowed;
#[test]
fn bootstrap_only_when_no_admin() {
assert!(is_bootstrap_allowed(0, 0, 0));
assert!(is_bootstrap_allowed(1, 0, 0));
assert!(!is_bootstrap_allowed(2, 0, 0));
assert!(!is_bootstrap_allowed(1, 1, 0));
assert!(!is_bootstrap_allowed(1, 0, 1));
}
}
+300
View File
@@ -0,0 +1,300 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupFileRow {
pub filename: String,
pub size_str: String,
pub created_str: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct BackupHistoryRow {
pub id: String,
pub backup_path: String,
pub status: String,
pub created_at: String,
pub size_bytes: i64,
pub error_message: Option<String>,
}
// GET /admin/backups
pub async fn backups_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut files = vec![];
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
for entry in dir_entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(filename) = path
.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
{
if filename.ends_with(".tar.gz") {
let meta = entry.metadata().unwrap();
let size_str = format_size(meta.len());
let created_str = meta
.created()
.ok()
.map(|c| {
let datetime: chrono::DateTime<chrono::Utc> = c.into();
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
})
.unwrap_or_else(|| "-".to_string());
files.push(BackupFileRow {
filename,
size_str,
created_str,
});
}
}
}
}
}
files.sort_by(|a, b| b.filename.cmp(&a.filename));
let history = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(BackupHistoryRow {
id: row.get(0)?,
backup_path: row.get(1)?,
status: row.get(2)?,
created_at: row.get(3)?,
size_bytes: row.get(4)?,
error_message: row.get(5)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::BackupsTemplate {
admin_username: user.username,
files,
history,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/backups/create
pub async fn backups_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (_user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
Ok(path) => {
let filename = std::path::Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("backup.tar.gz");
Redirect::to(&format!(
"/admin/backups?success=Backup created successfully: {}",
filename
))
.into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to generate backup: {}",
e
))
.into_response(),
}
}
// GET /admin/backups/download/:filename
pub async fn backups_download_get(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
) -> Response {
if require_auth(&state, &jar).await.is_err() {
return StatusCode::UNAUTHORIZED.into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return StatusCode::BAD_REQUEST.into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return StatusCode::NOT_FOUND.into_response();
}
match std::fs::read(&backup_path) {
Ok(bytes) => {
let body = axum::body::Body::from(bytes);
Response::builder()
.header("content-type", "application/octet-stream")
.header(
"content-disposition",
format!("attachment; filename=\"{}\"", filename),
)
.body(body)
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
}
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
// POST /admin/backups/delete/:filename
pub async fn backups_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Path(filename): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
}
let backup_path = state.config.backup_dir.join(&filename);
if !backup_path.exists() {
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
}
match std::fs::remove_file(&backup_path) {
Ok(_) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"BACKUP_DELETE",
"backup",
&filename,
None,
);
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/backups?error=Failed to delete backup file: {}",
e
))
.into_response(),
}
}
// POST /admin/backups/restore
pub async fn backups_restore_post(
State(state): State<AppState>,
jar: CookieJar,
mut multipart: axum::extract::Multipart,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut backup_bytes = vec![];
let mut confirm_text = String::new();
let mut csrf_token = String::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or_default().to_string();
if name == "backup_file" {
if let Ok(bytes) = field.bytes().await {
backup_bytes = bytes.to_vec();
}
} else if name == "confirm_text" {
if let Ok(text) = field.text().await {
confirm_text = text.trim().to_string();
}
} else if name == "csrf_token" {
if let Ok(text) = field.text().await {
csrf_token = text.trim().to_string();
}
}
}
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
}
if confirm_text != "RESTORE" {
return Redirect::to(
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
)
.into_response();
}
if backup_bytes.is_empty() {
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
.into_response();
}
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
return Redirect::to(&format!(
"/admin/backups?error=Failed to save uploaded file: {}",
e
))
.into_response();
}
match crate::cli::restore::run(
temp_file_path.to_string_lossy().to_string(),
None,
state.config.clone(),
)
.await
{
Ok(_) => {
let _ = std::fs::remove_file(&temp_file_path);
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions;", []);
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"RESTORE_EXECUTION",
"backup",
"upload",
None,
);
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
.into_response()
}
Err(e) => {
let _ = std::fs::remove_file(&temp_file_path);
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
}
}
}
+170
View File
@@ -0,0 +1,170 @@
use super::*;
// GET /user/dashboard
pub async fn user_dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (total_urls, active_links, dead_links) = {
let conn = user_dbs.content.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0))
};
let total_pages = {
let conn = user_dbs.content.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0)
};
let total_clicks = {
let conn = user_dbs.analytics.lock().unwrap();
get_total_clicks(&conn).unwrap_or(0)
};
let clicks_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_clicks_trend(&conn, "url", "all", 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
};
let mut trend_map = std::collections::BTreeMap::new();
for i in (0..30).rev() {
let date_str = (Utc::now() - chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string();
trend_map.insert(date_str, 0i64);
}
for (d, c) in clicks_data {
trend_map.insert(d, c);
}
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
let traffic_chart = generate_line_chart(&formatted_trend);
let countries_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "country", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
.unwrap_or_default()
};
let countries_chart = generate_bar_chart(&countries_data);
let referrers_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "referrer", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
.unwrap_or_default()
};
let referrers_chart = generate_bar_chart(&referrers_data);
let browsers_data = {
let conn = user_dbs.analytics.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "browser", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
.unwrap_or_default()
};
let browsers_chart = generate_bar_chart(&browsers_data);
let template = crate::templates::UserDashboardTemplate {
admin_username: user.username,
total_urls,
total_pages,
total_clicks,
active_links,
dead_links,
traffic_chart,
countries_chart,
browsers_chart,
referrers_chart,
};
template.into_response()
}
// GET /admin/dashboard
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
let (user, _) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (total_urls, active_links, dead_links) = {
let conn = state.content_db.lock().unwrap();
get_url_counts(&conn).unwrap_or((0, 0, 0))
};
let total_pages = {
let conn = state.content_db.lock().unwrap();
get_landing_page_count(&conn).unwrap_or(0)
};
let total_clicks = {
let conn = state.analytics_db.lock().unwrap();
get_total_clicks(&conn).unwrap_or(0)
};
let clicks_data = {
let conn = state.analytics_db.lock().unwrap();
get_clicks_trend(&conn, "url", "all", 30)
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
.unwrap_or_default()
};
let mut trend_map = std::collections::BTreeMap::new();
for i in (0..30).rev() {
let date_str = (Utc::now() - chrono::Duration::days(i))
.format("%Y-%m-%d")
.to_string();
trend_map.insert(date_str, 0i64);
}
for (d, c) in clicks_data {
trend_map.insert(d, c);
}
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
let traffic_chart = generate_line_chart(&formatted_trend);
let countries_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "country", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
.unwrap_or_default()
};
let countries_chart = generate_bar_chart(&countries_data);
let referrers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "referrer", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
.unwrap_or_default()
};
let referrers_chart = generate_bar_chart(&referrers_data);
let browsers_data = {
let conn = state.analytics_db.lock().unwrap();
get_metric_rankings(&conn, "url", "all", "browser", 5)
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
.unwrap_or_default()
};
let browsers_chart = generate_bar_chart(&browsers_data);
let template = crate::templates::DashboardTemplate {
admin_username: user.username,
total_urls,
total_pages,
total_clicks,
active_links,
dead_links,
traffic_chart,
countries_chart,
browsers_chart,
referrers_chart,
};
template.into_response()
}
+169
View File
@@ -0,0 +1,169 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct JobHistoryRow {
pub id: String,
pub job_name: String,
pub status: String,
pub started_at: String,
pub finished_at: Option<String>,
pub error_message: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct HealthCheckRow {
pub id: String,
pub object_type: String,
pub object_id: String,
pub checked_at: String,
pub status_code: Option<i64>,
pub error_message: Option<String>,
pub is_healthy: i64,
}
// GET /admin/health
pub async fn health_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let mut db_reports = vec![];
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin")
{
db_reports.push(r);
}
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system")
{
db_reports.push(r);
}
if let Ok(r) =
crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users")
{
db_reports.push(r);
}
let system_db_path = state.config.data_dir.join("admin").join("system.db");
let users_db_path = state.config.data_dir.join("admin").join("users.db");
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
let system_db_size = format_size(
std::fs::metadata(&system_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let users_db_size = format_size(
std::fs::metadata(&users_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let admin_db_size = format_size(
std::fs::metadata(&admin_db_path)
.map(|m| m.len())
.unwrap_or(0),
);
let users_dir = state.config.data_dir.join("users");
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
let job_history = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(JobHistoryRow {
id: row.get(0)?,
job_name: row.get(1)?,
status: row.get(2)?,
started_at: row.get(3)?,
finished_at: row.get(4)?,
error_message: row.get(5)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let health_checks = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(HealthCheckRow {
id: row.get(0)?,
object_type: row.get(1)?,
object_id: row.get(2)?,
checked_at: row.get(3)?,
status_code: row.get(4)?,
error_message: row.get(5)?,
is_healthy: row.get(6)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let (registry_errors, registry_warnings) = {
let system_conn = state.system_db.lock().unwrap();
let users_conn = state.users_db.lock().unwrap();
match crate::services::registry_validator::RegistryValidator::scan(
&system_conn,
&users_conn,
&state.config.data_dir,
None,
) {
Ok(issues) => {
let mut errors = Vec::new();
let mut warnings = Vec::new();
for issue in issues {
use crate::services::registry_validator::RegistryIssueType;
match issue.issue_type {
RegistryIssueType::StaleReservation
| RegistryIssueType::TenantAdminHasIsolatedContent => {
warnings.push(format!(
"Warning for slug {}: {}",
issue.slug, issue.description
));
}
_ => {
errors.push(format!(
"Error for slug {}: {}",
issue.slug, issue.description
));
}
}
}
(errors, warnings)
}
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
}
};
let template = crate::templates::HealthTemplate {
admin_username: user.username,
db_reports,
total_data_size,
system_db_size,
users_db_size,
admin_db_size,
tenants_db_size,
job_history,
health_checks,
registry_errors,
registry_warnings,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
+849
View File
@@ -0,0 +1,849 @@
//! Admin web UI handlers (feature-split modules).
//!
//! Handlers are organized by domain; shared auth, audit, export, and helpers
//! live in this module root so child modules can access them via `super`.
use crate::auth::{
authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token,
hash_password, verify_csrf, verify_password, verify_sha256,
};
use crate::charts::{generate_bar_chart, generate_line_chart};
use crate::db::admin::{
create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config,
write_audit_log as write_audit_log_legacy,
};
use crate::db::analytics::{
clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings,
get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors,
get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated,
get_total_clicks, get_visits_schema_columns, parse_ua,
};
use crate::db::content::{
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts,
list_landing_pages, list_urls,
};
use crate::models::User;
use crate::state::AppState;
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
use axum::{
extract::{ConnectInfo, Path, Query, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Redirect, Response},
Form,
};
use axum_extra::extract::cookie::Cookie;
use axum_extra::extract::CookieJar;
use chrono::Utc;
use flate2::read::GzDecoder;
use flate2::write::GzEncoder;
use flate2::Compression;
use rusqlite::{params, OptionalExtension};
use serde::Deserialize;
use std::collections::HashMap;
use std::fs::File;
use std::net::SocketAddr;
use tar::Builder;
use uuid::Uuid;
// --- Shared constants, auth, audit, and export helpers ---
#[allow(clippy::too_many_arguments)]
pub(crate) fn write_audit_log(
conn: &rusqlite::Connection,
state: &AppState,
username: &str,
action: &str,
object_type: Option<&str>,
object_id: Option<&str>,
ip_address: Option<&str>,
user_agent: Option<&str>,
) -> rusqlite::Result<crate::models::AuditLog> {
let res = write_audit_log_legacy(
conn,
username,
action,
object_type,
object_id,
ip_address,
user_agent,
);
// Also write to unified audit events in system.db
let system_conn = state.system_db.lock().unwrap();
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
username,
action,
object_type.unwrap_or(""),
object_id.unwrap_or(""),
Some(&metadata),
);
res
}
pub(crate) const PAGE_SIZE: usize = 25;
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
// Helper: Verify admin session and return user or redirect to login
pub(crate) async fn require_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(User, String), Redirect> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/admin/login")),
};
match authenticate_admin_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/admin/login")),
}
}
// Helper: Verify tenant user session and return user or redirect to login
pub(crate) async fn require_user_auth(
state: &AppState,
jar: &CookieJar,
) -> Result<(crate::models::TenantUser, String), Redirect> {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => return Err(Redirect::to("/login")),
};
match authenticate_user_session(&conn, jar) {
Ok(Some((user, session_id))) => Ok((user, session_id)),
_ => Err(Redirect::to("/login")),
}
}
#[derive(Deserialize)]
pub struct AnalyticsQuery {
pub analytics_page: Option<usize>,
pub date_from: Option<String>,
pub date_to: Option<String>,
}
pub(crate) fn validate_date_filters(
date_from: Option<&str>,
date_to: Option<&str>,
) -> Result<(Option<String>, Option<String>), StatusCode> {
let from_parsed = match date_from {
Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") {
Ok(d) => Some(d),
Err(_) => return Err(StatusCode::BAD_REQUEST),
},
_ => None,
};
let to_parsed = match date_to {
Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
Ok(d) => Some(d),
Err(_) => return Err(StatusCode::BAD_REQUEST),
},
_ => None,
};
if let (Some(f), Some(t)) = (from_parsed, to_parsed) {
if f > t {
return Err(StatusCode::BAD_REQUEST);
}
}
Ok((
from_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
to_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
))
}
pub(crate) fn escape_csv_field(field: &str) -> String {
let needs_escaping =
field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r');
if needs_escaping {
let escaped = field.replace('"', "\"\"");
format!("\"{}\"", escaped)
} else {
field.to_string()
}
}
pub(crate) struct DbExportStream {
receiver: tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::convert::Infallible>>,
}
impl futures_util::stream::Stream for DbExportStream {
type Item = Result<axum::body::Bytes, std::convert::Infallible>;
fn poll_next(
mut self: std::pin::Pin<&mut Self>,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Option<Self::Item>> {
self.receiver.poll_recv(cx)
}
}
pub(crate) async fn perform_csv_export(
state: AppState,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = state.content_db.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = state.analytics_db.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
let (has_utm_source, has_utm_campaign) = {
let conn = state.analytics_db.lock().unwrap();
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
(cols.contains("utm_source"), cols.contains("utm_campaign"))
};
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
let analytics_db = state.analytics_db.clone();
let target_id = id.clone();
tokio::task::spawn_blocking(move || {
let conn = analytics_db.lock().unwrap();
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
if has_utm_source {
header.push_str(",UTM Source");
}
if has_utm_campaign {
header.push_str(",UTM Campaign");
}
header.push('\n');
if tx
.blocking_send(Ok(axum::body::Bytes::from(header)))
.is_err()
{
return;
}
let select_fields = if has_utm_source && has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
} else if has_utm_source {
"timestamp, ip_address, country, referer, user_agent, utm_source"
} else if has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
} else {
"timestamp, ip_address, country, referer, user_agent"
};
let mut sql = format!(
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
select_fields
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(target_type.to_string()), Box::new(target_id)];
if let Some(df) = clean_date_from.as_deref() {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = clean_date_to.as_deref() {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return,
};
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
Ok(r) => r,
Err(_) => return,
};
let mut csv_buffer = String::new();
while let Ok(Some(row)) = rows.next() {
let timestamp: String = row.get(0).unwrap_or_default();
let ip_address: String = row.get(1).unwrap_or_default();
let country: String = row.get(2).unwrap_or_default();
let referer: String = row.get(3).unwrap_or_default();
let user_agent: String = row.get(4).unwrap_or_default();
let (browser, _, _) = parse_ua(&user_agent);
let referrer = clean_referrer(&referer);
let country_display = if country.is_empty() {
"Unknown".to_string()
} else {
country
};
let mut line = format!(
"{},{},{},{},{},{}",
escape_csv_field(&timestamp),
escape_csv_field(&ip_address),
escape_csv_field(&country_display),
escape_csv_field(&referrer),
escape_csv_field(&browser),
escape_csv_field(&user_agent)
);
let mut col_idx = 5;
if has_utm_source {
let utm_src: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
col_idx += 1;
}
if has_utm_campaign {
let utm_camp: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
}
line.push('\n');
csv_buffer.push_str(&line);
if csv_buffer.len() >= 8192 {
let bytes = axum::body::Bytes::from(csv_buffer);
if tx.blocking_send(Ok(bytes)).is_err() {
return;
}
csv_buffer = String::new();
}
}
if !csv_buffer.is_empty() {
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
}
});
let stream = DbExportStream { receiver: rx };
let filename = if target_type == "url" {
format!("url_{}_analytics.csv", id)
} else {
format!("page_{}_analytics.csv", id)
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
axum::body::Body::from_stream(stream),
)
.into_response()
}
pub(crate) async fn perform_json_export(
state: AppState,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = state.content_db.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = state.analytics_db.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
if count > MAX_JSON_EXPORT_ROWS as i64 {
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
}
let visits_raw = {
let conn = state.analytics_db.lock().unwrap();
match get_target_visits_all_in_memory(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
) {
Ok(v) => v,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
#[derive(serde::Serialize)]
struct JsonExportRow {
timestamp: String,
ip_address: String,
country: String,
referrer: String,
browser: String,
user_agent: String,
}
let export_rows: Vec<JsonExportRow> = visits_raw
.into_iter()
.map(|r| {
let (browser, _, _) = parse_ua(&r.user_agent);
let referrer = clean_referrer(&r.referer);
let country_display = if r.country.is_empty() {
"Unknown".to_string()
} else {
r.country
};
JsonExportRow {
timestamp: r.timestamp,
ip_address: r.ip_address,
country: country_display,
referrer,
browser,
user_agent: r.user_agent,
}
})
.collect();
let body_str = match serde_json::to_string(&export_rows) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
}
};
let filename = if target_type == "url" {
format!("url_{}_analytics.json", id)
} else {
format!("page_{}_analytics.json", id)
};
(
StatusCode::OK,
[
("Content-Type", "application/json"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
body_str,
)
.into_response()
}
// Helpers
pub(crate) fn format_size(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.2} KB", bytes as f64 / 1024.0)
} else {
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
}
}
pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
let mut total = 0;
if dir.is_dir() {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
total += get_dir_size(&path)?;
} else {
total += entry.metadata()?.len();
}
}
}
Ok(total)
}
pub(crate) async fn perform_user_csv_export(
user_dbs: crate::state::UserDbs,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = user_dbs.content.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
let (has_utm_source, has_utm_campaign) = {
let conn = user_dbs.analytics.lock().unwrap();
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
(cols.contains("utm_source"), cols.contains("utm_campaign"))
};
let (tx, rx) =
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
let analytics_db = user_dbs.analytics.clone();
let target_id = id.clone();
tokio::task::spawn_blocking(move || {
let conn = analytics_db.lock().unwrap();
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
if has_utm_source {
header.push_str(",UTM Source");
}
if has_utm_campaign {
header.push_str(",UTM Campaign");
}
header.push('\n');
if tx
.blocking_send(Ok(axum::body::Bytes::from(header)))
.is_err()
{
return;
}
let select_fields = if has_utm_source && has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
} else if has_utm_source {
"timestamp, ip_address, country, referer, user_agent, utm_source"
} else if has_utm_campaign {
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
} else {
"timestamp, ip_address, country, referer, user_agent"
};
let mut sql = format!(
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
select_fields
);
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
vec![Box::new(target_type.to_string()), Box::new(target_id)];
if let Some(df) = clean_date_from.as_deref() {
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
params.push(Box::new(format!("{}T00:00:00Z", df)));
}
if let Some(dt) = clean_date_to.as_deref() {
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
let next_day = parsed_date + chrono::Duration::days(1);
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
params.push(Box::new(format!(
"{}T00:00:00Z",
next_day.format("%Y-%m-%d")
)));
}
}
sql.push_str(" ORDER BY timestamp DESC, id DESC");
let mut stmt = match conn.prepare(&sql) {
Ok(s) => s,
Err(_) => return,
};
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
Ok(r) => r,
Err(_) => return,
};
let mut csv_buffer = String::new();
while let Ok(Some(row)) = rows.next() {
let timestamp: String = row.get(0).unwrap_or_default();
let ip_address: String = row.get(1).unwrap_or_default();
let country: String = row.get(2).unwrap_or_default();
let referer: String = row.get(3).unwrap_or_default();
let user_agent: String = row.get(4).unwrap_or_default();
let (browser, _, _) = parse_ua(&user_agent);
let referrer = clean_referrer(&referer);
let country_display = if country.is_empty() {
"Unknown".to_string()
} else {
country
};
let mut line = format!(
"{},{},{},{},{},{}",
escape_csv_field(&timestamp),
escape_csv_field(&ip_address),
escape_csv_field(&country_display),
escape_csv_field(&referrer),
escape_csv_field(&browser),
escape_csv_field(&user_agent)
);
let mut col_idx = 5;
if has_utm_source {
let utm_src: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
col_idx += 1;
}
if has_utm_campaign {
let utm_camp: String = row.get(col_idx).unwrap_or_default();
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
}
line.push('\n');
csv_buffer.push_str(&line);
if csv_buffer.len() >= 8192 {
let bytes = axum::body::Bytes::from(csv_buffer);
if tx.blocking_send(Ok(bytes)).is_err() {
return;
}
csv_buffer = String::new();
}
}
if !csv_buffer.is_empty() {
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
}
});
let stream = DbExportStream { receiver: rx };
let filename = if target_type == "url" {
format!("url_{}_analytics.csv", id)
} else {
format!("page_{}_analytics.csv", id)
};
(
StatusCode::OK,
[
("Content-Type", "text/csv"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
axum::body::Body::from_stream(stream),
)
.into_response()
}
pub(crate) async fn perform_user_json_export(
user_dbs: crate::state::UserDbs,
target_type: &'static str,
id: String,
date_from: Option<String>,
date_to: Option<String>,
) -> Response {
let (clean_date_from, clean_date_to) =
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
Ok(res) => res,
Err(status) => return status.into_response(),
};
let target_exists = {
let conn = user_dbs.content.lock().unwrap();
if target_type == "url" {
get_url_by_id(&conn, &id)
.map(|u| u.is_some())
.unwrap_or(false)
} else {
get_landing_page_by_id(&conn, &id)
.map(|p| p.is_some())
.unwrap_or(false)
}
};
if !target_exists {
return (StatusCode::NOT_FOUND, "Target not found").into_response();
}
let count = {
let conn = user_dbs.analytics.lock().unwrap();
get_target_visit_total_filtered(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
)
.unwrap_or(0)
};
if count > MAX_JSON_EXPORT_ROWS as i64 {
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
}
let visits_raw = {
let conn = user_dbs.analytics.lock().unwrap();
match get_target_visits_all_in_memory(
&conn,
target_type,
&id,
clean_date_from.as_deref(),
clean_date_to.as_deref(),
) {
Ok(v) => v,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
};
#[derive(serde::Serialize)]
struct JsonExportRow {
timestamp: String,
ip_address: String,
country: String,
referrer: String,
browser: String,
user_agent: String,
}
let export_rows: Vec<JsonExportRow> = visits_raw
.into_iter()
.map(|r| {
let (browser, _, _) = parse_ua(&r.user_agent);
let referrer = clean_referrer(&r.referer);
let country_display = if r.country.is_empty() {
"Unknown".to_string()
} else {
r.country
};
JsonExportRow {
timestamp: r.timestamp,
ip_address: r.ip_address,
country: country_display,
referrer,
browser,
user_agent: r.user_agent,
}
})
.collect();
let body_str = match serde_json::to_string(&export_rows) {
Ok(s) => s,
Err(_) => {
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
}
};
let filename = if target_type == "url" {
format!("url_{}_analytics.json", id)
} else {
format!("page_{}_analytics.json", id)
};
(
StatusCode::OK,
[
("Content-Type", "application/json"),
(
"Content-Disposition",
&format!("attachment; filename=\"{}\"", filename),
),
("X-BZOD-Export-Records", &count.to_string()),
],
body_str,
)
.into_response()
}
// --- Feature modules ---
mod auth;
pub use auth::*;
mod dashboard;
pub use dashboard::*;
mod urls;
pub use urls::*;
mod pages;
pub use pages::*;
mod users;
pub use users::*;
mod analytics;
pub use analytics::*;
mod settings;
pub use settings::*;
mod audit;
pub use audit::*;
mod sessions;
pub use sessions::*;
mod quotas;
pub use quotas::*;
mod health;
pub use health::*;
mod backups;
pub use backups::*;
mod api_keys;
pub use api_keys::*;
mod moderation;
pub use moderation::*;
+639
View File
@@ -0,0 +1,639 @@
use super::*;
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct GlobalSlugRow {
pub slug: String,
pub owner_user_id: i64,
pub target_type: String,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub deleted_at: Option<String>,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ModerationLogEntry {
pub id: String,
pub timestamp: String,
pub admin_username: String,
pub target_user_id: i64,
pub target_username: Option<String>,
pub resource_type: String,
pub resource_identifier: String,
pub action: String,
pub severity: String,
pub reason: String,
}
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct SlugHistoryRow {
pub id: i64,
pub slug: String,
pub old_owner_user_id: Option<i64>,
pub new_owner_user_id: Option<i64>,
pub action: String,
pub timestamp: String,
pub admin_username: Option<String>,
}
// GET /admin/moderation
pub async fn moderation_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let flagged_items = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(GlobalSlugRow {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let logs = {
let conn = state.system_db.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(ModerationLogEntry {
id: row.get(0)?,
timestamp: row.get(1)?,
admin_username: row.get(2)?,
target_user_id: row.get(3)?,
target_username: row.get(4)?,
resource_type: row.get(5)?,
resource_identifier: row.get(6)?,
action: row.get(7)?,
severity: row.get(8)?,
reason: row.get(9)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::ModerationTemplate {
admin_username: user.username,
flagged_items,
logs,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminModerateForm {
pub slug: String,
pub action: String,
pub severity: String,
pub reason: String,
pub csrf_token: String,
}
// POST /admin/moderation
pub async fn moderation_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminModerateForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response();
}
let action = form.action.trim().to_lowercase();
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
}
let (owner_user_id, target_type) = {
let system_conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()
.unwrap_or(None);
match row_opt {
Some(r) => r,
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
}
};
let owner_username = {
let users_conn = state.users_db.lock().unwrap();
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
.unwrap_or(None)
.map(|u| u.username)
};
{
let system_conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
if action == "deleted" {
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
} else {
let _ = system_conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![action, now, form.slug],
);
}
let event_id = Uuid::new_v4().to_string();
let _ = system_conn.execute(
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
rusqlite::params![
event_id,
now,
user.username,
owner_user_id,
owner_username,
target_type,
form.slug,
action,
form.severity,
form.reason
],
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"CONTENT_MODERATION",
"slug",
&form.slug,
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
);
}
Redirect::to(&format!(
"/admin/moderation?success=Moderation action '{}' applied",
action
))
.into_response()
}
#[derive(Deserialize)]
pub struct SlugsQuery {
pub search: Option<String>,
pub owner: Option<i64>,
pub status: Option<String>,
pub success: Option<String>,
pub error: Option<String>,
}
// GET /admin/slugs
pub async fn slugs_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<SlugsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let system_conn = state.system_db.lock().unwrap();
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
let mut values = vec![];
if let Some(ref s) = query.search {
if !s.trim().is_empty() {
sql.push_str(" AND slug LIKE ?");
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
}
}
if let Some(o) = query.owner {
sql.push_str(" AND owner_user_id = ?");
values.push(rusqlite::types::Value::Integer(o));
}
if let Some(ref st) = query.status {
if !st.trim().is_empty() {
sql.push_str(" AND status = ?");
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
}
}
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
let slugs = {
let mut stmt = system_conn.prepare(&sql).unwrap();
let rows = stmt
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
Ok(GlobalSlugRow {
slug: row.get(0)?,
owner_user_id: row.get(1)?,
target_type: row.get(2)?,
target_id: row.get(3)?,
created_at: row.get(4)?,
updated_at: row.get(5)?,
status: row.get(6)?,
deleted_at: row.get(7)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let history = {
let mut stmt = system_conn.prepare(
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
).unwrap();
let rows = stmt
.query_map([], |row| {
Ok(SlugHistoryRow {
id: row.get(0)?,
slug: row.get(1)?,
old_owner_user_id: row.get(2)?,
new_owner_user_id: row.get(3)?,
action: row.get(4)?,
timestamp: row.get(5)?,
admin_username: row.get(6)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SlugsTemplate {
admin_username: user.username,
slugs,
history,
csrf_token,
search_filter: query.search,
owner_filter: query.owner,
status_filter: query.status,
success: query.success,
error: query.error,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct AdminTransferForm {
pub slug: String,
pub new_owner_user_id: i64,
pub csrf_token: String,
}
// POST /admin/slugs/transfer
pub async fn slugs_transfer_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminTransferForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
let user_exists = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[form.new_owner_user_id],
|row| row.get::<_, bool>(0),
)
.unwrap_or(false)
};
if !user_exists {
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
}
let (old_owner, target_type, mut new_target_id) =
{
let conn = state.system_db.lock().unwrap();
let row_opt: Option<(i64, String, String)> = conn.query_row(
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
).optional().unwrap_or(None);
match row_opt {
Some(r) => r,
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
}
};
if old_owner == form.new_owner_user_id {
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
.into_response();
}
let old_dbs = match state.get_user_dbs(old_owner) {
Ok(dbs) => dbs,
Err(_) => {
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
.into_response()
}
};
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
Ok(dbs) => dbs,
Err(_) => {
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
.into_response()
}
};
{
let old_conn = old_dbs.content.lock().unwrap();
let new_conn = new_dbs.content.lock().unwrap();
if target_type == "url" {
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
Ok(u) => u,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to retrieve old URL: {}",
e
))
.into_response()
}
};
if let Some(url) = url_opt {
// Check quota
let users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_urls >= quota.max_urls {
return Redirect::to(
"/admin/slugs?error=New owner has exceeded URL quota limit",
)
.into_response();
}
}
// Copy
let new_url = match crate::db::content::create_url_extended(
&new_conn,
&url.code,
&url.destination,
url.title.as_deref(),
url.description.as_deref(),
&url.tags,
url.expires_at.as_deref(),
url.password_hash.as_deref(),
url.max_access_count,
) {
Ok(u) => u,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to copy URL record: {}",
e
))
.into_response()
}
};
new_target_id = new_url.id;
// Delete old
let _ = crate::db::content::delete_url(&old_conn, &url.id);
}
} else if target_type == "page" {
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
{
Ok(p) => p,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to retrieve old page: {}",
e
))
.into_response()
}
};
if let Some(page) = page_opt {
// Check quota
let users_conn = state.users_db.lock().unwrap();
let quota_opt =
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
.unwrap_or(None);
if let Some(quota) = quota_opt {
if quota.current_landings >= quota.max_landings {
return Redirect::to(
"/admin/slugs?error=New owner has exceeded landing page quota limit",
)
.into_response();
}
}
// Copy
let new_page = match crate::db::content::create_landing_page(
&new_conn,
&page.code,
&page.slug,
&page.title,
&page.html_content,
&page.state,
) {
Ok(p) => p,
Err(e) => {
return Redirect::to(&format!(
"/admin/slugs?error=Failed to copy page record: {}",
e
))
.into_response()
}
};
new_target_id = new_page.id;
// Delete old
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
}
}
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
);
let _ = conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_TRANSFER",
"slug",
&form.slug,
Some(&format!(
"Transferred from {} to {}",
old_owner, form.new_owner_user_id
)),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
form.slug, form.new_owner_user_id
))
.into_response()
}
#[derive(Deserialize)]
pub struct AdminSlugStatusForm {
pub slug: String,
pub status: String,
pub csrf_token: String,
}
// POST /admin/slugs/status
pub async fn slugs_status_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugStatusForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
let status = form.status.trim().to_lowercase();
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let _ = conn.execute(
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![status, now, form.slug],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_STATUS_UPDATE",
"slug",
&form.slug,
Some(&format!("Status set to {}", status)),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} status updated to {}",
form.slug, status
))
.into_response()
}
#[derive(Deserialize)]
pub struct AdminSlugDeleteForm {
pub slug: String,
pub csrf_token: String,
}
// POST /admin/slugs/delete
pub async fn slugs_delete_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<AdminSlugDeleteForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
}
{
let conn = state.system_db.lock().unwrap();
let now = Utc::now().to_rfc3339();
let old_owner_user_id: Option<i64> = conn
.query_row(
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
[&form.slug],
|row| row.get(0),
)
.optional()
.unwrap_or(None);
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
let _ = conn.execute(
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
);
let _ = crate::db::audit_events::write_audit_event(
&conn,
&user.username,
"SLUG_RELEASE",
"slug",
&form.slug,
Some("Slug released/deleted from global index"),
);
}
Redirect::to(&format!(
"/admin/slugs?success=Slug /{} released successfully",
form.slug
))
.into_response()
}
+484
View File
@@ -0,0 +1,484 @@
use super::*;
#[derive(Deserialize)]
pub struct UserPagesQuery {
pub error: Option<String>,
pub page: Option<usize>,
}
#[derive(Deserialize)]
pub struct CreateUserPageForm {
pub title: String,
pub slug: String,
pub code: String,
pub custom_slug: String,
pub state: String,
pub html_content: String,
pub csrf_token: String,
}
pub async fn user_pages_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UserPagesQuery>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (pages, total_pages, page, visible_pages) = {
let conn = user_dbs.content.lock().unwrap();
let total_records = get_landing_page_count(&conn).unwrap_or(0);
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(pages, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserPagesTemplate {
admin_username: user.username.clone(),
username: user.username,
pages,
csrf_token,
error: query.error,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
pub async fn user_pages_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUserPageForm>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
}
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(2);
} else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters")
.into_response();
}
} else if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to(
"/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
)
.into_response();
}
let clean_slug = form.slug.trim().to_lowercase();
if clean_slug.is_empty() {
return Redirect::to("/user/pages?error=Slug is required").into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) {
return Redirect::to("/user/pages?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/user/pages?error=Short code/slug already exists")
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
user.id,
"page",
"",
"reserving",
) {
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = user_dbs.content.lock().unwrap();
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if form.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings");
}
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/pages").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
}
}
}
pub async fn user_pages_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
}
let conn = user_dbs.content.lock().unwrap();
match get_landing_page_by_id(&conn, &id) {
Ok(Some(page)) => {
let _ = crate::db::users::decrement_quota_counter(
&state.users_db.lock().unwrap(),
user.id,
"landings",
);
match delete_landing_page(&conn, &id) {
Ok(_) => {
let _ = crate::db::users::release_global_slug(
&state.system_db.lock().unwrap(),
&page.code,
user.id,
);
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_PAGE_DELETION",
Some("page"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/pages").into_response()
}
Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
_ => Redirect::to("/user/pages?error=Page not found").into_response(),
}
}
// GET /admin/pages
#[derive(Deserialize)]
pub struct PagesQuery {
pub error: Option<String>,
pub page: Option<usize>,
}
pub async fn pages_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<PagesQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (pages, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap();
let total_records = get_landing_page_count(&conn).unwrap_or(0);
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(pages, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::PagesTemplate {
admin_username: user.username,
pages,
csrf_token,
error: query.error,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreatePageForm {
pub title: String,
pub slug: String,
pub code: String,
pub custom_slug: String,
pub state: String,
pub html_content: String,
pub csrf_token: String,
}
// POST /admin/pages/create
pub async fn pages_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreatePageForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(2);
} else {
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/pages?error=Custom code must be exactly 4 hex characters",
)
.into_response();
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let clean_slug = form.slug.trim().to_lowercase();
if clean_slug.is_empty() {
return Redirect::to("/admin/pages?error=Slug is required").into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
.unwrap_or(false)
{
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
{
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
create_landing_page(
&conn,
&code,
&clean_slug,
&form.title,
&form.html_content,
&form.state,
)
};
match res {
Ok(page) => {
{
let system_conn = state.system_db.lock().unwrap();
let global_status = if form.state == "published" {
"active"
} else {
"disabled"
};
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(
&users_conn,
admin_user_id,
"landings",
);
}
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_CREATION",
Some("page"),
Some(&page.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/pages/delete/:id
pub async fn pages_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap();
match delete_landing_page(&conn, &id) {
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"PAGE_DELETION",
Some("page"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/pages").into_response()
}
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
.into_response(),
}
}
+117
View File
@@ -0,0 +1,117 @@
use super::*;
// GET /admin/quotas
pub async fn quotas_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let quotas = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(crate::models::UserQuotas {
user_id: row.get(0)?,
max_urls: row.get(1)?,
max_landings: row.get(2)?,
max_api_tokens: row.get(3)?,
max_storage_mb: row.get(4)?,
current_urls: row.get(5)?,
current_landings: row.get(6)?,
current_api_tokens: row.get(7)?,
current_storage_mb: row.get(8)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::QuotasTemplate {
admin_username: user.username,
quotas,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/quotas
pub async fn quotas_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response();
}
let action = form.get("action").cloned().unwrap_or_default();
let users_conn = state.users_db.lock().unwrap();
if action == "reconcile_all" {
let user_ids: Vec<i64> = {
let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap();
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
rows.filter_map(|r| r.ok()).collect()
};
for uid in user_ids {
if let Ok(user_dbs) = state.get_user_dbs(uid) {
let user_content_conn = user_dbs.content.lock().unwrap();
let _ =
crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
}
}
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"QUOTAS_RECONCILE_ALL",
"quota",
"all",
None,
);
Redirect::to("/admin/quotas?success=All user quotas reconciled successfully")
.into_response()
} else if action == "reconcile" {
let uid_str = form.get("user_id").cloned().unwrap_or_default();
let uid = uid_str.parse::<i64>().unwrap_or(0);
if let Ok(user_dbs) = state.get_user_dbs(uid) {
let user_content_conn = user_dbs.content.lock().unwrap();
let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
&user.username,
"QUOTAS_RECONCILE",
"quota",
&uid.to_string(),
None,
);
Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response()
} else {
Redirect::to("/admin/quotas?error=User databases not found").into_response()
}
} else {
Redirect::to("/admin/quotas?error=Invalid action").into_response()
}
}
+114
View File
@@ -0,0 +1,114 @@
use super::*;
// GET /admin/sessions
pub async fn sessions_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let sessions = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap();
let rows = stmt
.query_map([], |row| {
Ok(crate::models::UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::SessionsTemplate {
admin_username: user.username,
sessions,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// POST /admin/sessions/revoke/:id
pub async fn sessions_revoke_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"SESSION_REVOKED",
"session",
&id,
None,
);
}
Redirect::to("/admin/sessions?success=Session revoked").into_response()
}
// POST /admin/sessions/revoke-all
pub async fn sessions_revoke_all_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &form_csrf) {
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
}
{
let conn = state.users_db.lock().unwrap();
let _ = conn.execute("DELETE FROM sessions;", []);
}
{
let conn_sys = state.system_db.lock().unwrap();
let _ = crate::db::audit_events::write_audit_event(
&conn_sys,
&user.username,
"SESSIONS_ALL_REVOKED",
"session",
"all",
None,
);
}
Redirect::to("/admin/sessions?success=All active sessions revoked").into_response()
}
File diff suppressed because it is too large. Load diff
+639
View File
@@ -0,0 +1,639 @@
use super::*;
#[derive(Deserialize)]
pub struct UserUrlsQuery {
pub tag: Option<String>,
pub error: Option<String>,
pub page: Option<usize>,
}
#[derive(Deserialize)]
pub struct CreateUserUrlForm {
pub destination: String,
#[serde(default)]
pub code: String,
#[serde(default)]
pub custom_slug: String,
#[serde(default)]
pub title: String,
#[serde(default)]
pub description: String,
#[serde(default)]
pub tags: String,
pub csrf_token: String,
#[serde(default)]
pub expires_at: String,
#[serde(default)]
pub password: String,
#[serde(default)]
pub max_access_count: String,
#[serde(default)]
pub utm_source: String,
#[serde(default)]
pub utm_medium: String,
#[serde(default)]
pub utm_campaign: String,
}
pub async fn user_urls_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UserUrlsQuery>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let (urls, total_pages, page, visible_pages) = {
let conn = user_dbs.content.lock().unwrap();
let total_records = if let Some(tag_str) = query.tag.as_deref() {
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
} else {
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
};
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
.unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(urls, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
let template = crate::templates::UserUrlsTemplate {
admin_username: user.username.clone(),
username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
base_url,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
pub async fn user_urls_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUserUrlForm>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
}
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let ip = get_client_ip(&headers, connect_info);
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters")
.into_response();
}
} else if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to(
"/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
)
.into_response();
}
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) {
return Redirect::to("/user/urls?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
user.id,
"url",
"",
"reserving",
) {
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let dest = match crate::services::urls::prepare_destination(
&form.destination,
crate::services::urls::UtmParams {
source: Some(&form.utm_source),
medium: Some(&form.utm_medium),
campaign: Some(&form.utm_campaign),
},
) {
Ok(d) => d,
Err(msg) => {
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
}
};
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
let res = {
let conn = user_dbs.content.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls");
}
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/urls").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
}
}
}
pub async fn user_urls_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_user_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let user_dbs = match state.get_user_dbs(user.id) {
Ok(dbs) => dbs,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
}
let conn = user_dbs.content.lock().unwrap();
match get_url_by_id(&conn, &id) {
Ok(Some(url)) => {
let _ = crate::db::users::decrement_quota_counter(
&state.users_db.lock().unwrap(),
user.id,
"urls",
);
match delete_url(&conn, &id) {
Ok(_) => {
let _ = crate::db::users::release_global_slug(
&state.system_db.lock().unwrap(),
&url.code,
user.id,
);
let ip = get_client_ip(&headers, connect_info);
let _ = write_audit_log(
&state.admin_db.lock().unwrap(),
&state,
&user.username,
"USER_URL_DELETION",
Some("url"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/user/urls").into_response()
}
Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e))
.into_response(),
}
}
_ => Redirect::to("/user/urls?error=Link not found").into_response(),
}
}
// GET /admin/urls
#[derive(Deserialize)]
pub struct UrlsQuery {
pub tag: Option<String>,
pub error: Option<String>,
pub page: Option<usize>,
}
pub async fn urls_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UrlsQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let (urls, total_pages, page, visible_pages) = {
let conn = state.content_db.lock().unwrap();
let total_records = if let Some(tag_str) = query.tag.as_deref() {
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
} else {
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
};
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
let total_pages = std::cmp::max(1, calculated_total_pages);
let requested_page = query.page.unwrap_or(1);
let current_page = if requested_page == 0 {
1
} else {
requested_page
}
.clamp(1, total_pages);
let offset = (current_page - 1) * PAGE_SIZE;
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
.unwrap_or_default();
let start_page = current_page.saturating_sub(3).max(1);
let end_page = std::cmp::min(total_pages, current_page + 3);
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
(urls, total_pages, current_page, visible_pages)
};
let csrf_token = generate_csrf_token(&session_id);
let proto = if state.config.cookie_secure {
"https"
} else {
"http"
};
let base_url = state
.config
.base_url
.clone()
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
let template = crate::templates::UrlsTemplate {
admin_username: user.username,
urls,
csrf_token,
error: query.error,
tag_filter: query.tag,
base_url,
current_page: page,
total_pages,
visible_pages,
};
template.into_response()
}
#[derive(Deserialize)]
pub struct CreateUrlForm {
pub destination: String,
pub code: String,
pub custom_slug: String,
pub title: String,
pub description: String,
pub tags: String,
pub csrf_token: String,
pub expires_at: String,
pub password: String,
pub max_access_count: String,
pub utm_source: String,
pub utm_medium: String,
pub utm_campaign: String,
}
// POST /admin/urls/create
pub async fn urls_create(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Form(form): Form<CreateUrlForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
// Custom Slug takes priority if provided
let mut code = form.custom_slug.trim().to_lowercase();
if code.is_empty() {
code = form.code.trim().to_lowercase();
if code.is_empty() {
code = generate_token(3);
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
return Redirect::to(
"/admin/urls?error=Custom code must be exactly 6 hex characters",
)
.into_response();
}
}
} else {
if !crate::utils::validation::validate_custom_slug(&code) {
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
.into_response();
}
}
let dest = match crate::services::urls::prepare_destination(
&form.destination,
crate::services::urls::UtmParams {
source: Some(&form.utm_source),
medium: Some(&form.utm_medium),
campaign: Some(&form.utm_campaign),
},
) {
Ok(d) => d,
Err(msg) => {
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
}
};
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
let password_hash_opt = if form.password.trim().is_empty() {
None
} else {
match hash_password(&form.password) {
Ok(h) => Some(h),
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
}
};
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
None
} else {
match form.max_access_count.trim().parse::<i64>() {
Ok(c) => Some(c),
Err(_) => {
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
}
}
};
let tags_list: Vec<String> = form
.tags
.split(',')
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
.collect();
let title_opt = if form.title.trim().is_empty() {
None
} else {
Some(form.title.trim())
};
let desc_opt = if form.description.trim().is_empty() {
None
} else {
Some(form.description.trim())
};
{
let users_conn = state.users_db.lock().unwrap();
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
{
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
}
}
{
let system_conn = state.system_db.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
return Redirect::to("/admin/urls?error=Short code/slug already exists")
.into_response();
}
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
if let Err(e) =
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
{
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
.into_response();
}
}
let res = {
let conn = state.content_db.lock().unwrap();
crate::db::content::create_url_extended(
&conn,
&code,
&dest,
title_opt,
desc_opt,
&tags_list,
expires_at_opt.as_deref(),
password_hash_opt.as_deref(),
max_access_count_opt,
)
};
match res {
Ok(url) => {
{
let system_conn = state.system_db.lock().unwrap();
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
);
}
{
let users_conn = state.users_db.lock().unwrap();
let _ =
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
}
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"URL_CREATION",
Some("url"),
Some(&url.id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => {
let system_conn = state.system_db.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
}
}
}
// POST /admin/urls/delete/:id
pub async fn urls_delete(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<String>,
Form(form): Form<std::collections::HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
if !verify_csrf(&session_id, &csrf_token) {
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
}
let ip = get_client_ip(&headers, connect_info);
let conn = state.content_db.lock().unwrap();
match delete_url(&conn, &id) {
Ok(_) => {
{
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"URL_DELETION",
Some("url"),
Some(&id),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
}
Redirect::to("/admin/urls").into_response()
}
Err(e) => {
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
}
}
}
+704
View File
@@ -0,0 +1,704 @@
use super::*;
#[derive(Deserialize)]
pub struct UsersQuery {
pub success: Option<String>,
pub error: Option<String>,
}
#[derive(Deserialize)]
pub struct CreateUserForm {
pub username: String,
pub password: String,
pub account_type: String,
pub metadata: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UpdateUserStatusForm {
pub status: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct UpdateUserTypeForm {
pub account_type: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct ResetPasswordForm {
pub new_password: String,
pub csrf_token: String,
}
#[derive(Deserialize)]
pub struct DeleteUserForm {
pub csrf_token: String,
}
pub async fn users_get(
State(state): State<AppState>,
jar: CookieJar,
Query(query): Query<UsersQuery>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let users = {
let conn = state.users_db.lock().unwrap();
crate::db::users::list_users(&conn).unwrap_or_default()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UsersTemplate {
admin_username: user.username,
users,
csrf_token,
success: query.success,
error: query.error,
};
template.into_response()
}
pub async fn users_create_post(
State(state): State<AppState>,
jar: CookieJar,
Form(form): Form<CreateUserForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let username = form.username.trim().to_lowercase();
if username.len() < 3 {
return Redirect::to("/admin/users?error=Username must be at least 3 characters")
.into_response();
}
if !username
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
return Redirect::to(
"/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores",
)
.into_response();
}
if form.password.trim().len() < 8 {
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
.into_response();
}
let account_type = if form.account_type.trim().is_empty() {
"standard"
} else {
form.account_type.trim()
};
let metadata = if form.metadata.trim().is_empty() {
None
} else {
Some(form.metadata.trim())
};
let hash = match hash_password(&form.password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
};
let new_user = {
let conn = state.users_db.lock().unwrap();
match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) {
Ok(u) => u,
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
{
return Redirect::to("/admin/users?error=Username already exists").into_response();
}
Err(e) => {
return Redirect::to(&format!("/admin/users?error=Database error: {}", e))
.into_response();
}
}
};
if let Err(e) = state.db.init_user_databases(new_user.id) {
return Redirect::to(&format!(
"/admin/users?error=Failed to initialize user databases: {}",
e
))
.into_response();
}
{
let conn = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn,
&state,
&user.username,
"USER_CREATION",
Some("user"),
Some(&new_user.id.to_string()),
None,
None,
);
}
Redirect::to("/admin/users?success=User created successfully").into_response()
}
pub async fn users_update_status_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UpdateUserStatusForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let status = form.status.trim().to_lowercase();
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
return Redirect::to("/admin/users?error=Invalid user status").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_status(&conn, id, &status) {
Ok(_) => {
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_STATUS_UPDATE",
Some("user"),
Some(&id.to_string()),
None,
None,
);
Redirect::to("/admin/users?success=User status updated").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to update status: {}",
e
))
.into_response(),
}
}
pub async fn users_update_type_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UpdateUserTypeForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
let account_type = form.account_type.trim().to_lowercase();
if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str())
{
return Redirect::to("/admin/users?error=Invalid account type").into_response();
}
let conn = state.users_db.lock().unwrap();
match crate::db::users::update_user_account_type(&conn, id, &account_type) {
Ok(_) => {
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_ACCOUNT_TYPE_UPDATE",
Some("user"),
Some(&id.to_string()),
None,
None,
);
Redirect::to("/admin/users?success=User account type updated").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to update account type: {}",
e
))
.into_response(),
}
}
pub async fn users_reset_password_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<i64>,
Form(form): Form<ResetPasswordForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
if form.new_password.trim().len() < 8 {
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
.into_response();
}
let hash = match hash_password(&form.new_password) {
Ok(h) => h,
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
};
let conn = state.users_db.lock().unwrap();
match crate::db::users::reset_user_password(&conn, id, &hash) {
Ok(_) => {
let ip = get_client_ip(&headers, connect_info);
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_PASSWORD_RESET",
Some("user"),
Some(&id.to_string()),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/users?success=Password reset successfully").into_response()
}
Err(e) => Redirect::to(&format!(
"/admin/users?error=Failed to reset password: {}",
e
))
.into_response(),
}
}
pub async fn users_delete_post(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
connect_info: Option<ConnectInfo<SocketAddr>>,
Path(id): Path<i64>,
Form(form): Form<DeleteUserForm>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
}
match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) {
Ok(_) => {
let ip = get_client_ip(&headers, connect_info);
let conn_admin = state.admin_db.lock().unwrap();
let _ = write_audit_log(
&conn_admin,
&state,
&user.username,
"USER_DELETION",
Some("user"),
Some(&id.to_string()),
Some(&ip),
headers.get("user-agent").and_then(|h| h.to_str().ok()),
);
Redirect::to("/admin/users?success=User deleted successfully").into_response()
}
Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(),
}
}
// ---------------------------------------------------------------------------
// GA Hardening UI Handlers and Structs
// ---------------------------------------------------------------------------
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct UserDetailStats {
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub current_urls: i64,
pub current_landings: i64,
pub current_api_tokens: i64,
pub current_storage_mb: i64,
pub url_pct: i64,
pub landing_pct: i64,
pub token_pct: i64,
pub storage_pct: i64,
pub total_visits: i64,
}
pub fn get_user_detail_stats(
state: &AppState,
user_id: i64,
) -> Result<UserDetailStats, Box<dyn std::error::Error>> {
use rusqlite::OptionalExtension;
let quotas = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
FROM quotas WHERE user_id = ?1;",
[user_id],
|row| Ok(crate::models::UserQuotas {
user_id,
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
current_urls: row.get(4)?,
current_landings: row.get(5)?,
current_api_tokens: row.get(6)?,
current_storage_mb: row.get(7)?,
})
).optional()?
};
let quotas = quotas.unwrap_or(crate::models::UserQuotas {
user_id,
max_urls: 100,
max_landings: 10,
max_api_tokens: 5,
max_storage_mb: 100,
current_urls: 0,
current_landings: 0,
current_api_tokens: 0,
current_storage_mb: 0,
});
let total_visits = {
if let Ok(dbs) = state.get_user_dbs(user_id) {
let conn = dbs.analytics.lock().unwrap();
conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
row.get::<_, i64>(0)
})
.unwrap_or(0)
} else {
0
}
};
let url_pct = if quotas.max_urls > 0 {
(quotas.current_urls * 100) / quotas.max_urls
} else {
0
};
let landing_pct = if quotas.max_landings > 0 {
(quotas.current_landings * 100) / quotas.max_landings
} else {
0
};
let token_pct = if quotas.max_api_tokens > 0 {
(quotas.current_api_tokens * 100) / quotas.max_api_tokens
} else {
0
};
let storage_pct = if quotas.max_storage_mb > 0 {
(quotas.current_storage_mb * 100) / quotas.max_storage_mb
} else {
0
};
Ok(UserDetailStats {
max_urls: quotas.max_urls,
max_landings: quotas.max_landings,
max_api_tokens: quotas.max_api_tokens,
max_storage_mb: quotas.max_storage_mb,
current_urls: quotas.current_urls,
current_landings: quotas.current_landings,
current_api_tokens: quotas.current_api_tokens,
current_storage_mb: quotas.current_storage_mb,
url_pct,
landing_pct,
token_pct,
storage_pct,
total_visits,
})
}
// GET /admin/users/new
pub async fn users_new_get(
State(state): State<AppState>,
jar: CookieJar,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UsersNewTemplate {
admin_username: user.username,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// GET /admin/users/:id
pub async fn user_detail_get(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let target_user = {
let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE id = ?1;",
[id],
|row| Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
);
match u_res {
Ok(u) => u,
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
}
};
let stats = match get_user_detail_stats(&state, id) {
Ok(s) => s,
Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(),
};
let sessions = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;")
.unwrap();
let rows = stmt
.query_map([id], |row| {
Ok(crate::models::UserSession {
id: row.get(0)?,
user_id: row.get(1)?,
expires_at: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let tokens = {
let conn = state.users_db.lock().unwrap();
let mut stmt = conn
.prepare(
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
)
.unwrap();
let rows = stmt
.query_map([id], |row| {
Ok(crate::models::UserApiToken {
id: row.get(0)?,
user_id: row.get(1)?,
token_hash: row.get(2)?,
created_at: row.get(3)?,
})
})
.unwrap();
rows.filter_map(|r| r.ok()).collect()
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserDetailTemplate {
admin_username: user.username,
target_user,
stats,
sessions,
tokens,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
// GET /admin/users/:id/edit
pub async fn user_edit_get(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Query(params): Query<HashMap<String, String>>,
) -> Response {
let (user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
let target_user = {
let conn = state.users_db.lock().unwrap();
let u_res = conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
FROM users WHERE id = ?1;",
[id],
|row| Ok(crate::models::TenantUser {
id: row.get(0)?,
username: row.get(1)?,
password_hash: row.get(2)?,
status: row.get(3)?,
created_at: row.get(4)?,
last_login: row.get(5)?,
account_type: row.get(6)?,
organization_id: row.get(7)?,
metadata: row.get(8)?,
})
);
match u_res {
Ok(u) => u,
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
}
};
let quotas = {
let conn = state.users_db.lock().unwrap();
conn.query_row(
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
FROM quotas WHERE user_id = ?1;",
[id],
|row| Ok(crate::models::UserQuotas {
user_id: id,
max_urls: row.get(0)?,
max_landings: row.get(1)?,
max_api_tokens: row.get(2)?,
max_storage_mb: row.get(3)?,
current_urls: row.get(4)?,
current_landings: row.get(5)?,
current_api_tokens: row.get(6)?,
current_storage_mb: row.get(7)?,
})
).unwrap_or(crate::models::UserQuotas {
user_id: id,
max_urls: 100,
max_landings: 10,
max_api_tokens: 5,
max_storage_mb: 100,
current_urls: 0,
current_landings: 0,
current_api_tokens: 0,
current_storage_mb: 0,
})
};
let csrf_token = generate_csrf_token(&session_id);
let template = crate::templates::UserEditTemplate {
admin_username: user.username,
target_user,
quotas,
csrf_token,
success: params.get("success").cloned(),
error: params.get("error").cloned(),
};
template.into_response()
}
#[derive(Deserialize)]
pub struct UserEditForm {
pub account_type: String,
pub metadata: String,
pub max_urls: i64,
pub max_landings: i64,
pub max_api_tokens: i64,
pub max_storage_mb: i64,
pub csrf_token: String,
}
// POST /admin/users/:id/edit
pub async fn user_edit_post(
State(state): State<AppState>,
jar: CookieJar,
Path(id): Path<i64>,
Form(form): Form<UserEditForm>,
) -> Response {
let (_user, session_id) = match require_auth(&state, &jar).await {
Ok(u) => u,
Err(redir) => return redir.into_response(),
};
if !verify_csrf(&session_id, &form.csrf_token) {
return Redirect::to(&format!(
"/admin/users/{}/edit?error=Invalid CSRF token",
id
))
.into_response();
}
let conn = state.users_db.lock().unwrap();
let _ = conn.execute(
"UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;",
rusqlite::params![form.account_type, form.metadata, id],
);
let _ = conn.execute(
"INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \
VALUES (?1, ?2, ?3, ?4, ?5) \
ON CONFLICT(user_id) DO UPDATE SET \
max_urls = excluded.max_urls, \
max_landings = excluded.max_landings, \
max_api_tokens = excluded.max_api_tokens, \
max_storage_mb = excluded.max_storage_mb;",
rusqlite::params![
id,
form.max_urls,
form.max_landings,
form.max_api_tokens,
form.max_storage_mb
],
);
Redirect::to(&format!(
"/admin/users/{}?success=User updated successfully",
id
))
.into_response()
}
+35 -29
View File
@@ -99,34 +99,23 @@ pub async fn api_create_url(
}
}
let mut dest = payload.destination.trim().to_string();
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
let mut has_utm = false;
{
let mut query = parsed.query_pairs_mut();
if let Some(ref src) = payload.utm_source {
if !src.trim().is_empty() {
query.append_pair("utm_source", src.trim());
has_utm = true;
}
}
if let Some(ref med) = payload.utm_medium {
if !med.trim().is_empty() {
query.append_pair("utm_medium", med.trim());
has_utm = true;
}
}
if let Some(ref camp) = payload.utm_campaign {
if !camp.trim().is_empty() {
query.append_pair("utm_campaign", camp.trim());
has_utm = true;
}
}
let dest = match crate::services::urls::prepare_destination(
&payload.destination,
crate::services::urls::UtmParams {
source: payload.utm_source.as_deref(),
medium: payload.utm_medium.as_deref(),
campaign: payload.utm_campaign.as_deref(),
},
) {
Ok(d) => d,
Err(msg) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({ "error": msg })),
)
.into_response();
}
if has_utm {
dest = parsed.to_string();
}
}
};
let password_hash = if let Some(ref pwd) = payload.password {
if pwd.is_empty() {
@@ -353,6 +342,15 @@ pub async fn api_update_url(
Json(payload): Json<UpdateUrlRequest>,
) -> Response {
let tags = payload.tags.unwrap_or_default();
if !crate::utils::validation::validate_redirect_destination(&payload.destination) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Destination must be a valid http(s) URL without control characters"
})),
)
.into_response();
}
let conn = state.content_db.lock().unwrap();
match update_url(
&conn,
@@ -793,7 +791,11 @@ pub struct OverallStatsResponse {
}
// GET /api/v1/stats
pub async fn api_overall_stats(State(state): State<AppState>, _user: ApiUser) -> Response {
pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) -> Response {
if let Err(err) = user.require_admin() {
return err.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))
@@ -982,9 +984,13 @@ pub struct AuditQuery {
// GET /api/v1/audit
pub async fn api_list_audit(
State(state): State<AppState>,
_user: ApiUser,
user: ApiUser,
Query(query): Query<AuditQuery>,
) -> Response {
if let Err(err) = user.require_admin() {
return err.into_response();
}
let limit = query.limit.unwrap_or(50);
let offset = query.offset.unwrap_or(0);
+54 -208
View File
@@ -1,8 +1,9 @@
use crate::auth::generate_token;
use crate::auth::password::hash_password;
use crate::auth::ApiUser;
use crate::services::bulk_urls::{
create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError,
};
use crate::state::AppState;
use crate::utils::get_client_ip;
use crate::utils::{get_client_ip, lock_db};
use axum::{
extract::{ConnectInfo, State},
http::{HeaderMap, StatusCode},
@@ -68,7 +69,18 @@ pub async fn api_bulk_qr(
// Retrieve URLs from database
let mut urls = Vec::new();
{
let conn = state.content_db.lock().unwrap();
let conn = match lock_db(&state.content_db, "content_db") {
Ok(c) => c,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: e.to_string(),
}),
)
.into_response();
}
};
for id in &payload.ids {
match crate::db::content::get_url_by_id(&conn, id) {
Ok(Some(url)) => urls.push(url),
@@ -115,9 +127,8 @@ pub async fn api_bulk_qr(
// Generate ZIP
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
Ok(zip_data) => {
// Write Audit Log
{
let system_conn = state.db.system.lock().unwrap();
// Write Audit Log (best-effort; do not fail the download on audit lock poison)
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
user.0.username(),
@@ -147,6 +158,16 @@ pub async fn api_bulk_qr(
}
}
fn bulk_url_error_response(err: BulkUrlError) -> Response {
let (status, msg) = match &err {
BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
};
(status, Json(BulkErrorResponse { error: msg })).into_response()
}
// POST /api/v1/bulk/url
pub async fn api_bulk_url(
State(state): State<AppState>,
@@ -185,214 +206,39 @@ pub async fn api_bulk_url(
}
};
// Check quota
{
let users_conn = state.users_db.lock().unwrap();
if let Some(quotas) =
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
{
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "Quota limit exceeded".to_string(),
}),
)
.into_response();
}
} else {
return (
StatusCode::FORBIDDEN,
Json(BulkErrorResponse {
error: "User quota not found".to_string(),
}),
)
.into_response();
}
if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) {
return bulk_url_error_response(e);
}
let mut conn = content_db.lock().unwrap();
let tx = match conn.transaction() {
Ok(t) => t,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to start database transaction: {}", e),
}),
)
.into_response()
}
let items: Vec<BulkUrlCreateItem> = payload
.into_iter()
.map(|item| BulkUrlCreateItem {
destination: item.destination,
code: item.code,
title: item.title,
description: item.description,
tags: item.tags,
expires_at: item.expires_at,
password: item.password,
max_access_count: item.max_access_count,
})
.collect();
let created_urls = match create_urls_bulk(
&content_db,
&state.system_db,
&state.users_db,
target_user_id,
items,
) {
Ok(urls) => urls,
Err(e) => return bulk_url_error_response(e),
};
let mut created_urls = Vec::new();
let mut reserved_slugs: Vec<String> = Vec::new();
for item in payload {
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
if code.is_empty() {
code = generate_token(3); // 6 hex
} else {
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
let _ = tx.rollback();
// Release reserving slugs
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::BAD_REQUEST,
Json(BulkErrorResponse {
error: format!("Short code '{}' must be 6 hex characters", code),
}),
)
.into_response();
}
}
// Reserve slug
{
let system_conn = state.system_db.lock().unwrap();
// Check availability in system.db and also check in our currently reserved slugs in this batch
let available = crate::db::users::is_slug_available(&system_conn, &code)
.unwrap_or(false)
&& !reserved_slugs.contains(&code);
if !available {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::CONFLICT,
Json(BulkErrorResponse {
error: format!("Short code '{}' already exists", code),
}),
)
.into_response();
}
if let Err(e) = crate::db::users::register_global_slug(
&system_conn,
&code,
target_user_id,
"url",
"",
"reserving",
) {
let _ = tx.rollback();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to reserve slug '{}': {}", code, e),
}),
)
.into_response();
}
reserved_slugs.push(code.clone());
}
let password_hash = if let Some(ref pwd) = item.password {
match hash_password(pwd) {
Ok(h) => Some(h),
Err(e) => {
let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(
&system_conn,
slug,
target_user_id,
);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Password hashing error: {}", e),
}),
)
.into_response();
}
}
} else {
None
};
let tags = item.tags.unwrap_or_default();
match crate::db::content::create_url_extended(
&tx,
&code,
&item.destination,
item.title.as_deref(),
item.description.as_deref(),
&tags,
item.expires_at.as_deref(),
password_hash.as_deref(),
item.max_access_count,
) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ =
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Database insert error: {}", e),
}),
)
.into_response();
}
}
}
if let Err(e) = tx.commit() {
let system_conn = state.system_db.lock().unwrap();
for slug in &reserved_slugs {
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
}
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(BulkErrorResponse {
error: format!("Failed to commit transaction: {}", e),
}),
)
.into_response();
}
// Activate slugs
{
let system_conn = state.system_db.lock().unwrap();
for url in &created_urls {
let _ = system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
);
}
}
// Increment quota counters
{
let users_conn = state.users_db.lock().unwrap();
for _ in 0..created_urls.len() {
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
}
}
// Write Audit Log for the entire batch
let ip = get_client_ip(&headers, connect_info);
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
{
let system_conn = state.db.system.lock().unwrap();
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
user.0.username(),
+67
View File
@@ -0,0 +1,67 @@
use axum::{
extract::Path,
http::{header, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use std::path::{Component, PathBuf};
const IMAGES_DIR: &str = "/app/images";
pub async fn image_handler(Path(path): Path<String>) -> Response {
// preview.png is reserved for social-media metadata.
// It is served from /app/www/images/preview.png,
// not from the persistent application image directory.
if path == "preview.png" {
return crate::web::pages::social_preview().await;
}
let relative = PathBuf::from(&path);
// Prevent path traversal.
if relative.components().any(|component| {
matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
}) {
return (StatusCode::BAD_REQUEST, "Invalid image path").into_response();
}
let image_path = PathBuf::from(IMAGES_DIR).join(relative);
let content = match tokio::fs::read(&image_path).await {
Ok(content) => content,
Err(_) => {
return (StatusCode::NOT_FOUND, "Image not found").into_response();
}
};
let content_type = match image_path
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
.as_deref()
{
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("svg") => "image/svg+xml",
Some("avif") => "image/avif",
_ => "application/octet-stream",
};
let content_type = HeaderValue::from_static(content_type);
(
[
(header::CONTENT_TYPE, content_type),
(
header::CACHE_CONTROL,
HeaderValue::from_static("public, max-age=86400"),
),
],
content,
)
.into_response()
}
+1
View File
@@ -11,3 +11,4 @@ pub mod routes;
pub mod system;
pub use routes::create_router;
pub mod images;
+52 -9
View File
@@ -63,15 +63,10 @@ pub async fn resolve_page(
.into_response();
}
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
}
// 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
let page_opt = {
@@ -186,3 +181,51 @@ pub async fn deploy_script() -> Response {
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
}
}
pub async fn social_preview() -> Response {
let mut target_path = std::path::PathBuf::from("www/images/preview.png");
if !target_path.exists() {
if let Ok(exe_path) = std::env::current_exe() {
if let Some(exe_dir) = exe_path.parent() {
let path1 = exe_dir.join("www/images/preview.png");
if path1.exists() {
target_path = path1;
} else if let Some(parent1) = exe_dir.parent() {
let path2 = parent1.join("www/images/preview.png");
if path2.exists() {
target_path = path2;
} else if let Some(parent2) = parent1.parent() {
let path3 = parent2.join("www/images/preview.png");
if path3.exists() {
target_path = path3;
}
}
}
}
}
}
if !target_path.exists() {
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
let path = std::path::PathBuf::from(manifest_dir).join("www/images/preview.png");
if path.exists() {
target_path = path;
}
}
}
match tokio::fs::read(target_path).await {
Ok(content) => (
StatusCode::OK,
[
("content-type", "image/png"),
("cache-control", "public, max-age=86400"),
],
content,
)
.into_response(),
Err(_) => (StatusCode::NOT_FOUND, "preview.png not found").into_response(),
}
}
+3 -1
View File
@@ -26,6 +26,7 @@ pub async fn gate_post(
State(state): State<AppState>,
Path(code): Path<String>,
jar: CookieJar,
headers: axum::http::HeaderMap,
Form(form): Form<PasswordGateForm>,
) -> Response {
let url_opt = match get_url_by_code(&state.db, &code) {
@@ -55,8 +56,9 @@ pub async fn gate_post(
if verify_password(&form.password, password_hash) {
// Correct password - set 15 min temporary cookie
let cookie_name = format!("bzod_gate_{}", code);
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
let cookie = Cookie::build((cookie_name, "authorized"))
.secure(state.config.cookie_secure)
.secure(secure_flag)
.same_site(axum_extra::extract::cookie::SameSite::Strict)
.http_only(true)
.path("/")
+378 -88
View File
@@ -1,20 +1,298 @@
//! Public short-code redirect hot path.
//!
//! Design notes:
//! - Destination `Location` headers never panic on malformed values.
//! - Content DB work uses a single mutex acquisition where safe.
//! - Expiration is enforced on the read path; persistent `expired=1` is left to
//! the background expiry job (`jobs::expiry`), not written here.
//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved.
//! - Analytics enqueue remains non-blocking (`try_send` via the queue).
use axum::{
extract::{ConnectInfo, Path, State},
http::{HeaderMap, StatusCode},
http::{header, HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Redirect, Response},
};
use axum_extra::extract::CookieJar;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::net::SocketAddr;
use std::sync::{Arc, Mutex};
use tracing::{error, warn};
use uuid::Uuid;
use crate::analytics::get_client_country;
use crate::models::VisitRecord;
use crate::models::{LinkPreview, Url, VisitRecord};
use crate::state::AppState;
use crate::templates::PreviewTemplate;
use crate::utils::get_client_ip;
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
enum ResolveOutcome {
Ready(Box<ResolvedUrl>),
/// Early HTTP response that does not need further processing.
Early {
status: StatusCode,
body: &'static str,
},
/// Permanent redirect to a relative path (e.g. page target → `/p/{code}`).
PermanentPath(String),
DbError {
operation: &'static str,
message: String,
owner_user_id: Option<i64>,
resource_id: Option<String>,
},
}
/// Safe client-facing DB error after structured server-side logging.
fn db_error_response(
operation: &str,
code: &str,
owner_user_id: Option<i64>,
resource_id: Option<&str>,
err: impl std::fmt::Display,
) -> Response {
error!(
operation = operation,
code = code,
owner_user_id = owner_user_id,
resource_id = resource_id.unwrap_or(""),
error = %err,
"redirect path database error"
);
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
}
/// Build a permanent redirect without panicking on invalid destinations.
///
/// Defense in depth:
/// 1. Canonical destination rules (http/https, no control chars) — same as writes.
/// 2. `HeaderValue` construction — rejects remaining illegal header bytes.
///
/// Neither step may panic. Full destination values are not logged.
fn permanent_redirect_to(destination: &str, code: &str) -> Response {
if !crate::utils::validation::validate_redirect_destination(destination) {
warn!(
operation = "validate_redirect_destination",
code = code,
destination_len = destination.len(),
"invalid stored redirect destination rejected"
);
return (
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid redirect destination",
)
.into_response();
}
match HeaderValue::from_str(destination) {
Ok(loc) => {
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(header::LOCATION, loc);
resp
}
Err(err) => {
warn!(
operation = "build_location_header",
code = code,
error = %err,
// Do not log the full destination if it may contain control chars;
// log length only for forensics.
destination_len = destination.len(),
"invalid redirect destination rejected (possible response-splitting attempt)"
);
(
StatusCode::INTERNAL_SERVER_ERROR,
"Invalid redirect destination",
)
.into_response()
}
}
}
/// Result of the initial global-slug + URL resolution phase.
struct ResolvedUrl {
owner_user_id: i64,
url: Url,
content: Arc<Mutex<rusqlite::Connection>>,
}
/// Lookup global slug namespace then load the URL from the tenant content DB.
/// Runs entirely on a blocking thread.
fn resolve_url_blocking(
system_db: Arc<Mutex<rusqlite::Connection>>,
state: AppState,
code: &str,
) -> ResolveOutcome {
// 1. Query global slug namespace in system.db
let slug_info = {
let system_conn = match system_db.lock() {
Ok(c) => c,
Err(e) => {
return ResolveOutcome::DbError {
operation: "lock_system_db",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
};
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
) {
Ok(s) => s,
Err(e) => {
return ResolveOutcome::DbError {
operation: "prepare_global_slugs",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
};
match stmt
.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
{
Ok(info) => info,
Err(e) => {
return ResolveOutcome::DbError {
operation: "query_global_slugs",
message: e.to_string(),
owner_user_id: None,
resource_id: None,
};
}
}
};
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Some(info) => info,
None => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "url".to_string(), "".to_string(), "active".to_string())
}
};
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return ResolveOutcome::Early {
status: StatusCode::GONE,
body: "This content has been disabled or moderated",
};
}
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
}
// 2. Get content database connection via tenant DB resolution
let content_conn = match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs,
Err(e) => {
return ResolveOutcome::DbError {
operation: "get_user_dbs",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
};
let url = {
let conn = match content_conn.content.lock() {
Ok(c) => c,
Err(e) => {
return ResolveOutcome::DbError {
operation: "lock_content_db",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
};
match crate::db::content::get_url_by_code(&conn, code) {
Ok(Some(url)) => url,
Ok(None) => {
return ResolveOutcome::Early {
status: StatusCode::NOT_FOUND,
body: "Short code not found",
};
}
Err(e) => {
return ResolveOutcome::DbError {
operation: "get_url_by_code",
message: e.to_string(),
owner_user_id: Some(owner_user_id),
resource_id: None,
};
}
}
};
ResolveOutcome::Ready(Box::new(ResolvedUrl {
owner_user_id,
url,
content: content_conn.content,
}))
}
/// Increment access count and load preview under a single content-DB lock.
fn increment_and_preview_blocking(
content: Arc<Mutex<rusqlite::Connection>>,
url_id: &str,
code: &str,
owner_user_id: i64,
fallback_access_count: i64,
) -> Result<(i64, Option<LinkPreview>), String> {
let conn = content
.lock()
.map_err(|e| format!("lock_content_db_hot: {}", e))?;
let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) {
Ok(n) => n,
Err(e) => {
// Preserve prior soft-failure semantics for the counter value used only
// internally; never silence the underlying error.
error!(
operation = "increment_access_count",
code = code,
owner_user_id = owner_user_id,
resource_id = url_id,
error = %e,
"failed to increment access count; continuing with estimated value"
);
fallback_access_count + 1
}
};
let preview = match crate::db::preview::get_preview(&conn, url_id) {
Ok(p) => p,
Err(e) => {
error!(
operation = "get_preview",
code = code,
owner_user_id = owner_user_id,
resource_id = url_id,
error = %e,
"failed to load link preview; continuing without preview"
);
None
}
};
Ok((new_access_count, preview))
}
// GET /:code
// Resolve and redirect
pub async fn resolve_redirect(
@@ -31,74 +309,51 @@ pub async fn resolve_redirect(
return (StatusCode::NOT_FOUND, "Not Found").into_response();
}
// 1. Query global slug namespace in system.db
let slug_info = {
let system_conn = state.system_db.lock().unwrap();
let mut stmt = match system_conn.prepare(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
) {
Ok(s) => s,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
stmt.query_row(rusqlite::params![code], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
))
})
.optional()
};
let system_db = state.system_db.clone();
let state_for_lookup = state.clone();
let code_for_lookup = code.clone();
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
Ok(Some(info)) => info,
Ok(None) => {
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
(1, "url".to_string(), "".to_string(), "active".to_string())
}
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
};
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
if slug_status != "active" {
return (
StatusCode::GONE,
"This content has been disabled or moderated",
)
.into_response();
}
// If target type is page, redirect permanently to /p/slug
if target_type == "page" {
return Redirect::permanent(&format!("/p/{}", code)).into_response();
}
// 2. Get content database connection - admin (user_id=1) uses legacy content_db,
// tenant users use per-user content databases
let content_conn = if owner_user_id == 1 {
state.content_db.clone()
} else {
match state.get_user_dbs(owner_user_id) {
Ok(dbs) => dbs.content,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
let outcome = match tokio::task::spawn_blocking(move || {
resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup)
})
.await
{
Ok(outcome) => outcome,
Err(e) => {
return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string());
}
};
let url_opt = {
let conn = content_conn.lock().unwrap();
match crate::db::content::get_url_by_code(&conn, &code) {
Ok(url) => url,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
let resolved = match outcome {
ResolveOutcome::Ready(r) => r,
ResolveOutcome::Early { status, body } => return (status, body).into_response(),
ResolveOutcome::PermanentPath(path) => {
return Redirect::permanent(&path).into_response();
}
ResolveOutcome::DbError {
operation,
message,
owner_user_id,
resource_id,
} => {
return db_error_response(
operation,
&code,
owner_user_id,
resource_id.as_deref(),
message,
);
}
};
let url = match url_opt {
Some(u) => u,
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
};
let ResolvedUrl {
owner_user_id,
url,
content,
} = *resolved;
// 3. Expiration check
// 3. Expiration check (read-only on the hot path).
// Background job `jobs::expiry::run_expiry_checker` persists expired=1.
if url.expired {
return (StatusCode::GONE, "This link has expired").into_response();
}
@@ -106,14 +361,6 @@ pub async fn resolve_redirect(
if let Some(ref expires_at_str) = url.expires_at {
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
if expires_at.with_timezone(&Utc) < Utc::now() {
// Mark as expired in DB asynchronously/immediately
{
let conn = content_conn.lock().unwrap();
let _ = conn.execute(
"UPDATE urls SET expired = 1 WHERE id = ?1;",
[url.id.clone()],
);
}
return (StatusCode::GONE, "This link has expired").into_response();
}
}
@@ -141,15 +388,40 @@ pub async fn resolve_redirect(
}
}
// 6. Increment access count & retrieve preview config
let _new_access_count = {
let conn = content_conn.lock().unwrap();
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
};
let preview_opt = {
let conn = content_conn.lock().unwrap();
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
// 6. Increment access count & retrieve preview config (single content lock, off executor)
let url_id = url.id.clone();
let code_for_hot = code.clone();
let fallback_access_count = url.access_count;
let preview_opt = match tokio::task::spawn_blocking(move || {
increment_and_preview_blocking(
content,
&url_id,
&code_for_hot,
owner_user_id,
fallback_access_count,
)
})
.await
{
Ok(Ok((_new_access_count, preview))) => preview,
Ok(Err(msg)) => {
return db_error_response(
"increment_and_preview",
&code,
Some(owner_user_id),
Some(&url.id),
msg,
);
}
Err(e) => {
return db_error_response(
"spawn_blocking_hot",
&code,
Some(owner_user_id),
Some(&url.id),
e.to_string(),
);
}
};
// Asynchronously record analytics
@@ -200,14 +472,32 @@ pub async fn resolve_redirect(
}
.into_response()
} else {
{
use axum::http::{header, HeaderValue};
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
resp.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(&url.destination).unwrap(),
);
resp
}
permanent_redirect_to(&url.destination, &code)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn permanent_redirect_rejects_crlf() {
let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
assert!(resp.headers().get(header::LOCATION).is_none());
}
#[test]
fn permanent_redirect_rejects_control_chars() {
let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123");
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
#[test]
fn permanent_redirect_accepts_valid_url() {
let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123");
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
let loc = resp.headers().get(header::LOCATION).unwrap();
assert_eq!(loc, "https://example.com/path?q=1");
}
}
+4 -1
View File
@@ -1,5 +1,7 @@
use crate::state::AppState;
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
use crate::web::{
admin, api, bulk, images, multi_user, pages, password_gate, qr, redirect, system,
};
use axum::{
routing::{delete, get, post, put},
Router,
@@ -95,6 +97,7 @@ pub fn create_router(state: AppState) -> Router {
.route("/admin/pages/delete/:id", post(admin::pages_delete))
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
.route("/deploy.sh", get(pages::deploy_script))
.route("/images/*path", get(images::image_handler))
.route(
"/admin/analytics/url/:id/export/csv",
get(admin::url_analytics_csv_export),
+2 -2
View File
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
queue_size: 0,
memory_usage: get_memory_usage(),
uptime_seconds: uptime,
version: "0.1.0",
git_commit: "unknown",
version: crate::build_info::APP_VERSION,
git_commit: crate::build_info::GIT_COMMIT,
})
.into_response()
}
+1242 -72
View File
File diff suppressed because it is too large. Load diff
+53 -50
View File
@@ -13,8 +13,7 @@
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<!-- Create Landing Page Form -->
<div class="urls-dashboard">
<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>
@@ -24,7 +23,7 @@
<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="page-form-grid-2">
<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>
@@ -36,7 +35,7 @@
</div>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
<div class="page-form-grid-3">
<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">
@@ -49,7 +48,7 @@
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state">
<select id="state" name="state" class="form-input">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
@@ -59,16 +58,15 @@
<div class="form-group">
<label for="html_content">Raw HTML Document *</label>
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>Landing Page</title>&#10;</head>&#10;<body>&#10; <h1>Welcome!</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form>
</div>
<!-- Landing Pages Table -->
<div class="card" style="padding: 0; overflow: hidden;">
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header">
<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
@@ -76,69 +74,75 @@
</div>
<div class="table-container">
<table>
<table class="registered-pages-table">
<colgroup>
<col class="col-page-title">
<col class="col-page-paths">
<col class="col-page-status">
<col class="col-page-qr">
<col class="col-page-actions">
</colgroup>
<thead>
<tr>
<th>Page Title</th>
<th>Short Path</th>
<th>SEO Preview Path</th>
<th>Page</th>
<th>Paths</th>
<th>Status</th>
<th>Analytics</th>
<th>QR Code</th>
<th>Created</th>
<th>Action</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="5" 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] }}...
<td data-label="Page">
<div class="page-title-block">
<div class="page-title-heading">{{ page.title }}</div>
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
</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 data-label="Paths">
<div class="page-paths">
<div class="page-path-group">
<span class="page-path-caption">Short Path</span>
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
/p/{{ page.code }}
</a>
</div>
<div class="page-path-group">
<span class="page-path-caption">SEO Preview Path</span>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
/p/{{ page.code }}/{{ page.slug }}
</a>
</div>
</div>
</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>
<td data-label="Status" class="status-cell">
<span class="badge badge-{{ page.state }}">
{{ page.state }}
</span>
</td>
<td>
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<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 data-label="Actions" class="actions-cell">
<div class="action-stack">
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
<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>
</div>
</td>
</tr>
{% endfor %}
@@ -147,8 +151,7 @@
</table>
</div>
<!-- Pagination Controls -->
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
<div class="pagination">
{% if current_page > 1 %}
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
+105 -88
View File
@@ -4,6 +4,14 @@
{% block active_urls %}active{% endblock %}
{% block extra_css %}
@media (max-width: 720px) {
#utm_fields > div {
grid-template-columns: 1fr !important;
}
}
{% endblock %}
{% block header_title %}Short URL Registry{% endblock %}
{% block content %}
@@ -13,9 +21,8 @@
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
<!-- Create Link Form -->
<div class="card">
<div class="urls-dashboard">
<div class="card url-form-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
@@ -97,9 +104,8 @@
</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;">
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header">
<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
@@ -113,7 +119,15 @@
</div>
<div class="table-container">
<table>
<table class="registered-links-table">
<colgroup>
<col class="col-short">
<col class="col-destination">
<col class="col-qr">
<col class="col-health">
<col class="col-tags">
<col class="col-actions">
</colgroup>
<thead>
<tr>
<th>Short Link</th>
@@ -121,108 +135,112 @@
<th>QR Code</th>
<th>Health</th>
<th>Tags</th>
<th>Analytics</th>
<th>Created</th>
<th>Action</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if urls.is_empty() %}
<tr>
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<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>
<div style="display: flex; align-items: center; gap: 0.25rem;">
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
</a>
{% if url.is_password_protected() %}
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
<svg width="12" height="12" 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"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</span>
{% endif %}
</div>
<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 %}
{% if let Some(expires_at) = url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %}
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
{% if url.expired %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if let Some(max) = url.max_access_count %}
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
Clicks: {{ url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
<td data-label="Short Link">
<div class="short-link-wrap">
<div class="short-link-primary">
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
</a>
{% if url.is_password_protected() %}
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
<svg width="12" height="12" 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"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
</span>
{% endif %}
</div>
{% else %}
{% if url.access_count > 0 %}
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
Clicks: {{ url.access_count }}
</div>
{% if let Some(title) = url.title.as_deref() %}
{% if !title.is_empty() %}
<div class="short-link-title">{{ title }}</div>
{% endif %}
{% endif %}
{% endif %}
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
</div>
</td>
<td data-label="Destination">
<div class="destination-block">
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
{{ url.destination }}
</a>
{% if let Some(desc) = url.description.as_deref() %}
{% if !desc.is_empty() %}
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
{% endif %}
{% endif %}
{% if let Some(expires_at) = url.expires_at.as_deref() %}
{% if !expires_at.is_empty() %}
<div class="destination-meta expiry">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
{% if url.expired %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
{% endif %}
</div>
{% endif %}
{% endif %}
{% if let Some(max) = url.max_access_count %}
<div class="destination-meta access">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
Clicks: {{ url.access_count }} / {{ max }}
{% if url.is_access_exhausted() %}
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
{% endif %}
</div>
{% else %}
{% if url.access_count > 0 %}
<div class="destination-meta neutral">
Clicks: {{ url.access_count }}
</div>
{% endif %}
{% endif %}
</div>
</td>
{% let code = url.code.as_str() %}
{% include "components/qr_preview.html" %}
<td>
<td data-label="Health" class="status-cell">
<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 %}
<td data-label="Tags">
{% if url.tags.is_empty() %}
<span class="tags-empty">—</span>
{% else %}
<div class="tags-wrap">
{% for t in url.tags %}
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
{{ t }}
</a>
{% endfor %}
</div>
{% endif %}
</td>
<td data-label="Actions" class="actions-cell">
<div class="action-stack">
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
<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>
</div>
</td>
<td>
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</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 %}
@@ -230,8 +248,7 @@
</table>
</div>
<!-- Pagination Controls -->
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
<div class="pagination">
{% if current_page > 1 %}
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt;&lt; First</a>
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">&lt; Prev</a>
+77 -67
View File
@@ -13,7 +13,7 @@
</div>
{% endif %}
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
<div class="urls-dashboard">
<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>
@@ -23,40 +23,44 @@
<form action="/user/pages/create" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<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 class="page-form-grid-2">
<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 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 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="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state" class="form-input">
<option value="draft">Draft</option>
<option value="published" selected>Published</option>
<option value="archived">Archived</option>
</select>
<div class="page-form-grid-3">
<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="custom_slug">Custom Slug (optional)</label>
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
</div>
<div class="form-group">
<label for="state">Publish State</label>
<select id="state" name="state" class="form-input">
<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">HTML Content *</label>
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</html>" required></textarea>
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html>&#10;<html>&#10;<head>&#10; <title>My Page</title>&#10;</head>&#10;<body>&#10; <h1>Hello</h1>&#10;</body>&#10;</html>" required></textarea>
</div>
<button type="submit" class="btn">Save Page</button>
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
</form>
</div>
<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;">
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
<div class="registered-links-header">
<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>
My Landing Pages
@@ -64,69 +68,75 @@
</div>
<div class="table-container">
<table>
<table class="registered-pages-table">
<colgroup>
<col class="col-page-title">
<col class="col-page-paths">
<col class="col-page-status">
<col class="col-page-qr">
<col class="col-page-actions">
</colgroup>
<thead>
<tr>
<th>Page Title</th>
<th>Short Path</th>
<th>SEO Preview Path</th>
<th>Page</th>
<th>Paths</th>
<th>Status</th>
<th>Analytics</th>
<th>QR Code</th>
<th>Created</th>
<th>Action</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% if pages.is_empty() %}
<tr>
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
No landing pages created yet.
</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] }}...
<td data-label="Page">
<div class="page-title-block">
<div class="page-title-heading">{{ page.title }}</div>
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
</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 data-label="Paths">
<div class="page-paths">
<div class="page-path-group">
<span class="page-path-caption">Short Path</span>
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
/p/{{ page.code }}
</a>
</div>
<div class="page-path-group">
<span class="page-path-caption">SEO Preview Path</span>
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
/p/{{ page.code }}/{{ page.slug }}
</a>
</div>
</div>
</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>
<td data-label="Status" class="status-cell">
<span class="badge badge-{{ page.state }}">
{{ page.state }}
</span>
</td>
<td>
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
</td>
{% let code = page.code.as_str() %}
{% include "components/qr_preview.html" %}
<td style="font-size: 0.8rem; color: var(--text-secondary);">
{{ page.created_at[0..10] }}
</td>
<td>
<form action="/user/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 data-label="Actions" class="actions-cell">
<div class="action-stack">
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
📊 Analytics
</a>
<form action="/user/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>
</div>
</td>
</tr>
{% endfor %}
@@ -135,7 +145,7 @@
</table>
</div>
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
<div class="pagination">
{% if current_page > 1 %}
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt;&lt; First</a>
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">&lt; Prev</a>
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more