23 Commits
Author SHA1 Message Date
thakares fb5d827322 chore: ignore local backups
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 20:54:05 +05:30
thakares feed352c2e fix: only initialize admin explicitly 2026-08-27 19:27:24 +05:30
thakares e42d1a5d80 fix: standardize deployment data directory and CI linting
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:41:24 +05:30
thakares c2e138f823 refactor(config): require explicit BZOD data directory
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-27 15:06:05 +05:30
thakares d398341f01 release: finalize BZOD v0.8.0
Rust CI / Test & Quality Checks (push) Canceled after 0s
Rust CI / Build Docker Image (push) Canceled after 0s
2026-08-21 16:23:32 +05:30
thakares d7e0ac7679 refactor: complete v0.8 core architecture 2026-08-21 13:56:36 +05:30
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
173 changed files with 25142 additions and 10632 deletions

No files matched your search

+6 -4
View File
@@ -1,17 +1,19 @@
# BZOD Platform Configuration
HOST=0.0.0.0
PORT=8080
DATA_DIR=./data
# Physical BZOD data root.
# REQUIRED: Set this explicitly; there is no implicit ./data fallback.
NX9_BZOD_DATA_DIR=/var/lib/bzod/data
# Security Settings
COOKIE_SECURE=false
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
# Bootstrap Admin Credentials
# Default username: admin
# Default password: admin
ADMIN_USERNAME=admin
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
# REQUIRED for a fresh deployment.
# Use a strong unique password.
ADMIN_PASSWORD=
# Cron & Cleaner Intervals (in minutes)
LINK_CHECK_INTERVAL_MINS=60
+5 -1
View File
@@ -1,4 +1,4 @@
/target
/target/
data/
*.db
*.db-wal
@@ -6,3 +6,7 @@ data/
.env
.idea/
bzod.env
# Local database backups
backups/
Generated
+1 -1
View File
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "bzod"
version = "0.5.1"
version = "0.8.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.8.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
# 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
# Create data directory
RUN mkdir -p /app/data && \
chown -R bzod:bzod /app
USER bzod
ENV DATA_DIR=/app/data \
# Runtime configuration
ENV NX9_BZOD_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"]
USER bzod
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["serve"]
+1202 -869
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}");
}
}
}
}
+424 -175
View File
@@ -1,226 +1,475 @@
#!/usr/bin/env bash
# BZOD Production Deployment Script
#
# 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.8.0
# 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.8.0}"
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}"
NX9_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: ${NX9_BZOD_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 \
"${NX9_BZOD_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}
NX9_BZOD_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}"
NX9_BZOD_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 "${NX9_BZOD_DATA_DIR}" ]]; then
cp -a "${NX9_BZOD_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 " ${NX9_BZOD_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
- NX9_BZOD_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.8.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
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
exec /usr/local/bin/bzod "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
# BZOD Administrator Guide
Version: v0.5.1
Version: v0.8.0
---
+22 -5
View File
@@ -1,6 +1,6 @@
# BZOD Architecture Guide
Version: v0.5.1
Version: v0.8.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.8.0
Applies To: BZOD Multi-User Platform
---
+141
View File
@@ -4,6 +4,147 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog and this project follows Semantic Versioning.
# v0.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening
## Added
* Core Admin is strictly platform-operator-only and has no tenant application storage.
* Inspection-only global URL and landing-page registries for Admin.
* Strict Admin/tenant route boundary with HTTP 403 enforcement.
* TenantId-based active ownership and tenant filesystem topology.
* Tenant-aware analytics worker grouping.
* Deterministic cross-role session invalidation and cookie clearing.
## Changed
* Removed active production dependencies on the legacy `system.db.global_slugs` registry.
* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations.
* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`.
* User login and Admin login now establish role-specific sessions and clear the opposite-role session.
## Compatibility
* Historical v0.7.x migration and legacy restore compatibility remains preserved.
* Legacy database/schema identifiers are retained only where required for migration and historical restore support.
---
---
# 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)
+40 -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.8.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.
---
@@ -269,3 +284,27 @@ bzod doctor
* SECURITY.md
* API.md
* ARCHITECTURE.md
---
# Data Directory Configuration
BZOD requires an explicit physical data directory root. There is **no implicit `./data` fallback**.
### Configuration Precedence
1. **CLI `--data-dir`** (Highest priority)
```bash
bzod serve --data-dir /var/lib/bzod/data
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
```
2. **Environment Variable `NX9_BZOD_DATA_DIR`**
```bash
export NX9_BZOD_DATA_DIR=/var/lib/bzod/data
bzod serve
```
3. **Configuration File (`bzod.toml` / `config.toml`)**
```toml
data_dir = "/var/lib/bzod/data"
```
4. **Error**: If no data directory is provided via CLI, `NX9_BZOD_DATA_DIR`, or config file, BZOD terminates with an actionable error.
+1 -1
View File
@@ -1,4 +1,4 @@
# BZOD v0.5.1 vs Self-Hosted URL Management Platforms
# BZOD v0.8.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.8.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.
+18 -4
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
@@ -86,7 +100,7 @@ services:
environment:
HOST: 0.0.0.0
PORT: 8654
DATA_DIR: /app/data
NX9_BZOD_DATA_DIR: /app/data
COOKIE_SECURE: "false"
healthcheck:
@@ -191,10 +205,10 @@ BZOD Docker Container
# Environment Variables
| Variable | Description | Default |
| ------------- | ------------------ | --------- |
| ----------------- | ------------------ | ------------- |
| HOST | Bind address | 0.0.0.0 |
| PORT | Listen port | 8654 |
| DATA_DIR | Database directory | /app/data |
| NX9_BZOD_DATA_DIR | Database directory | (required) |
| COOKIE_SECURE | Secure cookies | false |
| RUST_LOG | Logging level | info |
+3 -3
View File
@@ -1,6 +1,6 @@
# BZOD Installation Guide
Version: v0.5.1
Version: v0.8.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.8.0-linux-amd64.tar.gz
```
Extract:
```bash
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz
tar -xzf bzod-v0.8.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.8.0
---
+219
View File
@@ -1,3 +1,205 @@
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
Release Date: 2026-08-21
## Highlights
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
## Verification
- Phase 5 Core Separation tests: **4/4 passed**
- Admin capability boundary tests: **11/11 passed**
- Admin/user route and session boundary tests: **27/27 passed**
- Phase 6A elimination tests: **6/6 passed**
- Workspace regression suite: **all tests passed**
- `cargo fmt --all -- --check`: **PASS**
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
---
# 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 +211,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.8.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.8.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.8.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.8.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
+14 -2
View File
@@ -1,11 +1,23 @@
# Upgrade Guide
Version: v0.5.1
Version: v0.8.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.8.0.
---
# BZOD v0.8.0 Upgrade Overview
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
Key upgrade characteristics:
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
* Active production operations no longer use `system.db.global_slugs`.
* Active tenant ownership is represented by immutable `TenantId`.
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
# Overview
BZOD v0.5.1 is a platform hardening release focused on:
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.
+47 -19
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;
}
}
}
}
@@ -47,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
}
info!(
"Flushing {} visits to user analytics databases",
"Flushing {} visits to tenant analytics databases",
batch.len()
);
// Group visits by owner_user_id
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
// Group visits by owner_tenant_id (or legacy user 1 fallback)
let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
std::collections::HashMap::new();
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
for record in batch.drain(..) {
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
groups.entry(user_id).or_default().push(record);
if let Some(tenant_id) = record.owner_tenant_id {
groups.entry(tenant_id).or_default().push(record);
} else if record.owner_user_id == Some(1) {
legacy_user1_visits.push(record);
} else {
error!("Dropping analytics visit with no owner_tenant_id");
}
}
for (user_id, user_visits) in groups {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
if let Some(parent) = db_path.parent() {
let _ = std::fs::create_dir_all(parent);
for (tenant_id, tenant_visits) in groups {
let db_path = db.topology.tenant_analytics_db(tenant_id);
if !db_path.exists() {
error!(
"Refusing to write analytics for non-existent tenant analytics path: {:?}",
db_path
);
continue;
}
match rusqlite::Connection::open(&db_path) {
@@ -74,19 +90,31 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
if let Err(e) = insert_visits_batch(&mut conn, &tenant_visits) {
error!(
"Failed to write analytics batch to user {} database: {:?}",
user_id, e
"Failed to write analytics batch to tenant {} database: {:?}",
tenant_id, e
);
}
}
Err(e) => {
error!(
"Failed to open analytics database for user {}: {:?}",
user_id, e
"Failed to open analytics database for tenant {}: {:?}",
tenant_id, e
);
}
}
}
if !legacy_user1_visits.is_empty() {
if let Ok(legacy_db_path) = db.topology.analytics_db("1") {
if legacy_db_path.exists() {
if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) {
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
let _ = insert_visits_batch(&mut conn, &legacy_user1_visits);
}
}
}
}
}
+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 -39
View File
@@ -198,26 +198,9 @@ pub fn authenticate_user_session(
return Ok(None);
}
// Get tenant user (status must be 'active')
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([session.user_id], |row| {
Ok(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()?;
// Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some)
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
if let Some(user) = user_opt {
Ok(Some((user, session.id)))
@@ -251,25 +234,8 @@ pub fn authenticate_api_key(
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
if let Some(user_id) = user_id_opt {
let mut stmt = users_conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1 AND status = 'active';"
)?;
let user_opt = stmt
.query_row([user_id], |row| {
Ok(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()?;
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
if let Some(user) = user_opt {
return Ok(Some(ApiActor::User(user)));
+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",
};
+149
View File
@@ -0,0 +1,149 @@
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 topology = crate::db::topology::Topology::new(&config.data_dir);
let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?;
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 = topology.content_db_i64(target_admin_id)?;
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(())
}
}
+7 -1
View File
@@ -16,6 +16,10 @@ struct UserBackupMetadata {
created_at: String,
account_type: String,
metadata: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: Option<String>,
quotas: UserBackupQuotas,
}
@@ -94,7 +98,7 @@ pub async fn run(
);
// 4. Force checkpoint on user's databases
let user_dir = config.data_dir.join("users").join(user_id.to_string());
let user_dir = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?;
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
@@ -119,6 +123,8 @@ pub async fn run(
created_at: user.created_at,
account_type: user.account_type,
metadata: user.metadata,
tenant_id: user.tenant_id.map(|t| t.to_string()),
uuid: user.uuid,
quotas,
};
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
+7 -4
View File
@@ -7,6 +7,7 @@ use tracing::{error, info};
pub async fn run(
username: Option<String>,
password: Option<String>,
data_dir: Option<String>,
mut config: Config,
) -> Result<(), Box<dyn std::error::Error>> {
@@ -25,16 +26,18 @@ pub async fn run(
return Ok(());
}
let password = read_input("Enter password: ");
if password.trim().is_empty() {
let final_password = match password {
Some(p) => p,
None => read_input("Enter password: "),
};
if final_password.trim().is_empty() {
error!("Password cannot be empty");
return Ok(());
}
let hash = hash_password(&password).map_err(|e| e.to_string())?;
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
let conn = db.users.lock().unwrap();
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
db.init_user_databases(u.id)?;
info!(
"Successfully created admin user: {} (ID: {})",
u.username, u.id
+44 -23
View File
@@ -15,11 +15,6 @@ pub async fn run(
}
let db = Db::init(&config)?;
if user_id == 1 && !force {
error!("Deleting legacy_admin system account requires --force flag");
return Ok(());
}
// Capture user details
let user_details = {
let conn = db.users.lock().unwrap();
@@ -32,37 +27,63 @@ pub async fn run(
}
};
// 1. Transactional clean up on system.db (deleting their global slug mappings)
{
let mut system_conn = db.system.lock().unwrap();
let tx = system_conn.transaction()?;
if user_details.account_type == "admin" && !force {
error!("Deleting administrator account requires --force flag");
return Ok(());
}
// Get all slugs owned by the user
let slugs: Vec<String> = {
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
let rows = stmt.query_map([user_id], |row| row.get(0))?;
rows.filter_map(|r| r.ok()).collect()
};
// Delete from global_slugs and write to history
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
let now = Utc::now().to_rfc3339();
for slug in slugs {
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
let _ = tx.execute(
if let Some(ref tid) = user_details.tenant_id {
let tid_str = tid.to_string();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let system_conn = db.system.lock().unwrap();
// Get all URL slugs owned by tenant
let mut stmt =
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?;
let url_slugs: Vec<String> = stmt
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Get all page slugs owned by tenant
let mut stmt2 = pages_conn
.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?;
let page_slugs: Vec<String> = stmt2
.query_map([&tid_str], |row| row.get(0))?
.filter_map(|r| r.ok())
.collect();
// Record history and retire/delete slugs
for slug in url_slugs {
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
tx.commit()?;
for slug in page_slugs {
let _ =
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&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, NULL, 'deleted', ?3, ?4);",
rusqlite::params![slug, user_id, now, "cli"],
);
}
}
// 2. Delete user folder and database files from disk
let user_dir = config.data_dir.join("users").join(user_id.to_string());
// 2. Delete tenant directory from disk
if let Some(ref tid) = user_details.tenant_id {
let user_dir = db.topology.tenant_dir(*tid);
if user_dir.exists() {
let _ = std::fs::remove_dir_all(&user_dir);
}
}
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
{
+72 -22
View File
@@ -24,14 +24,27 @@ pub async fn run(
let mut all_healthy = true;
// Define target databases in the new layout
let admin_dir = config.data_dir.join("admin");
let topology = crate::db::topology::Topology::new(&config.data_dir);
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")),
("admin", topology.admin_db()),
("system", topology.system_db()),
("users", topology.users_registry_db()),
("global_urls", topology.global_urls_db()),
("global_landing_pages", topology.global_landing_pages_db()),
("reserved", topology.reserved_db()),
(
"legacy content",
topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
(
"legacy analytics",
topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
),
];
for (db_name, db_path) in dbs {
@@ -90,8 +103,8 @@ pub async fn run(
// Global Slug Registry Integrity Check
println!("Global Slug Registry Integrity Check");
println!("====================================");
let system_db_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
let system_db_path = topology.system_db();
let users_db_path = topology.users_registry_db();
if system_db_path.exists() && users_db_path.exists() {
match (
@@ -99,32 +112,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);
}
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
}
if !warnings.is_empty() {
println!(" Warnings (Attention Needed):");
for warn in &warnings {
println!(" - {}", warn);
);
println!();
println!("Owner:");
println!(" Tenant ID {}", issue.owner_tenant_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;
}
}
+20 -3
View File
@@ -17,11 +17,28 @@ pub async fn run(
return Err("Invalid short code or custom slug format".into());
}
let url_opt = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_by_code(&conn, &normalized_code)?
let owner_info = {
let conn = db.global_urls.lock().unwrap();
conn.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';",
rusqlite::params![normalized_code],
|row| row.get::<_, String>(0),
)
.ok()
};
let tid_str = match owner_info {
Some(t) => t,
None => return Err(format!("Short code not found: {}", normalized_code).into()),
};
let tid = tid_str
.parse::<crate::identity::TenantId>()
.map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?;
let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let url_opt = crate::db::content::get_url_by_code(&conn, &normalized_code)?;
match url_opt {
Some(url) => {
println!("{}", url.destination);
+56
View File
@@ -0,0 +1,56 @@
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 admin_count: i64 = {
let conn = db.users.lock().unwrap();
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 conn = db.users.lock().unwrap();
let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?;
}
info!("Administrator initialized successfully.");
Ok(())
}
+81 -2
View File
@@ -15,12 +15,91 @@ pub async fn run(
if dry_run {
info!("Dry run enabled: pending database migrations will be reported but not applied.");
info!("Data directory: {:?}", config.data_dir);
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, true, false).await?;
println!("\nIdentity Migration Preflight Report (Dry Run):");
println!("----------------------------------------------");
println!("Total users: {}", id_report.total_users);
println!(
"Users needing TenantId: {}",
id_report.users_assigned_tenant_id
);
println!("Users needing UUID: {}", id_report.users_assigned_uuid);
println!("Directories to move: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin (users/1) preserved: {}",
id_report.legacy_admin_preserved
);
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?;
println!("\nGlobal Slug Migration Preflight Report (Dry Run):");
println!("-------------------------------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated);
println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated);
println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated);
return Ok(());
}
info!("Running database migrations...");
info!("Running database schema migrations...");
let _db = Db::init(&config)?;
info!("Database migrations applied successfully.");
info!("Database schema migrations applied successfully.");
info!("Running identity & directory migration lifecycle...");
let id_report =
crate::db::identity_migrate::run_identity_migration(&config, false, false).await?;
println!("\nIdentity Migration Report:");
println!("--------------------------");
println!("Total users: {}", id_report.total_users);
println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id);
println!("UUIDs assigned: {}", id_report.users_assigned_uuid);
println!("Directories migrated: {}", id_report.directories_moved);
println!(
"Directories already migrated: {}",
id_report.directories_already_migrated
);
println!(
"Legacy admin preserved: {}",
id_report.legacy_admin_preserved
);
println!("Validation passed: {}", id_report.validation_passed);
if !id_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &id_report.warnings {
println!(" - {}", w);
}
}
info!("Running global slug migration lifecycle...");
let slug_report =
crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?;
println!("\nGlobal Slug Migration Report:");
println!("-----------------------------");
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
println!("URL slugs migrated: {}", slug_report.url_slugs_migrated);
println!("Page slugs migrated: {}", slug_report.page_slugs_migrated);
println!(
"Reserved slugs verified: {}",
slug_report.reserved_slugs_migrated
);
println!(
"Existing target records verified: {}",
slug_report.existing_records_verified
);
println!("Validation passed: {}", slug_report.validation_passed);
if !slug_report.warnings.is_empty() {
println!("\nWarnings:");
for w in &slug_report.warnings {
println!(" - {}", w);
}
}
Ok(())
}
+87 -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,68 @@ 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>,
},
}
impl Commands {
pub fn data_dir(&self) -> Option<&str> {
match self {
Commands::Serve { data_dir, .. } => data_dir.as_deref(),
Commands::Backup { data_dir, .. } => data_dir.as_deref(),
Commands::Restore { data_dir, .. } => data_dir.as_deref(),
Commands::Migrate { data_dir, .. } => data_dir.as_deref(),
Commands::Stats { data_dir, .. } => data_dir.as_deref(),
Commands::Validate { data_dir, .. } => data_dir.as_deref(),
Commands::AuditDestinations { data_dir, .. } => data_dir.as_deref(),
Commands::CreateAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::InitAdmin { data_dir, .. } => data_dir.as_deref(),
Commands::Doctor { data_dir, .. } => data_dir.as_deref(),
Commands::Shorten { data_dir, .. } => data_dir.as_deref(),
Commands::Expand { data_dir, .. } => data_dir.as_deref(),
Commands::CreateUser { data_dir, .. } => data_dir.as_deref(),
Commands::DeleteUser { data_dir, .. } => data_dir.as_deref(),
Commands::DisableUser { data_dir, .. } => data_dir.as_deref(),
Commands::EnableUser { data_dir, .. } => data_dir.as_deref(),
Commands::ResetPassword { data_dir, .. } => data_dir.as_deref(),
Commands::ListUsers { data_dir, .. } => data_dir.as_deref(),
Commands::BackupUser { data_dir, .. } => data_dir.as_deref(),
Commands::RestoreUser { data_dir, .. } => data_dir.as_deref(),
Commands::AdminMigrate { data_dir, .. } => data_dir.as_deref(),
Commands::Repair { command } => match command {
RepairCommands::Registry { data_dir, .. } => data_dir.as_deref(),
},
}
}
}
+196
View File
@@ -0,0 +1,196 @@
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 topology = crate::db::topology::Topology::new(&config.data_dir);
let system_db_path = topology.system_db();
let users_db_path = topology.users_registry_db();
let urls_db_path = topology.global_urls_db();
let pages_db_path = topology.global_landing_pages_db();
if !system_db_path.exists()
|| !users_db_path.exists()
|| !urls_db_path.exists()
|| !pages_db_path.exists()
{
println!("Error: Core databases (system.db, users.db, slugs/*.db) not found.");
return Ok(());
}
let sys_conn = Connection::open(&system_db_path)?;
let usr_conn = Connection::open(&users_db_path)?;
let mut urls_conn = Connection::open(&urls_db_path)?;
let mut pages_conn = Connection::open(&pages_db_path)?;
let slug_filter = slug.as_deref();
if dry_run {
println!("BZOD Registry Repair (v0.8)\n");
println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)...");
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
let true_orphans = issues
.iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingTenant
)
})
.collect::<Vec<_>>();
let corrupt = issues
.iter()
.filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase)
.collect::<Vec<_>>();
let access_failures = issues
.iter()
.filter(|i| i.issue_type == RegistryIssueType::AccessFailure)
.collect::<Vec<_>>();
println!("\nDetected Issues (Total: {}):", issues.len());
println!(" Orphaned/Missing Targets: {}", true_orphans.len());
println!(" Corrupt Databases (Protected): {}", corrupt.len());
println!(" Access Failures (Protected): {}", access_failures.len());
if !true_orphans.is_empty() {
println!("\nThe following orphaned entries would be repaired/removed:");
for issue in &true_orphans {
println!(
" {} [{}] — Tenant: {}",
issue.slug,
issue.target_type.to_uppercase(),
issue.owner_tenant_id
);
}
}
if !corrupt.is_empty() {
println!("\nProtected from destructive repair (Corrupt DBs):");
for issue in &corrupt {
println!(
" {} [{}] — Path: {:?}",
issue.slug,
issue.target_type.to_uppercase(),
issue.database_path
);
}
}
println!("\nNo changes have been made (dry-run).");
println!(
"Run again with:\n bzod repair registry --force{}",
if let Some(s) = slug_filter {
format!(" --slug {}", s)
} else {
"".to_string()
}
);
info!(
"Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}",
true_orphans.len(),
corrupt.len(),
start_time.elapsed()
);
} else if force {
let issues =
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
// Only repair true orphans, missing targets, or missing tenants.
// Never delete records with CorruptDatabase or AccessFailure per safety policies.
let repairable = issues
.into_iter()
.filter(|i| {
matches!(
i.issue_type,
RegistryIssueType::MissingTarget
| RegistryIssueType::TrueOrphan
| RegistryIssueType::MissingTenant
)
})
.collect::<Vec<_>>();
if repairable.is_empty() {
println!("No repairable registry issues found.");
return Ok(());
}
let tx_urls = urls_conn.transaction()?;
let tx_pages = pages_conn.transaction()?;
let mut removed_count = 0;
for issue in &repairable {
if issue.target_type == "url" {
removed_count += tx_urls
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
} else {
removed_count += tx_pages.execute(
"DELETE FROM global_landing_pages WHERE slug = ?1;",
[&issue.slug],
)?;
}
}
tx_urls.commit()?;
tx_pages.commit()?;
// Record audit event in system.db
let _ = crate::db::audit_events::write_audit_event(
&sys_conn,
"cli",
"REGISTRY_REPAIR",
"registry",
slug_filter.unwrap_or("*"),
Some(&format!(
"Repaired/removed {} orphaned slug entries",
removed_count
)),
);
println!("Registry Repair Complete.");
println!("Repaired/Removed: {} entries", removed_count);
info!(
"Registry Repair Complete. Removed: {}. Duration: {:?}",
removed_count,
start_time.elapsed()
);
}
}
}
Ok(())
}
+297 -25
View File
@@ -1,21 +1,246 @@
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::Conflict
| RegistryIssueType::InvalidTargetType
| RegistryIssueType::InvalidStatus => {
errors.push(issue);
}
RegistryIssueType::MissingTenant if !is_legacy => {
errors.push(issue);
}
_ => {
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,9 +250,34 @@ pub fn perform_restore(
return Err(e.into());
}
// 3. Run validation on temp_dir
let mut temp_config = Config::load();
temp_config.data_dir = temp_dir.clone();
// 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 temp_config = Config::load_with_cli(Some(&temp_dir))?;
// Namespace audit
match crate::db::users::audit_slug_namespace(&temp_config) {
@@ -46,29 +296,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() {
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: {:?}", errors).into());
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 +350,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?;
+272 -30
View File
@@ -1,9 +1,13 @@
use crate::config::Config;
use crate::db::Db;
use crate::identity::TenantId;
use chrono::Utc;
use rusqlite::OptionalExtension;
use std::fs::File;
use std::path::PathBuf;
use tar::Archive;
use tracing::{error, info};
use uuid::Uuid;
use zstd::Decoder;
#[derive(serde::Serialize, serde::Deserialize)]
@@ -15,6 +19,10 @@ struct UserBackupMetadata {
created_at: String,
account_type: String,
metadata: Option<String>,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
uuid: Option<String>,
quotas: UserBackupQuotas,
}
@@ -70,26 +78,72 @@ pub async fn run(
info!("Restoring user {} from backup...", metadata.username);
// 2. Resolve target user ID and upsert user record in users.db
let target_user_id = {
// 2. Resolve identity per Correction #1:
// Order:
// 1. Archive contains TenantId + UUID -> preserve exactly.
// 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID.
// 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID.
let (target_user_id, target_tenant_id) = {
let users_conn = db.users.lock().unwrap();
let existing_user =
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
match existing_user {
Some(u) => {
let tenant_id = if let Some(tid) = u.tenant_id {
tid
} else if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid) = u.uuid {
uid.clone()
} else if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
users_conn.execute(
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;",
rusqlite::params![
metadata.password_hash,
metadata.status,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid,
u.id
],
)?;
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
rusqlite::params![
u.id,
metadata.quotas.max_urls,
metadata.quotas.max_landings,
metadata.quotas.max_api_tokens,
metadata.quotas.max_storage_mb
],
)?;
u.id
(u.id, tenant_id)
}
None => {
let tenant_id = if let Some(ref tid_str) = metadata.tenant_id {
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
} else {
TenantId::generate()
};
let user_uuid = if let Some(ref uid_str) = metadata.uuid {
uid_str.clone()
} else {
Uuid::new_v4().to_string()
};
let id_taken: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
@@ -100,16 +154,35 @@ pub async fn run(
let new_id = if !id_taken {
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![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
rusqlite::params![
metadata.id,
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?;
metadata.id
} else {
users_conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
rusqlite::params![
metadata.username,
metadata.password_hash,
metadata.status,
metadata.created_at,
metadata.account_type,
metadata.metadata,
tenant_id.as_str(),
user_uuid
],
)?;
users_conn.last_insert_rowid()
};
@@ -117,19 +190,23 @@ pub async fn run(
users_conn.execute(
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
VALUES (?1, ?2, ?3, ?4, ?5);",
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
rusqlite::params![
new_id,
metadata.quotas.max_urls,
metadata.quotas.max_landings,
metadata.quotas.max_api_tokens,
metadata.quotas.max_storage_mb
],
)?;
new_id
(new_id, tenant_id)
}
}
};
// 3. Extract database files to /data/users/<target_user_id>/
let dest_dir = config
.data_dir
.join("users")
.join(target_user_id.to_string());
// 3. Extract database files to /data/users/<TenantId>/
let dest_dir = db.topology.tenant_dir(target_tenant_id);
std::fs::create_dir_all(&dest_dir)?;
std::fs::create_dir_all(dest_dir.join("extensions"))?;
let f2 = File::open(&file_path)?;
let zst_dec2 = Decoder::new(f2)?;
@@ -156,27 +233,192 @@ pub async fn run(
}
}
// 4. Register slugs in global_slugs using the shared helper
{
let system_conn = db.system.lock().unwrap();
crate::db::users::register_restored_user_slugs(
&system_conn,
target_user_id,
&dest_dir.join("content.db"),
)?;
// 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
let content_path = dest_dir.join("content.db");
if content_path.exists() {
let restored_content_conn = rusqlite::Connection::open(&content_path)?;
let now = Utc::now().to_rfc3339();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
// 4a. Validate URL slugs for collisions
let mut url_slugs = Vec::new();
let mut stmt =
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
// Check collision with global_urls
let existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
// Check collision with global_landing_pages
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})"
)
.into());
}
}
// Check reserved
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: URL slug '{code}' is a reserved system keyword"
)
.into());
}
url_slugs.push((code, target_id, created_at, global_status));
}
// 4b. Validate Landing Page slugs for collisions
let mut page_slugs = Vec::new();
let mut stmt = restored_content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let code: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let global_status = if state == "published" {
"active"
} else {
"disabled"
};
let existing_url_owner: Option<String> = urls_conn
.query_row(
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_url_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})"
)
.into());
}
}
let existing_page_owner: Option<String> = pages_conn
.query_row(
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
[&code],
|r| r.get(0),
)
.optional()?;
if let Some(ref owner) = existing_page_owner {
if owner != target_tenant_id.as_str() {
return Err(format!(
"Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})"
)
.into());
}
}
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&code],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
return Err(format!(
"Slug collision: Landing page slug '{code}' is a reserved system keyword"
)
.into());
}
page_slugs.push((code, target_id, created_at, global_status));
}
// 4c. Register validated URL slugs
for (code, target_id, created_at, global_status) in url_slugs {
let _ = urls_conn.execute(
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
}
// 4d. Register validated Landing Page slugs
for (code, target_id, created_at, global_status) in page_slugs {
let _ = pages_conn.execute(
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
rusqlite::params![
code,
target_tenant_id.as_str(),
target_id,
created_at,
now,
global_status
],
);
}
// 5. Reconcile quotas for restored user
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
crate::db::users::reconcile_user_quotas(
&db.users.lock().unwrap(),
target_user_id,
&restored_content_conn,
)?;
}
info!(
"User '{}' (ID: {}) successfully restored from backup.",
metadata.username, target_user_id
"User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
metadata.username, target_user_id, target_tenant_id
);
Ok(())
}
+90 -16
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;
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).await;
});
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;
let rx = shutdown_rx.clone();
join_handles.push((
"aggregator",
tokio::spawn(async move {
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
});
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
}),
));
let retention_db = db.clone();
let retention_days = config.data_retention_days;
let rx = shutdown_rx.clone();
join_handles.push((
"retention_cleaner",
tokio::spawn(async move {
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
});
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();
let rx = shutdown_rx.clone();
join_handles.push((
"backup_scheduler",
tokio::spawn(async move {
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
});
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
}),
));
let expiry_db = db.clone();
let rx = shutdown_rx.clone();
join_handles.push((
"expiry_checker",
tokio::spawn(async move {
crate::jobs::run_expiry_checker(expiry_db).await;
});
crate::jobs::run_expiry_checker(expiry_db, rx).await;
}),
));
let reconcile_db = db.clone();
let reconcile_interval_hours = {
@@ -75,14 +123,16 @@ pub async fn run(
.and_then(|val| val.parse::<u64>().ok())
.unwrap_or(24)
};
let rx = shutdown_rx.clone();
join_handles.push((
"quota_reconciliation",
tokio::spawn(async move {
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
});
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
}),
));
let state = AppState {
admin_db: db.admin.clone(),
content_db: db.content.clone(),
analytics_db: db.analytics.clone(),
system_db: db.system.clone(),
users_db: db.users.clone(),
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
@@ -98,7 +148,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(())
}
+39 -17
View File
@@ -18,6 +18,24 @@ pub async fn run(
}
let db = Db::init(&config)?;
// Resolve active standard user tenant
let (user_id, tid) = {
let users_conn = db.users.lock().unwrap();
let users = crate::db::users::list_users(&users_conn)?;
let active_user = users.into_iter().find(|u| {
u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some()
});
match active_user {
Some(u) => (u.id, u.tenant_id.unwrap()),
None => {
return Err(
"Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`."
.into(),
)
}
}
};
// 2. Validate/normalize slug/code
let code = match slug {
Some(s) => {
@@ -33,17 +51,24 @@ pub async fn run(
None => crate::utils::random::generate_token(3),
};
// 3. Register slug in system.db with status 'reserving' and check availability
// 3. Register slug in global_urls with status 'reserving'
{
let system_conn = db.system.lock().unwrap();
if !crate::db::users::is_slug_available(&system_conn, &code)? {
return Err("Short code/slug already exists".into());
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
if let Err(e) =
crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid)
{
return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into());
}
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
}
// 4. Persist URL
let conn = db.content.lock().unwrap();
// 4. Persist URL in tenant content DB
let content_path = db.topology.tenant_content_db(tid);
let conn = rusqlite::Connection::open(&content_path)?;
let _ = crate::db::sqlite::enable_wal(&conn, "content");
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content");
let res = crate::db::content::create_url_extended(
&conn,
&code,
@@ -58,18 +83,15 @@ pub async fn run(
match res {
Ok(url) => {
// Activate slug in system.db
// Activate slug in global_urls
{
let system_conn = db.system.lock().unwrap();
system_conn.execute(
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
)?;
let urls_conn = db.global_urls.lock().unwrap();
crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
}
// Increment quota for user ID 1
// Increment quota
{
let users_conn = db.users.lock().unwrap();
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
crate::db::users::increment_quota_counter(&users_conn, user_id, "urls")?;
}
let proto = if config.cookie_secure {
@@ -87,8 +109,8 @@ pub async fn run(
Ok(())
}
Err(e) => {
let system_conn = db.system.lock().unwrap();
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
let urls_conn = db.global_urls.lock().unwrap();
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
Err(e.into())
}
}
+58 -12
View File
@@ -14,14 +14,34 @@ 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", db.topology.admin_db()),
("system.db", db.topology.system_db()),
("users.db", db.topology.users_registry_db()),
("global_urls.db", db.topology.global_urls_db()),
(
"global_landing_pages.db",
db.topology.global_landing_pages_db(),
),
("reserved.db", db.topology.reserved_db()),
(
"legacy content.db",
db.topology
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
),
(
"legacy analytics.db",
db.topology
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
),
];
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
);
@@ -35,8 +55,29 @@ pub async fn run(
println!("Users Count: {}", users_count);
let (urls_total, urls_active, urls_dead) = {
let conn = db.content.lock().unwrap();
crate::db::content::get_url_counts(&conn)?
let conn = db.global_urls.lock().unwrap();
let total: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let active: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
[],
|r| r.get(0),
)
.unwrap_or(0);
let dead: i64 = conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
[],
|r| r.get(0),
)
.unwrap_or(0);
(total, active, dead)
};
println!(
"Shortened URLs: {} total ({} active / {} dead)",
@@ -44,14 +85,19 @@ pub async fn run(
);
let pages_count = {
let conn = db.content.lock().unwrap();
crate::db::content::get_landing_page_count(&conn)?
let conn = db.global_landing_pages.lock().unwrap();
conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
[],
|r| r.get(0),
)
.unwrap_or(0)
};
println!("Landing Pages: {}", pages_count);
let total_visits = {
let conn = db.analytics.lock().unwrap();
crate::db::analytics::get_total_clicks(&conn)?
let users_conn = db.users.lock().unwrap();
crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
};
println!("Redirect Clicks: {}", total_visits);
+101 -15
View File
@@ -3,6 +3,45 @@ use std::env;
use std::fs;
use std::path::PathBuf;
pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
MissingDataDir,
InvalidConfig(String),
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingDataDir => write!(
f,
"data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=<path>"
),
Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"),
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DataDirSource {
Cli,
Env,
ConfigFile,
}
impl std::fmt::Display for DataDirSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Cli => write!(f, "CLI"),
Self::Env => write!(f, "NX9_BZOD_DATA_DIR"),
Self::ConfigFile => write!(f, "config.toml"),
}
}
}
#[derive(Clone, Debug)]
pub struct Config {
pub host: String,
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
}
impl Config {
pub fn load() -> Self {
// 1. Built-in defaults
pub fn load() -> Result<Self, ConfigError> {
Self::load_with_cli(None::<&std::path::Path>)
}
pub fn load_with_cli<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
) -> Result<Self, ConfigError> {
Self::load_from_sources(cli_data_dir, true)
}
pub fn load_from_sources<P: AsRef<std::path::Path>>(
cli_data_dir: Option<P>,
load_dotenv: bool,
) -> Result<Self, ConfigError> {
// 1. Built-in defaults (no implicit data_dir default)
let mut host = "0.0.0.0".to_string();
let mut port = 8080u16;
let mut data_dir = PathBuf::from("./data");
let mut admin_username = "admin".to_string();
let mut bootstrap_password_sha256 = "".to_string();
let mut session_secret =
@@ -63,12 +114,18 @@ impl Config {
let mut backup_dir = PathBuf::from("./backups");
let mut base_url = None;
// 2. Load bzod.toml if it exists
let mut toml_path = "bzod.toml".to_string();
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
toml_path = "config/bzod.toml".to_string();
}
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
let mut resolved_data_dir: Option<PathBuf> = None;
let mut data_dir_source: Option<DataDirSource> = None;
// 2. Load bzod.toml / config.toml if it exists
let possible_tomls = [
"bzod.toml",
"config/bzod.toml",
"config.toml",
"config/config.toml",
];
for toml_path in possible_tomls {
if let Ok(toml_content) = fs::read_to_string(toml_path) {
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
if let Some(h) = toml_config.host {
host = h;
@@ -77,7 +134,10 @@ impl Config {
port = p;
}
if let Some(d) = toml_config.data_dir {
data_dir = PathBuf::from(d);
if !d.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d));
data_dir_source = Some(DataDirSource::ConfigFile);
}
}
if let Some(u) = toml_config.admin_username {
admin_username = u;
@@ -118,16 +178,20 @@ impl Config {
if let Some(bu) = toml_config.base_url {
base_url = Some(bu);
}
break;
}
}
}
// 3. Load .env if present
if load_dotenv {
let _ = dotenvy::dotenv();
if fs::metadata("config/.env").is_ok() {
let _ = dotenvy::from_path("config/.env");
}
}
// 4. Load from Environment Variables (taking highest precedence)
// 4. Load from Environment Variables (taking precedence over config file)
if let Ok(h) = env::var("HOST") {
host = h;
}
@@ -136,8 +200,11 @@ impl Config {
port = p;
}
}
if let Ok(d_str) = env::var("DATA_DIR") {
data_dir = PathBuf::from(d_str);
if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
if !d_str.trim().is_empty() {
resolved_data_dir = Some(PathBuf::from(d_str));
data_dir_source = Some(DataDirSource::Env);
}
}
if let Ok(u) = env::var("ADMIN_USERNAME") {
admin_username = u;
@@ -187,7 +254,26 @@ impl Config {
base_url = Some(bu);
}
Self {
// 5. CLI override (highest precedence)
if let Some(cli_dir) = cli_data_dir {
let path_ref = cli_dir.as_ref();
if !path_ref.as_os_str().is_empty() {
resolved_data_dir = Some(path_ref.to_path_buf());
data_dir_source = Some(DataDirSource::Cli);
}
}
let data_dir = match resolved_data_dir {
Some(dir) => dir,
None => return Err(ConfigError::MissingDataDir),
};
if let Some(source) = data_dir_source {
tracing::info!("Data directory: {}", data_dir.display());
tracing::info!("Data directory source: {}", source);
}
Ok(Self {
host,
port,
data_dir,
@@ -202,6 +288,6 @@ impl Config {
backup_interval_mins,
backup_dir,
base_url,
}
})
}
}
+2
View File
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?,
})
})?;
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
accept_language: row.get("accept_language")?,
country: row.get("country")?,
status_code: row.get("status_code")?,
owner_tenant_id: None,
owner_user_id: row.get("owner_user_id")?,
})
})?;
+398
View File
@@ -0,0 +1,398 @@
//! Explicit Phase 3 Identity & Directory Migration Engine.
//!
//! Lifecycle:
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
//! 2. Backup (create pre-migration tarball)
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
//! 5. Validation (verify all users, directories, and databases)
//! 6. Completion Marker (record audit event and system setting)
//!
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
//! to a normal TenantId directory.
use rusqlite::Connection;
use std::fs;
use std::path::PathBuf;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::{is_v08_user_id, Topology};
use crate::db::Db;
use crate::identity::TenantId;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct IdentityMigrationReport {
pub total_users: usize,
pub users_assigned_tenant_id: usize,
pub users_assigned_uuid: usize,
pub directories_moved: usize,
pub directories_already_migrated: usize,
pub legacy_admin_preserved: bool,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct PreflightPlan {
pub total_users: usize,
pub normal_users: usize,
pub users_needing_tenant_id: Vec<(i64, String)>,
pub users_needing_uuid: Vec<(i64, String)>,
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
pub directories_already_migrated: usize,
}
/// Run the full explicit identity migration lifecycle.
pub async fn run_identity_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
let topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db for Core connections
let db = Db::init(config)?;
// 2. Preflight
let plan = {
let users_conn = db.users.lock().unwrap();
inspect_preflight(&users_conn, &topology)?
};
info!(
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
plan.total_users,
plan.normal_users,
plan.users_needing_tenant_id.len(),
plan.users_needing_uuid.len(),
plan.directories_to_move.len()
);
if dry_run {
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
return Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
users_assigned_uuid: plan.users_needing_uuid.len(),
directories_moved: plan.directories_to_move.len(),
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if there is work to do and backup is not skipped)
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|| !plan.users_needing_uuid.is_empty()
|| !plan.directories_to_move.is_empty();
if needs_migration && !skip_backup {
info!("Creating pre-migration backup...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Identity Migration (users.db backfill)
let (assigned_tids, assigned_uuids) = {
let mut users_conn = db.users.lock().unwrap();
migrate_user_table_identities(&mut users_conn)?
};
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
let moved_dirs = {
let users_conn = db.users.lock().unwrap();
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
};
// 6. Validation
let validation_passed = {
let users_conn = db.users.lock().unwrap();
validate_identity_migration(&users_conn, &topology, &mut warnings)?
};
// 7. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
assigned_tids, assigned_uuids, moved_dirs
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"IDENTITY_MIGRATION_COMPLETED",
"identity",
"users.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
[&now],
);
}
Ok(IdentityMigrationReport {
total_users: plan.total_users,
users_assigned_tenant_id: assigned_tids,
users_assigned_uuid: assigned_uuids,
directories_moved: moved_dirs,
directories_already_migrated: plan.directories_already_migrated,
legacy_admin_preserved: true,
validation_passed,
warnings,
})
}
/// Inspect existing database and filesystem to build a preflight plan.
pub fn inspect_preflight(
users_conn: &Connection,
topology: &Topology,
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let total_users = users.len();
let mut normal_users = 0;
let mut users_needing_tenant_id = Vec::new();
let mut users_needing_uuid = Vec::new();
let mut directories_to_move = Vec::new();
let mut directories_already_migrated = 0;
for user in &users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
normal_users += 1;
if user.tenant_id.is_none() {
users_needing_tenant_id.push((user.id, user.username.clone()));
}
if user.uuid.is_none() {
users_needing_uuid.push((user.id, user.username.clone()));
}
if let Some(tid) = user.tenant_id {
let legacy_dir = topology.user_dir_i64(user.id)?;
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
directories_to_move.push((user.id, legacy_dir, tid));
} else if target_dir.exists() {
directories_already_migrated += 1;
}
}
}
Ok(PreflightPlan {
total_users,
normal_users,
users_needing_tenant_id,
users_needing_uuid,
directories_to_move,
directories_already_migrated,
})
}
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
/// Restart-safe: never regenerates or modifies existing identities.
pub fn migrate_user_table_identities(
users_conn: &mut Connection,
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut assigned_tids = 0;
let mut assigned_uuids = 0;
let tx = users_conn.transaction()?;
for user in users {
// Skip legacy admin / system accounts
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
let mut needs_update = false;
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
let mut uuid_str = user.uuid;
if tid_str.is_none() {
// Allocate unique TenantId
let tid = crate::identity::TenantId::generate();
tid_str = Some(tid.as_str().to_string());
assigned_tids += 1;
needs_update = true;
}
if uuid_str.is_none() {
// Allocate unique UUID
let u = uuid::Uuid::new_v4().to_string();
uuid_str = Some(u);
assigned_uuids += 1;
needs_update = true;
}
if needs_update {
tx.execute(
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
rusqlite::params![tid_str, uuid_str, user.id],
)?;
}
}
tx.commit()?;
Ok((assigned_tids, assigned_uuids))
}
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
/// Legacy users/1 is preserved.
pub fn migrate_tenant_directories(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<usize, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut moved = 0;
for user in users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
// Preserve Core / system accounts intact
continue;
}
let Some(tid) = user.tenant_id else {
warnings.push(format!(
"User '{}' (ID {}) has no TenantId; skipping directory move",
user.username, user.id
));
continue;
};
let legacy_dir = match topology.user_dir_i64(user.id) {
Ok(d) => d,
Err(_) => continue,
};
let target_dir = topology.tenant_dir(tid);
if legacy_dir.exists() && legacy_dir != target_dir {
if !target_dir.exists() {
// Atomic rename on same filesystem
fs::rename(&legacy_dir, &target_dir)?;
let _ = fs::create_dir_all(target_dir.join("extensions"));
info!(
"Migrated tenant directory for user '{}': {:?} -> {:?}",
user.username, legacy_dir, target_dir
);
moved += 1;
} else {
// Target already exists (interrupted / partial run)
warn!(
"Target directory {:?} already exists for user '{}'. Verifying contents.",
target_dir, user.username
);
// Ensure extensions dir exists
let _ = fs::create_dir_all(target_dir.join("extensions"));
// If legacy directory is now empty or duplicate, clean it up safely
if let Ok(entries) = fs::read_dir(&legacy_dir) {
if entries.count() == 0 {
let _ = fs::remove_dir(&legacy_dir);
}
}
}
}
}
Ok(moved)
}
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
pub fn validate_identity_migration(
users_conn: &Connection,
topology: &Topology,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let users = crate::db::users::list_users(users_conn)?;
let mut valid = true;
for user in &users {
if user.account_type == "admin"
|| user.account_type == "system"
|| user.username == "legacy_admin"
{
continue;
}
// Validate TenantId
match user.tenant_id {
Some(tid) => {
if !is_v08_user_id(tid.as_str()) {
warnings.push(format!(
"Invalid TenantId format '{}' for user '{}'",
tid.as_str(),
user.username
));
valid = false;
}
let target_dir = topology.tenant_dir(tid);
if !target_dir.exists() {
// Check if content db was initialized or if directory was not yet created
warnings.push(format!(
"Tenant directory {:?} does not exist for user '{}'",
target_dir, user.username
));
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing TenantId",
user.username, user.id
));
valid = false;
}
}
// Validate UUID
match &user.uuid {
Some(u) => {
if uuid::Uuid::parse_str(u).is_err() {
warnings.push(format!(
"Invalid UUID format '{}' for user '{}'",
u, user.username
));
valid = false;
}
}
None => {
warnings.push(format!(
"Normal user '{}' (ID {}) is missing UUID",
user.username, user.id
));
valid = false;
}
}
}
Ok(valid)
}
+35 -1
View File
@@ -35,7 +35,21 @@ pub fn run_migrations(
);
let tx = conn.transaction()?;
tx.execute_batch(m.sql)?;
match tx.execute_batch(m.sql) {
Ok(()) => (),
Err(e) => {
let err_msg = e.to_string();
if err_msg.contains("duplicate column name") {
tracing::warn!(
database = db_name,
version = m.version,
"Column already exists during migration, continuing"
);
} else {
return Err(e.into());
}
}
}
tx.commit()?;
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
@@ -568,4 +582,24 @@ pub const USERS_MIGRATIONS: &[Migration] = &[
WHERE username = 'admin' AND account_type = 'standard';
"#,
},
Migration {
version: 3,
name: "tenant_id",
sql: r#"
ALTER TABLE users ADD COLUMN tenant_id TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
ON users(tenant_id)
WHERE tenant_id IS NOT NULL;
"#,
},
Migration {
version: 4,
name: "uuid",
sql: r#"
ALTER TABLE users ADD COLUMN uuid TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
ON users(uuid)
WHERE uuid IS NOT NULL;
"#,
},
];
+92 -231
View File
@@ -7,50 +7,71 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
use rusqlite::Connection;
use std::fs;
use std::sync::{Arc, Mutex};
use tracing::info;
pub mod admin;
pub mod analytics;
pub mod audit_events;
pub mod content;
pub mod identity_migrate;
pub mod migrations;
pub mod preview;
pub mod qr;
pub mod schema_v08;
pub mod slug_migrate;
pub mod slugs;
pub mod sqlite;
pub mod tenant;
pub mod topology;
pub mod users;
use crate::db::schema_v08::{
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
};
use crate::db::topology::Topology;
#[derive(Clone)]
pub struct Db {
pub admin: Arc<Mutex<Connection>>,
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub system: Arc<Mutex<Connection>>,
pub users: Arc<Mutex<Connection>>,
pub global_urls: Arc<Mutex<Connection>>,
pub global_landing_pages: Arc<Mutex<Connection>>,
pub reserved: Arc<Mutex<Connection>>,
pub data_dir: std::path::PathBuf,
pub topology: Topology,
}
fn open_prepared(
path: &std::path::Path,
name: &str,
) -> Result<Connection, Box<dyn std::error::Error>> {
info!("Opening {}.db", name);
let conn = Connection::open(path)?;
enable_wal(&conn, name)?;
enable_foreign_keys(&conn, name)?;
Ok(conn)
}
impl Db {
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
use chrono::Utc;
use tracing::info;
// Ensure data directory exists
if !config.data_dir.exists() {
fs::create_dir_all(&config.data_dir)?;
let topology = Topology::new(&config.data_dir);
if !topology.root().exists() {
fs::create_dir_all(topology.root())?;
}
topology.ensure_core_dirs()?;
let admin_dir = config.data_dir.join("admin");
let users_dir = config.data_dir.join("users");
fs::create_dir_all(&admin_dir)?;
fs::create_dir_all(&users_dir)?;
let admin_dir = topology.admin_dir();
// Automated Legacy Migration: check if legacy files are at the root
let legacy_admin_db = config.data_dir.join("admin.db");
let legacy_content_db = config.data_dir.join("content.db");
let legacy_analytics_db = config.data_dir.join("analytics.db");
let legacy_admin_db = topology.legacy_flat_admin_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",
@@ -60,7 +81,7 @@ impl Db {
"system.db-shm",
];
for f in files {
let src = config.data_dir.join(f);
let src = topology.root().join(f);
if src.exists() {
let dst = admin_dir.join(f);
let _ = fs::rename(&src, &dst);
@@ -88,24 +109,9 @@ impl Db {
}
}
let admin_path = admin_dir.join("admin.db");
let system_path = admin_dir.join("system.db");
let users_db_path = admin_dir.join("users.db");
info!("Opening admin.db");
let mut admin_conn = Connection::open(admin_path)?;
info!("Opening system.db");
let mut system_conn = Connection::open(system_path)?;
info!("Opening users.db");
let mut users_conn = Connection::open(users_db_path)?;
enable_wal(&admin_conn, "admin")?;
enable_wal(&system_conn, "system")?;
enable_wal(&users_conn, "users")?;
enable_foreign_keys(&admin_conn, "admin")?;
enable_foreign_keys(&system_conn, "system")?;
enable_foreign_keys(&users_conn, "users")?;
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
// Run migrations for system.db first
info!("Running system migrations");
@@ -168,182 +174,54 @@ impl Db {
let now = Utc::now().to_rfc3339();
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
let mut global_landing_pages_conn =
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
// Ensure legacy_admin (user ID 1) exists in users.db
let legacy_admin_id = 1i64;
let legacy_admin_exists: bool = users_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
[legacy_admin_id],
|row| row.get(0),
)
.unwrap_or(false);
if !legacy_admin_exists {
// Get copied administrator password hash
let admin_password_hash: String = admin_conn
.query_row(
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
[],
|row| row.get(0),
)
.unwrap_or_else(|_| {
// If admin_db is empty, hash a default password
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
});
let now = 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![
legacy_admin_id,
"legacy_admin",
admin_password_hash,
"disabled",
now,
"system"
],
)?;
// Seed quotas
users_conn.execute(
"INSERT INTO quotas (user_id) VALUES (?1);",
[legacy_admin_id],
)?;
}
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
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...");
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
for f in content_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
for f in analytics_files {
let src = config.data_dir.join(f);
if src.exists() {
let dst = legacy_user_dir.join(f);
let _ = fs::rename(&src, &dst);
}
}
}
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
let content_path = legacy_user_dir.join("content.db");
let analytics_path = legacy_user_dir.join("analytics.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
enable_wal(&content_conn, "content")?;
enable_wal(&analytics_conn, "analytics")?;
enable_foreign_keys(&content_conn, "content")?;
enable_foreign_keys(&analytics_conn, "analytics")?;
// Run migrations for content.db and analytics.db
run_migrations(
&mut content_conn,
"content",
CONTENT_MIGRATIONS,
&mut global_urls_conn,
"global_urls",
GLOBAL_URLS_MIGRATIONS,
Some(&system_arc),
)?;
run_migrations(
&mut analytics_conn,
"analytics",
ANALYTICS_MIGRATIONS,
&mut global_landing_pages_conn,
"global_landing_pages",
GLOBAL_LANDING_PAGES_MIGRATIONS,
Some(&system_arc),
)?;
// If we just migrated legacy content, populate the global_slugs table in system.db
if legacy_migration_needed {
info!("Populating global slug index with legacy content...");
let mut sys_lock = system_arc.lock().unwrap();
let tx = sys_lock.transaction()?;
// Extract urls from content.db and insert into global_slugs
{
let mut stmt =
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let status: String = row.get(3)?;
let global_status = if status == "dead" {
"disabled"
} else {
"active"
};
let now = Utc::now().to_rfc3339();
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
);
}
}
// Extract landing pages from content.db and insert into global_slugs
{
let mut stmt = content_conn
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let target_id: String = row.get(1)?;
let created_at: String = row.get(2)?;
let state: String = row.get(3)?;
let now = Utc::now().to_rfc3339();
let status = if state == "published" {
"active"
} else {
"disabled"
};
let _ = tx.execute(
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
);
}
}
tx.commit()?;
info!("Global slug index populated successfully.");
}
run_migrations(
&mut reserved_conn,
"reserved",
RESERVED_SLUGS_MIGRATIONS,
Some(&system_arc),
)?;
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
let db = Self {
admin: Arc::new(Mutex::new(admin_conn)),
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
system: system_arc,
users: Arc::new(Mutex::new(users_conn)),
global_urls: Arc::new(Mutex::new(global_urls_conn)),
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
reserved: Arc::new(Mutex::new(reserved_conn)),
data_dir: config.data_dir.clone(),
topology,
};
let _ = db.reconcile_global_slugs(config);
// Post-init: Clean up stale reservations
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
{
let system_conn = db.system.lock().unwrap();
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
if let (Ok(urls_conn), Ok(pages_conn)) =
(db.global_urls.lock(), db.global_landing_pages.lock())
{
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
Ok(count) => {
if count > 0 {
tracing::info!("Cleaned up {} stale reserving slugs", count);
tracing::info!(
"Cleaned up {} stale reserving slugs from v0.8 registry",
count
);
}
}
Err(e) => {
@@ -351,28 +229,6 @@ impl Db {
}
}
}
// Post-init: Verify global registry integrity
{
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
match crate::db::users::verify_global_slug_registry_integrity(
&system_conn,
&users_conn,
&config.data_dir,
) {
Ok((errors, warnings)) => {
for err in errors {
tracing::error!("Global registry integrity error: {}", err);
}
for warn in warnings {
tracing::warn!("Global registry integrity warning: {}", warn);
}
}
Err(e) => {
tracing::error!("Failed to verify global registry integrity: {}", e);
}
}
}
Ok(db)
@@ -382,24 +238,33 @@ impl Db {
let admin = self.admin.lock().unwrap();
let _ = admin.execute("VACUUM;", []);
let content = self.content.lock().unwrap();
let _ = content.execute("VACUUM;", []);
let analytics = self.analytics.lock().unwrap();
let _ = analytics.execute("VACUUM;", []);
let system = self.system.lock().unwrap();
let _ = system.execute("VACUUM;", []);
let users = self.users.lock().unwrap();
let _ = users.execute("VACUUM;", []);
let global_urls = self.global_urls.lock().unwrap();
let _ = global_urls.execute("VACUUM;", []);
let global_landing_pages = self.global_landing_pages.lock().unwrap();
let _ = global_landing_pages.execute("VACUUM;", []);
let reserved = self.reserved.lock().unwrap();
let _ = reserved.execute("VACUUM;", []);
Ok(())
}
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
let user_dir = self.data_dir.join("users").join(user_id.to_string());
fs::create_dir_all(&user_dir)?;
let user = {
let conn = self.users.lock().unwrap();
crate::db::users::get_user_by_id(&conn, user_id)?
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
};
let location = crate::db::tenant::location_for_user(&user)?;
let user_dir = location.dir(&self.topology)?;
fs::create_dir_all(user_dir.join("extensions"))?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
@@ -442,7 +307,7 @@ impl Db {
pub fn reconcile_global_slugs(
&self,
config: &Config,
_config: &Config,
) -> Result<(), Box<dyn std::error::Error>> {
use chrono::Utc;
@@ -460,11 +325,9 @@ impl Db {
drop(stmt);
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 = match self.topology.content_db_i64(user_id) {
Ok(p) => p,
Err(_) => continue,
};
if content_path.exists() {
@@ -525,16 +388,14 @@ impl Db {
#[cfg(test)]
mod db_init_tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_db_init() {
let temp_dir = PathBuf::from("./temp_test_db_dir");
let temp_dir = std::env::temp_dir().join(format!("test_db_init_{}", uuid::Uuid::new_v4()));
if temp_dir.exists() {
let _ = std::fs::remove_dir_all(&temp_dir);
}
let mut config = Config::load();
config.data_dir = temp_dir.clone();
let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
let db = Db::init(&config);
// Cleanup
+90
View File
@@ -0,0 +1,90 @@
//! Independently versioned v0.8 schemas.
//!
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
use super::migrations::Migration;
/// `slugs/global_urls.db` — globally unique URL slugs.
///
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
/// migrate it to the 12-hex user id.
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_urls (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
"#,
},
];
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS global_landing_pages (
slug TEXT PRIMARY KEY,
owner_tenant_id TEXT NOT NULL,
target_id TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
status TEXT NOT NULL,
retired_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
"#,
},
Migration {
version: 2,
name: "tenant_id_column",
sql: r#"
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
"#,
},
];
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
version: 1,
name: "initial_schema",
sql: r#"
CREATE TABLE IF NOT EXISTS reserved_slugs (
slug TEXT PRIMARY KEY,
reason TEXT
);
"#,
}];
/// Allowed statuses for the v0.8 slug databases.
///
/// `reserving` is an allocation lock, not a published state.
/// Frozen published states: `active`, `disabled`, `retired`.
pub const SLUG_STATUS_RESERVING: &str = "reserving";
pub const SLUG_STATUS_ACTIVE: &str = "active";
pub const SLUG_STATUS_DISABLED: &str = "disabled";
pub const SLUG_STATUS_RETIRED: &str = "retired";
+538
View File
@@ -0,0 +1,538 @@
//! Explicit Phase 4 Global Slug Migration Engine.
//!
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
//! - `slugs/global_urls.db` (`global_urls` table)
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
//! - `slugs/reserved.db` (`reserved_slugs` table)
//!
//! Lifecycle:
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
//! 2. Backup (create pre-migration tarball)
//! 3. Transactional Migration (insert into target databases with restart safety)
//! 4. Validation (row counts, field parity, global uniqueness across databases)
//! 5. Completion Marker (record audit event and system setting)
use rusqlite::Connection;
use std::collections::HashMap;
use tracing::{info, warn};
use crate::config::Config;
use crate::db::topology::Topology;
use crate::db::Db;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SlugMigrationReport {
pub total_legacy_slugs: usize,
pub url_slugs_migrated: usize,
pub page_slugs_migrated: usize,
pub reserved_slugs_migrated: usize,
pub existing_records_verified: usize,
pub validation_passed: bool,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SlugPreflightReport {
pub total_slugs: usize,
pub url_slugs: usize,
pub page_slugs: usize,
pub reserved_slugs: usize,
pub unresolvable_owners: Vec<(String, i64)>,
pub unknown_target_types: Vec<(String, String)>,
}
/// Helper struct for legacy slug record
struct LegacySlugRecord {
slug: String,
owner_user_id: i64,
target_type: String,
target_id: String,
created_at: String,
updated_at: String,
status: String,
deleted_at: Option<String>,
}
/// Run the full explicit global slug migration.
pub async fn run_global_slug_migration(
config: &Config,
dry_run: bool,
skip_backup: bool,
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
let _topology = Topology::new(&config.data_dir);
let mut warnings = Vec::new();
// 1. Initialize Db
let db = Db::init(config)?;
// 2. Preflight
let preflight = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
inspect_slug_preflight(&system_conn, &users_conn)?
};
info!(
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
preflight.total_slugs,
preflight.url_slugs,
preflight.page_slugs,
preflight.reserved_slugs,
preflight.unresolvable_owners.len(),
preflight.unknown_target_types.len()
);
if !preflight.unresolvable_owners.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
preflight.unresolvable_owners.len(),
preflight.unresolvable_owners
);
return Err(msg.into());
}
if !preflight.unknown_target_types.is_empty() {
let msg = format!(
"Fatal: Found {} slugs with unknown target_type: {:?}",
preflight.unknown_target_types.len(),
preflight.unknown_target_types
);
return Err(msg.into());
}
if dry_run {
info!("Dry run enabled: no slug records will be migrated.");
return Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: preflight.url_slugs,
page_slugs_migrated: preflight.page_slugs,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: 0,
validation_passed: true,
warnings,
});
}
// 3. Backup before migration (if needed and not skipped)
if preflight.total_slugs > 0 && !skip_backup {
info!("Creating pre-migration backup before slug migration...");
match crate::jobs::backup::perform_backup(&db, config).await {
Ok(path) => info!("Pre-migration backup created at {:?}", path),
Err(e) => {
warn!(
"Pre-migration backup failed: {}. Continuing with caution.",
e
);
warnings.push(format!("Pre-migration backup warning: {e}"));
}
}
}
// 4. Transactional Migration
let (migrated_urls, migrated_pages, verified_existing) = {
let system_conn = db.system.lock().unwrap();
let users_conn = db.users.lock().unwrap();
let mut urls_conn = db.global_urls.lock().unwrap();
let mut pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
migrate_slugs_transactional(
&system_conn,
&users_conn,
&mut urls_conn,
&mut pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 5. Validation
let validation_passed = {
let system_conn = db.system.lock().unwrap();
let urls_conn = db.global_urls.lock().unwrap();
let pages_conn = db.global_landing_pages.lock().unwrap();
let reserved_conn = db.reserved.lock().unwrap();
validate_slug_migration(
&system_conn,
&urls_conn,
&pages_conn,
&reserved_conn,
&mut warnings,
)?
};
// 6. Completion Marker in system.db
if validation_passed {
let system_conn = db.system.lock().unwrap();
let now = chrono::Utc::now().to_rfc3339();
let details = format!(
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
migrated_urls, migrated_pages, verified_existing
);
let _ = crate::db::audit_events::write_audit_event(
&system_conn,
"system",
"GLOBAL_SLUG_MIGRATION_COMPLETED",
"slugs",
"slugs/*.db",
Some(&details),
);
let _ = system_conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
[&now],
);
}
Ok(SlugMigrationReport {
total_legacy_slugs: preflight.total_slugs,
url_slugs_migrated: migrated_urls,
page_slugs_migrated: migrated_pages,
reserved_slugs_migrated: preflight.reserved_slugs,
existing_records_verified: verified_existing,
validation_passed,
warnings,
})
}
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
pub fn inspect_slug_preflight(
system_conn: &Connection,
users_conn: &Connection,
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
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),
)?;
if !has_global_slugs {
return Ok(SlugPreflightReport {
total_slugs: 0,
url_slugs: 0,
page_slugs: 0,
reserved_slugs: 0,
unresolvable_owners: Vec::new(),
unknown_target_types: Vec::new(),
});
}
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records = stmt.query_map([], |row| {
Ok(LegacySlugRecord {
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)?,
})
})?;
// Build owner map from users.db: user_id -> TenantId/legacy_admin
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
let mut total_slugs = 0;
let mut url_slugs = 0;
let mut page_slugs = 0;
let mut unresolvable_owners = Vec::new();
let mut unknown_target_types = Vec::new();
for r in records {
let rec = r?;
total_slugs += 1;
if !owner_map.contains_key(&rec.owner_user_id) {
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
}
match rec.target_type.as_str() {
"url" => url_slugs += 1,
"page" => page_slugs += 1,
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
}
}
let reserved_slugs: usize = system_conn
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
.unwrap_or(0);
Ok(SlugPreflightReport {
total_slugs,
url_slugs,
page_slugs,
reserved_slugs,
unresolvable_owners,
unknown_target_types,
})
}
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
pub fn migrate_slugs_transactional(
system_conn: &Connection,
users_conn: &Connection,
urls_conn: &mut Connection,
pages_conn: &mut Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
// 1. Build owner map: user_id -> TenantId
let mut owner_map = HashMap::new();
let users = crate::db::users::list_users(users_conn)?;
for u in users {
if let Some(tid) = u.tenant_id {
owner_map.insert(u.id, tid.as_str().to_string());
} else if u.account_type == "admin"
|| u.account_type == "system"
|| u.username == "legacy_admin"
{
owner_map.insert(u.id, "legacy_admin".to_string());
}
}
// 2. Fetch all legacy slugs
let mut stmt = system_conn.prepare(
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
FROM global_slugs;",
)?;
let records: Vec<LegacySlugRecord> = stmt
.query_map([], |row| {
Ok(LegacySlugRecord {
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)?,
})
})?
.collect::<Result<_, _>>()?;
let mut migrated_urls = 0;
let mut migrated_pages = 0;
let mut verified_existing = 0;
let tx_urls = urls_conn.transaction()?;
let tx_pages = pages_conn.transaction()?;
for rec in records {
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
Some(t) => t.clone(),
None => {
warnings.push(format!(
"Unresolvable owner_user_id {} for slug '{}'; skipping",
rec.owner_user_id, rec.slug
));
continue;
}
};
let retired_at = rec.deleted_at;
if rec.target_type == "url" {
// Check if record already exists in global_urls.db
let existing: Option<(String, String)> = tx_urls
.query_row(
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
rec.slug
));
}
} else {
tx_urls.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_urls += 1;
}
} else if rec.target_type == "page" {
// Check if record already exists in global_landing_pages.db
let existing: Option<(String, String)> = tx_pages
.query_row(
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
[&rec.slug],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok();
if let Some((existing_owner, existing_target)) = existing {
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
verified_existing += 1;
} else {
warnings.push(format!(
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
rec.slug
));
}
} else {
tx_pages.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
rusqlite::params![
rec.slug,
owner_tenant_id,
rec.target_id,
rec.created_at,
rec.updated_at,
rec.status,
retired_at
],
)?;
migrated_pages += 1;
}
}
}
tx_urls.commit()?;
tx_pages.commit()?;
// Seed reserved slugs
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
Ok((migrated_urls, migrated_pages, verified_existing))
}
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
pub fn validate_slug_migration(
system_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
reserved_conn: &Connection,
warnings: &mut Vec<String>,
) -> Result<bool, Box<dyn std::error::Error>> {
let mut valid = true;
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),
)?;
if !has_global_slugs {
return Ok(true);
}
let legacy_url_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
[],
|r| r.get(0),
)?;
let legacy_page_count: usize = system_conn.query_row(
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
[],
|r| r.get(0),
)?;
let target_url_count: usize =
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
let target_page_count: usize =
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
r.get(0)
})?;
if target_url_count < legacy_url_count {
warnings.push(format!(
"URL slug count mismatch: legacy has {}, target has {}",
legacy_url_count, target_url_count
));
valid = false;
}
if target_page_count < legacy_page_count {
warnings.push(format!(
"Page slug count mismatch: legacy has {}, target has {}",
legacy_page_count, target_page_count
));
valid = false;
}
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
let collisions_urls_pages: usize = urls_conn.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_urls_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
collisions_urls_pages
));
valid = false;
}
let collisions_reserved_urls: usize = urls_conn
.query_row(
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
)
.unwrap_or(0);
if collisions_reserved_urls > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
collisions_reserved_urls
));
valid = false;
}
let collisions_reserved_pages: usize = pages_conn.query_row(
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
[],
|r| r.get(0),
).unwrap_or(0);
if collisions_reserved_pages > 0 {
warnings.push(format!(
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
collisions_reserved_pages
));
valid = false;
}
let _ = reserved_conn;
Ok(valid)
}
+472
View File
@@ -0,0 +1,472 @@
//! Frozen v0.8 Slug Registry Layer.
//!
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
//!
//! Guarantees:
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
//! - Concurrency-safe global allocations.
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
use serde::{Deserialize, Serialize};
use crate::db::schema_v08::{
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
};
use crate::identity::TenantId;
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
("admin", "System route"),
("login", "System route"),
("logout", "System route"),
("dashboard", "System route"),
("api", "System route"),
("docs", "System route"),
("assets", "System route"),
("static", "System route"),
("favicon.ico", "System route"),
("robots.txt", "System route"),
("health", "System route"),
("metrics", "System route"),
("install", "System route"),
("setup", "System route"),
("support", "System route"),
("help", "System route"),
("security", "System route"),
("abuse", "System route"),
("billing", "System route"),
("status", "System route"),
("legacy_admin", "System reserved"),
("administrator", "System reserved"),
("system", "System reserved"),
("root", "System reserved"),
("www", "System reserved"),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum SlugTargetType {
Url,
LandingPage,
}
impl SlugTargetType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Url => "url",
Self::LandingPage => "page",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ResolvedSlugInfo {
pub slug: String,
pub owner_tenant_id: String,
pub target_type: SlugTargetType,
pub target_id: String,
pub created_at: String,
pub updated_at: String,
pub status: String,
pub retired_at: Option<String>,
}
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
let mut inserted = 0usize;
for (slug, reason) in CORE_RESERVED_SLUGS {
let n = conn.execute(
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
params![slug, reason],
)?;
inserted += n;
}
Ok(inserted)
}
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
}
/// Check whether a slug is reserved in `slugs/reserved.db`.
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
reserved_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[slug],
|row| row.get(0),
)
}
/// Check whether a slug is available for a new registration.
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
/// in any state (including `retired`).
pub fn is_slug_available(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
if is_slug_reserved(reserved_conn, slug)? {
return Ok(false);
}
let in_urls: bool = urls_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_urls {
return Ok(false);
}
let in_pages: bool = pages_conn.query_row(
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
[slug],
|r| r.get(0),
)?;
if in_pages {
return Ok(false);
}
Ok(true)
}
/// Lookup a slug in `slugs/global_urls.db`.
pub fn lookup_url_slug(
urls_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
urls_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Lookup a slug in `slugs/global_landing_pages.db`.
pub fn lookup_landing_page_slug(
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
pages_conn
.query_row(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE slug = ?1;",
[slug],
|row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
},
)
.optional()
}
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
pub fn lookup_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
return Ok(Some(info));
}
lookup_landing_page_slug(pages_conn, slug)
}
/// Register a URL slug in `slugs/global_urls.db`.
pub fn register_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Register a landing page slug in `slugs/global_landing_pages.db`.
pub fn register_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
target_id: &str,
status: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
)?;
Ok(())
}
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
pub fn reserve_url_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
pub fn reserve_landing_page_slug(
reserved_conn: &Connection,
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
return Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("Slug is unavailable or reserved".into()),
));
}
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
)?;
Ok(())
}
/// Release a reserving URL slug (e.g. if content creation fails).
pub fn release_url_slug(
urls_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
urls_conn.execute(
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Release a reserving Landing Page slug (e.g. if content creation fails).
pub fn release_landing_page_slug(
pages_conn: &Connection,
slug: &str,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<()> {
pages_conn.execute(
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
params![slug, owner_tenant_id.as_str()],
)?;
Ok(())
}
/// Update URL slug target and activate after reservation.
pub fn activate_url_slug(
urls_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
urls_conn.execute(
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Update Landing Page slug target and activate after reservation.
pub fn activate_landing_page_slug(
pages_conn: &Connection,
slug: &str,
target_id: &str,
) -> rusqlite::Result<()> {
let now = Utc::now().to_rfc3339();
pages_conn.execute(
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
)?;
Ok(())
}
/// Retire a slug permanently so it cannot be reused.
pub fn retire_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
params![SLUG_STATUS_RETIRED, now, now, slug],
)?;
Ok(n_pages > 0)
}
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
pub fn disable_slug(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
params![SLUG_STATUS_DISABLED, now, slug],
)?;
Ok(n_pages > 0)
}
/// Transfer slug ownership to a new tenant.
pub fn transfer_slug_owner(
urls_conn: &Connection,
pages_conn: &Connection,
slug: &str,
new_owner_tenant_id: &TenantId,
new_target_id: &str,
) -> rusqlite::Result<bool> {
let now = Utc::now().to_rfc3339();
let n_urls = urls_conn.execute(
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
if n_urls > 0 {
return Ok(true);
}
let n_pages = pages_conn.execute(
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
)?;
Ok(n_pages > 0)
}
/// List all slugs owned by a specific tenant.
pub fn list_slugs_by_tenant(
urls_conn: &Connection,
pages_conn: &Connection,
owner_tenant_id: &TenantId,
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
let mut results = Vec::new();
let mut stmt = urls_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::Url,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
let mut stmt = pages_conn.prepare(
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
)?;
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
Ok(ResolvedSlugInfo {
slug: row.get(0)?,
owner_tenant_id: row.get(1)?,
target_type: SlugTargetType::LandingPage,
target_id: row.get(2)?,
created_at: row.get(3)?,
updated_at: row.get(4)?,
status: row.get(5)?,
retired_at: row.get(6)?,
})
})?;
for r in rows {
results.push(r?);
}
Ok(results)
}
/// Clean up stale reservations older than threshold seconds.
pub fn cleanup_stale_reservations(
urls_conn: &Connection,
pages_conn: &Connection,
older_than_seconds: i64,
) -> rusqlite::Result<usize> {
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
let n1 = urls_conn.execute(
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
let n2 = pages_conn.execute(
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
[&threshold],
)?;
Ok(n1 + n2)
}
+266
View File
@@ -0,0 +1,266 @@
//! Tenant database access boundary.
//!
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
//! Unknown ids must not create filesystem databases.
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use rusqlite::Connection;
use crate::db::topology::Topology;
use crate::error::AppError;
use crate::identity::TenantId;
use crate::models::TenantUser;
/// Open tenant SQLite connections (content, analytics, profile).
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
/// How a tenant database may be opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum TenantOpenMode {
/// Authenticated tenant user / API actor. Status must be `active`.
Ordinary,
/// Public slug/QR/gate resolution. User must exist and not be deleted.
PublicContent,
/// Core jobs / admin inspection. User must exist and not be deleted.
/// Existing files only — will not create a database.
CoreJob,
/// Explicit provisioning after a Core user row was inserted.
Provision,
}
/// Resolved tenant filesystem location.
///
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
/// and exists only until Phase 3 directory migration.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TenantLocation {
Id(TenantId),
Legacy(i64),
}
impl TenantLocation {
pub fn cache_key(&self) -> String {
match self {
Self::Id(id) => id.as_str().to_string(),
Self::Legacy(row_id) => format!("legacy:{row_id}"),
}
}
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
match self {
Self::Id(id) => Ok(topology.tenant_dir(*id)),
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
}
}
}
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
if let Some(id) = user.tenant_id {
return Ok(TenantLocation::Id(id));
}
if user.id <= 0 {
return Err(AppError::NotFound("invalid user id".into()));
}
Ok(TenantLocation::Legacy(user.id))
}
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
match mode {
TenantOpenMode::Ordinary => status == "active",
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
status != "deleted"
}
}
}
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
if !status_allows_open(&user.status, mode) {
return Err(AppError::Unauthorized(format!(
"tenant access denied for status '{}'",
user.status
)));
}
Ok(())
}
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
pub fn open_for_row_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
user_id: i64,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
pub fn open_for_tenant_id(
users_conn: &Connection,
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
tenant_id: TenantId,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
assert_may_open(&user, mode)?;
let location = location_for_user(&user)?;
open_location(topology, system_db, pool, &location, mode)
}
pub fn open_location(
topology: &Topology,
system_db: &Arc<Mutex<Connection>>,
pool: &mut std::collections::HashMap<String, UserDbs>,
location: &TenantLocation,
mode: TenantOpenMode,
) -> Result<UserDbs, AppError> {
let key = location.cache_key();
if let Some(dbs) = pool.get(&key) {
return Ok(dbs.clone());
}
let user_dir = location
.dir(topology)
.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let create = matches!(
mode,
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
);
if !create && !content_path.exists() {
return Err(AppError::NotFound(format!(
"tenant database missing at {}",
content_path.display()
)));
}
if create {
std::fs::create_dir_all(user_dir.join("extensions"))?;
}
let dbs = open_files(
&content_path,
&analytics_path,
&profile_path,
system_db,
create,
)?;
pool.insert(key, dbs.clone());
Ok(dbs)
}
fn open_files(
content_path: &Path,
analytics_path: &Path,
profile_path: &Path,
system_db: &Arc<Mutex<Connection>>,
create: bool,
) -> Result<UserDbs, AppError> {
if !create && !content_path.exists() {
return Err(AppError::NotFound("content.db missing".into()));
}
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(system_db),
)
.map_err(|e| AppError::Internal(e.to_string()))?;
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
Ok(UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
})
}
/// Job/helper path: registered user, existing content.db only, never create.
pub fn existing_content_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("content.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
pub fn existing_analytics_path(
users_conn: &Connection,
topology: &Topology,
user_id: i64,
) -> Result<PathBuf, rusqlite::Error> {
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
})?;
if user.status == "deleted" {
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
}
let loc = location_for_user(&user)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let dir = loc
.dir(topology)
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
let path = dir.join("analytics.db");
if !path.exists() {
return Err(rusqlite::Error::InvalidPath(path));
}
Ok(path)
}
+345
View File
@@ -0,0 +1,345 @@
//! Frozen v0.8.0 database topology under a configurable physical root.
//!
//! The logical layout is:
//!
//! ```text
//! <data_dir>/
//! ├── admin/{admin,system,users}.db
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
//! └── users/<user-key>/{profile,content,analytics}.db
//! └── extensions/<extension>/<extension>.db
//! ```
//!
//! `<data_dir>` is `Config.data_dir` (CLI `--data-dir`, env `NX9_BZOD_DATA_DIR`, or config file).
//! It is not renamed to `database/`. Production Docker, CasaOS, and
//! `deploy.sh` bind this physical root.
use std::path::{Path, PathBuf};
use crate::identity::TenantId;
/// Directory name of the migration-era `legacy_admin` tenant (`users.db` id = 1).
pub const LEGACY_ADMIN_USER_KEY: &str = "1";
/// Frozen first-party extension names. There is no runtime plugin loader.
pub const FIRST_PARTY_EXTENSIONS: &[&str] = &["cv", "certificates", "documents", "portfolio"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TopologyError {
InvalidUserDir { name: String },
InvalidExtension { name: String },
}
impl std::fmt::Display for TopologyError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidUserDir { name } => {
write!(f, "invalid tenant directory name: {name:?}")
}
Self::InvalidExtension { name } => {
write!(f, "invalid extension name: {name:?}")
}
}
}
}
impl std::error::Error for TopologyError {}
/// Authoritative resolver for every BZOD database path.
#[derive(Clone, Debug)]
pub struct Topology {
root: PathBuf,
}
impl Topology {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn admin_dir(&self) -> PathBuf {
self.root.join("admin")
}
pub fn slugs_dir(&self) -> PathBuf {
self.root.join("slugs")
}
pub fn users_dir(&self) -> PathBuf {
self.root.join("users")
}
pub fn admin_db(&self) -> PathBuf {
self.admin_dir().join("admin.db")
}
pub fn system_db(&self) -> PathBuf {
self.admin_dir().join("system.db")
}
pub fn users_registry_db(&self) -> PathBuf {
self.admin_dir().join("users.db")
}
pub fn global_urls_db(&self) -> PathBuf {
self.slugs_dir().join("global_urls.db")
}
pub fn global_landing_pages_db(&self) -> PathBuf {
self.slugs_dir().join("global_landing_pages.db")
}
pub fn reserved_db(&self) -> PathBuf {
self.slugs_dir().join("reserved.db")
}
/// Pre-multi-tenant files that may still exist at the physical root.
pub fn legacy_flat_admin_db(&self) -> PathBuf {
self.root.join("admin.db")
}
pub fn legacy_flat_system_db(&self) -> PathBuf {
self.root.join("system.db")
}
pub fn legacy_flat_users_db(&self) -> PathBuf {
self.root.join("users.db")
}
pub fn legacy_flat_content_db(&self) -> PathBuf {
self.root.join("content.db")
}
pub fn legacy_flat_analytics_db(&self) -> PathBuf {
self.root.join("analytics.db")
}
pub fn legacy_admin_dir(&self) -> PathBuf {
self.users_dir().join(LEGACY_ADMIN_USER_KEY)
}
/// Frozen tenant directory: `users/<12-hex-TenantId>/`.
pub fn tenant_dir(&self, tenant_id: TenantId) -> PathBuf {
self.users_dir().join(tenant_id.as_str())
}
pub fn tenant_content_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("content.db")
}
pub fn tenant_analytics_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("analytics.db")
}
pub fn tenant_profile_db(&self, tenant_id: TenantId) -> PathBuf {
self.tenant_dir(tenant_id).join("profile.db")
}
pub fn user_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_user_dir_name(user_key) {
return Err(TopologyError::InvalidUserDir {
name: user_key.to_string(),
});
}
Ok(self.users_dir().join(user_key))
}
pub fn user_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.user_dir(&user_id.to_string())
}
pub fn content_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("content.db"))
}
pub fn analytics_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("analytics.db"))
}
pub fn profile_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("profile.db"))
}
pub fn content_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.content_db(&user_id.to_string())
}
pub fn analytics_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.analytics_db(&user_id.to_string())
}
pub fn profile_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.profile_db(&user_id.to_string())
}
pub fn extensions_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
Ok(self.user_dir(user_key)?.join("extensions"))
}
pub fn extensions_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
self.extensions_dir(&user_id.to_string())
}
pub fn extension_db(&self, user_key: &str, extension: &str) -> Result<PathBuf, TopologyError> {
if !is_valid_extension_name(extension) {
return Err(TopologyError::InvalidExtension {
name: extension.to_string(),
});
}
Ok(self
.extensions_dir(user_key)?
.join(extension)
.join(format!("{extension}.db")))
}
/// Create `admin/`, `slugs/`, and `users/` under the physical root.
pub fn ensure_core_dirs(&self) -> std::io::Result<()> {
std::fs::create_dir_all(self.admin_dir())?;
std::fs::create_dir_all(self.slugs_dir())?;
std::fs::create_dir_all(self.users_dir())?;
Ok(())
}
/// Create a tenant directory and its `extensions/` folder.
pub fn ensure_user_dirs(&self, user_key: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
let dir = self.user_dir(user_key)?;
std::fs::create_dir_all(&dir)?;
std::fs::create_dir_all(dir.join("extensions"))?;
Ok(dir)
}
pub fn ensure_user_dirs_i64(
&self,
user_id: i64,
) -> Result<PathBuf, Box<dyn std::error::Error>> {
self.ensure_user_dirs(&user_id.to_string())
}
}
/// Tenant directory names: 12 lowercase hex (v0.8) or a positive decimal id (v0.7).
pub fn is_valid_user_dir_name(name: &str) -> bool {
if name.is_empty() || name == "." || name == ".." {
return false;
}
if name
.as_bytes()
.iter()
.any(|b| *b == b'/' || *b == b'\\' || *b == 0 || *b == b'.')
{
return false;
}
if is_v08_user_id(name) {
return true;
}
is_legacy_integer_user_id(name)
}
pub fn is_v08_user_id(name: &str) -> bool {
name.len() == 12 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
}
pub fn is_legacy_integer_user_id(name: &str) -> bool {
if name.is_empty() || name.as_bytes()[0] == b'0' {
return false;
}
name.bytes().all(|b| b.is_ascii_digit()) && name.parse::<i64>().map(|n| n > 0).unwrap_or(false)
}
/// First-party extension directory names: `^[a-z][a-z0-9_]{0,31}$`.
pub fn is_valid_extension_name(name: &str) -> bool {
let mut chars = name.chars();
match chars.next() {
Some(c) if c.is_ascii_lowercase() => {}
_ => return false,
}
name.len() <= 32
&& name
.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn physical_root_is_not_renamed_to_database() {
let t = Topology::new("/var/lib/bzod/data");
assert_eq!(t.root(), Path::new("/var/lib/bzod/data"));
assert!(t.admin_dir().ends_with("data/admin"));
assert!(t.slugs_dir().ends_with("data/slugs"));
assert!(t.users_dir().ends_with("data/users"));
assert!(!t.root().ends_with("database"));
}
#[test]
fn frozen_core_paths() {
let t = Topology::new("/app/data");
assert_eq!(t.admin_db(), PathBuf::from("/app/data/admin/admin.db"));
assert_eq!(t.system_db(), PathBuf::from("/app/data/admin/system.db"));
assert_eq!(
t.users_registry_db(),
PathBuf::from("/app/data/admin/users.db")
);
assert_eq!(
t.global_urls_db(),
PathBuf::from("/app/data/slugs/global_urls.db")
);
assert_eq!(
t.global_landing_pages_db(),
PathBuf::from("/app/data/slugs/global_landing_pages.db")
);
assert_eq!(
t.reserved_db(),
PathBuf::from("/app/data/slugs/reserved.db")
);
}
#[test]
fn tenant_paths_legacy_integer_and_v08_hex() {
let t = Topology::new("/app/data");
assert_eq!(
t.content_db_i64(2).unwrap(),
PathBuf::from("/app/data/users/2/content.db")
);
let tid = crate::identity::TenantId::parse("a1b2c3d4e5f6").unwrap();
assert_eq!(
t.tenant_content_db(tid),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/content.db")
);
assert_eq!(
t.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
PathBuf::from("/app/data/users/a1b2c3d4e5f6/extensions/cv/cv.db")
);
}
#[test]
fn rejects_path_traversal_and_forged_names() {
let t = Topology::new("/app/data");
assert!(t.user_dir("..").is_err());
assert!(t.user_dir("../2").is_err());
assert!(t.user_dir("2/../3").is_err());
assert!(t.user_dir("2/foo").is_err());
assert!(t.user_dir("-1").is_err());
assert!(t.user_dir("0").is_err());
assert!(t.user_dir("01").is_err());
assert!(t.user_dir("").is_err());
assert!(t.user_dir("ABCDEFABCDEF").is_err());
assert!(t.content_db_i64(-5).is_err());
assert!(t.content_db_i64(0).is_err());
assert!(t.extension_db("2", "../cv").is_err());
assert!(t.extension_db("2", "cv/db").is_err());
assert!(t.extension_db("2", "").is_err());
assert!(t.extension_db("2", "CV").is_err());
}
#[test]
fn first_party_extension_names_are_valid() {
for name in FIRST_PARTY_EXTENSIONS {
assert!(is_valid_extension_name(name), "{name}");
}
}
}
+193 -213
View File
@@ -1,7 +1,70 @@
use crate::identity::TenantId;
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
use chrono::Utc;
use rusqlite::{params, Connection, OptionalExtension};
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
let tenant_raw: Option<String> = row.get(9)?;
let tenant_id = match tenant_raw {
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
})?),
None => None,
};
let uuid: Option<String> = row.get(10)?;
Ok(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)?,
tenant_id,
uuid,
})
}
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
for _ in 0..16 {
let candidate = TenantId::generate();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
[candidate.as_str()],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique TenantId".into()),
))
}
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
for _ in 0..16 {
let candidate = uuid::Uuid::new_v4().to_string();
let exists: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
[&candidate],
|row| row.get(0),
)?;
if !exists {
return Ok(candidate);
}
}
Err(rusqlite::Error::SqliteFailure(
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
Some("failed to allocate unique UUID".into()),
))
}
// --- User Operations ---
pub fn is_reserved_username(username: &str) -> bool {
@@ -17,11 +80,19 @@ pub fn create_admin_user(
let created_at = Utc::now().to_rfc3339();
let status = "active";
let account_type = "admin";
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
params![username, password_hash, status, created_at, account_type],
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
params![
username,
password_hash,
status,
created_at,
account_type,
user_uuid,
],
)?;
let id = conn.last_insert_rowid();
@@ -39,6 +110,8 @@ pub fn create_admin_user(
account_type: account_type.to_string(),
organization_id: None,
metadata: None,
tenant_id: None,
uuid: Some(user_uuid),
})
}
@@ -58,17 +131,21 @@ pub fn create_user(
let created_at = Utc::now().to_rfc3339();
let status = "active";
let tenant_id = allocate_unique_tenant_id(conn)?;
let user_uuid = allocate_unique_uuid(conn)?;
conn.execute(
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
params![
username,
password_hash,
status,
created_at,
account_type,
metadata
metadata,
tenant_id.as_str(),
user_uuid,
],
)?;
@@ -87,27 +164,37 @@ pub fn create_user(
account_type: account_type.to_string(),
organization_id: None,
metadata: metadata.map(|s| s.to_string()),
tenant_id: Some(tenant_id),
uuid: Some(user_uuid),
})
}
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE id = ?1;",
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
params![id],
|row| {
Ok(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)?,
})
},
map_user,
)
.optional()
}
pub fn get_user_by_tenant_id(
conn: &Connection,
tenant_id: TenantId,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
params![tenant_id.as_str()],
map_user,
)
.optional()
}
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
params![uuid],
map_user,
)
.optional()
}
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
username: &str,
) -> rusqlite::Result<Option<TenantUser>> {
conn.query_row(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users WHERE username = ?1;",
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
params![username],
|row| {
Ok(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)?,
})
},
map_user,
)
.optional()
}
@@ -184,23 +258,10 @@ pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()
}
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
let mut stmt = conn.prepare(
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
FROM users ORDER BY username ASC;",
)?;
let rows = stmt.query_map([], |row| {
Ok(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)?,
})
})?;
let mut stmt = conn.prepare(&format!(
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
))?;
let rows = stmt.query_map([], map_user)?;
let mut users = Vec::new();
for u in rows {
@@ -439,6 +500,9 @@ pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqli
// --- Global Slug & Quota Reconciliation Helpers ---
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
)]
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
// 1. Check reserved list
let reserved: bool = system_conn
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
Ok(!exists)
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
)]
pub fn register_global_slug(
system_conn: &Connection,
slug: &str,
@@ -490,6 +557,9 @@ pub fn register_global_slug(
Ok(())
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
)]
pub fn release_global_slug(
system_conn: &Connection,
slug: &str,
@@ -508,6 +578,7 @@ pub fn release_global_slug(
Ok(())
}
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
pub fn soft_delete_global_slug(
system_conn: &Connection,
slug: &str,
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
let mut invalid_entries = Vec::new();
let warnings = Vec::new();
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
let mut slug_owners: HashMap<String, Vec<String>> = HashMap::new();
// 1. Scan legacy content.db if it exists
let legacy_content_path = config.data_dir.join("content.db");
let legacy_content_path =
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_content_db();
if legacy_content_path.exists() {
if let Ok(conn) = Connection::open(&legacy_content_path) {
// URLs
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
slug_owners.entry(code).or_default().push("1".to_string());
}
}
}
@@ -565,7 +637,7 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(1);
slug_owners.entry(code).or_default().push("1".to_string());
}
}
}
@@ -574,14 +646,14 @@ pub fn audit_slug_namespace(
}
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
let users_dir = config.data_dir.join("users");
let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
if users_dir.exists() {
for entry in std::fs::read_dir(users_dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(user_id) = name_str.parse::<i64>() {
if crate::db::topology::is_valid_user_dir_name(name_str) {
let content_db_path = path.join("content.db");
if content_db_path.exists() {
if let Ok(conn) = Connection::open(&content_db_path) {
@@ -590,7 +662,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
}
}
}
@@ -602,7 +677,10 @@ pub fn audit_slug_namespace(
if let Ok(mut rows) = stmt.query([]) {
while let Some(row) = rows.next().unwrap_or(None) {
if let Ok(code) = row.get::<_, String>(0) {
slug_owners.entry(code).or_default().push(user_id);
slug_owners
.entry(code)
.or_default()
.push(name_str.to_string());
}
}
}
@@ -638,6 +716,9 @@ pub fn audit_slug_namespace(
})
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
)]
pub fn cleanup_stale_reservations(
system_conn: &Connection,
data_dir: &std::path::Path,
@@ -661,18 +742,31 @@ pub fn cleanup_stale_reservations(
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
if age > chrono::Duration::minutes(15) {
// Check if target record exists by looking up code = slug in owner's content.db
let content_db_path = if owner_user_id == 1 {
let p1 = data_dir.join("users").join("1").join("content.db");
if p1.exists() {
p1
let topology = crate::db::topology::Topology::new(data_dir);
let content_db_path = {
let users_path = topology.users_registry_db();
if let Ok(users_conn) = Connection::open(&users_path) {
crate::db::tenant::existing_content_path(
&users_conn,
&topology,
owner_user_id,
)
.ok()
.or_else(|| {
if owner_user_id == 1 {
Some(topology.legacy_flat_content_db())
} else {
data_dir.join("content.db")
None
}
})
.unwrap_or_else(|| topology.legacy_flat_content_db())
} else if owner_user_id == 1 {
topology.legacy_flat_content_db()
} else {
data_dir
.join("users")
.join(owner_user_id.to_string())
.join("content.db")
topology
.content_db_i64(owner_user_id)
.unwrap_or_else(|_| topology.legacy_flat_content_db())
}
};
let mut target_exists = false;
@@ -722,149 +816,9 @@ 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))
}
#[deprecated(
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
)]
pub fn register_restored_user_slugs(
system_conn: &Connection,
target_user_id: i64,
@@ -1010,3 +964,29 @@ pub fn reconcile_user_quotas(
Ok(())
}
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
pub fn get_platform_total_clicks(
topology: &crate::db::topology::Topology,
users_conn: &Connection,
) -> Option<i64> {
let mut stmt = users_conn
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
.ok()?;
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
let mut total = 0i64;
for tid_str in rows.flatten() {
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
let analytics_path = topology.tenant_analytics_db(tid);
if analytics_path.exists() {
if let Ok(conn) = Connection::open(&analytics_path) {
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
.unwrap_or(0);
total += count;
}
}
}
}
Some(total)
}
+139
View File
@@ -0,0 +1,139 @@
//! Frozen v0.8 tenant identity.
//!
//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12
//! lowercase hexadecimal characters, CSPRNG-generated, independent of username
//! and of SQLite row ids.
use rand::{thread_rng, RngCore};
use std::fmt;
use std::str::FromStr;
/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`).
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct TenantId {
hex: [u8; Self::LEN],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TenantIdError {
InvalidLength { got: usize },
InvalidCharacter { found: char },
}
impl fmt::Display for TenantIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidLength { got } => {
write!(
f,
"TenantId must be {} lowercase hex characters, got {got}",
TenantId::LEN
)
}
Self::InvalidCharacter { found } => {
write!(f, "TenantId must be lowercase hexadecimal, found {found:?}")
}
}
}
}
impl std::error::Error for TenantIdError {}
impl TenantId {
pub const LEN: usize = 12;
/// Cryptographically secure random TenantId. Never derived from user data.
pub fn generate() -> Self {
let mut bytes = [0u8; 6];
thread_rng().fill_bytes(&mut bytes);
let encoded = hex::encode(bytes);
Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars")
}
pub fn parse(s: &str) -> Result<Self, TenantIdError> {
if s.len() != Self::LEN {
return Err(TenantIdError::InvalidLength { got: s.len() });
}
if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) {
return Err(TenantIdError::InvalidCharacter { found });
}
let mut hex = [0u8; Self::LEN];
hex.copy_from_slice(s.as_bytes());
Ok(Self { hex })
}
pub fn as_str(&self) -> &str {
std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex")
}
}
impl fmt::Display for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for TenantId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_tuple("TenantId").field(&self.as_str()).finish()
}
}
impl FromStr for TenantId {
type Err = TenantIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
impl serde::Serialize for TenantId {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(self.as_str())
}
}
impl<'de> serde::Deserialize<'de> for TenantId {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = String::deserialize(deserializer)?;
Self::parse(&s).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_accepts_lowercase_12_hex() {
let id = TenantId::parse("fafafa12c3e4").unwrap();
assert_eq!(id.as_str(), "fafafa12c3e4");
assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap());
}
#[test]
fn parse_rejects_uppercase_and_wrong_length() {
assert!(matches!(
TenantId::parse("FAFAFA12C3E4"),
Err(TenantIdError::InvalidCharacter { found: 'F' })
));
assert!(matches!(
TenantId::parse("abc"),
Err(TenantIdError::InvalidLength { got: 3 })
));
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
assert!(TenantId::parse("../etc/passwd").is_err());
assert!(TenantId::parse("gggggggggggg").is_err());
}
#[test]
fn generate_is_opaque_lowercase_hex() {
let a = TenantId::generate();
let b = TenantId::generate();
assert_ne!(a, b);
assert_eq!(a.as_str().len(), 12);
assert!(a
.as_str()
.chars()
.all(|c| matches!(c, '0'..='9' | 'a'..='f')));
}
}
+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> = {
+45 -12
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");
@@ -55,20 +65,32 @@ pub async fn perform_backup(
if let Ok(conn) = db.admin.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.content.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.analytics.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.system.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.users.lock() {
if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let user_ids: Vec<i64> = if let Ok(conn) = db.users.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
rows.filter_map(|r| r.ok()).collect()
} else {
Vec::new()
}
} else {
Vec::new()
}
} else {
Vec::new()
};
for user_id in user_ids {
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
@@ -77,8 +99,14 @@ pub async fn perform_backup(
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
}
if let Ok(conn) = db.global_urls.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.global_landing_pages.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
if let Ok(conn) = db.reserved.lock() {
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
}
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
@@ -89,12 +117,17 @@ pub async fn perform_backup(
let enc = GzEncoder::new(file, Compression::default());
let mut tar = Builder::new(enc);
let admin_dir = config.data_dir.join("admin");
let admin_dir = db.topology.admin_dir();
if admin_dir.exists() {
tar.append_dir_all("admin", &admin_dir)?;
}
let users_dir = config.data_dir.join("users");
let slugs_dir = db.topology.slugs_dir();
if slugs_dir.exists() {
tar.append_dir_all("slugs", &slugs_dir)?;
}
let users_dir = db.topology.users_dir();
if users_dir.exists() {
tar.append_dir_all("users", &users_dir)?;
}
+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 -10
View File
@@ -46,11 +46,12 @@ pub fn open_user_content_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("content.db");
let db_path = {
let users = db.users.lock().map_err(|_| {
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
})?;
crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
};
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
@@ -61,11 +62,12 @@ pub fn open_user_analytics_conn(
db: &Db,
user_id: i64,
) -> Result<rusqlite::Connection, rusqlite::Error> {
let db_path = db
.data_dir
.join("users")
.join(user_id.to_string())
.join("analytics.db");
let db_path = {
let users = db.users.lock().map_err(|_| {
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
})?;
crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
};
let conn = rusqlite::Connection::open(db_path)?;
crate::db::sqlite::enable_wal(&conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
+13 -3
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> = {
@@ -27,9 +37,9 @@ pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
rows.filter_map(|r| r.ok()).collect()
};
let users_conn = db.users.lock().unwrap();
for user_id in user_ids {
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
let users_conn = db.users.lock().unwrap();
if let Err(e) =
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
{
+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> = {
+2
View File
@@ -1,10 +1,12 @@
pub mod analytics;
pub mod auth;
pub mod build_info;
pub mod charts;
pub mod cli;
pub mod config;
pub mod db;
pub mod error;
pub mod identity;
pub mod jobs;
pub mod models;
pub mod services;
+26 -2
View File
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
.init();
let cli = Cli::parse();
let config = Config::load();
let config = match Config::load_with_cli(cli.command.data_dir()) {
Ok(config) => config,
Err(err) => {
eprintln!("Error: {err}");
std::process::exit(1);
}
};
match cli.command {
Commands::Serve {
@@ -38,8 +44,14 @@ 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?;
bzod::cli::create_admin::run(username, None, 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 +106,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(())
+18
View File
@@ -27,6 +27,24 @@ pub struct TenantUser {
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
pub organization_id: Option<i64>,
pub metadata: Option<String>,
/// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3).
pub tenant_id: Option<crate::identity::TenantId>,
/// Frozen v0.8 immutable UUID.
pub uuid: Option<String>,
}
impl TenantUser {
pub fn require_tenant_id(&self) -> Result<crate::identity::TenantId, crate::error::AppError> {
self.tenant_id.ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id))
})
}
pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> {
self.uuid.as_deref().ok_or_else(|| {
crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id))
})
}
}
#[derive(Serialize, Deserialize, Clone, Debug)]
+4
View File
@@ -1,3 +1,4 @@
use crate::identity::TenantId;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Debug)]
@@ -12,6 +13,9 @@ pub struct VisitRecord {
pub accept_language: String,
pub country: String,
pub status_code: u16,
#[serde(default)]
pub owner_tenant_id: Option<TenantId>,
#[serde(default)]
pub owner_user_id: Option<i64>,
}
+132
View File
@@ -0,0 +1,132 @@
//! 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;
use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY};
/// 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/`
/// - `{data_dir}/slugs/` is created empty if missing (v0.8 topology)
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
let topology = Topology::new(data_dir);
let admin_dir = topology.admin_dir();
let users_1_dir = topology.legacy_admin_dir();
std::fs::create_dir_all(&admin_dir)?;
std::fs::create_dir_all(&users_1_dir)?;
std::fs::create_dir_all(topology.slugs_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 {
let topology = Topology::new(data_dir);
Self {
admin: topology.admin_db(),
system: topology.system_db(),
users: topology.users_registry_db(),
content: topology
.content_db(LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
analytics: topology
.analytics_db(LEGACY_ADMIN_USER_KEY)
.expect("legacy admin user key is valid"),
}
}
}
#[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("slugs").is_dir());
assert!(!dir.join("admin.db").exists());
assert!(!dir.join("content.db").exists());
let _ = fs::remove_dir_all(&dir);
}
}
+224
View File
@@ -0,0 +1,224 @@
//! 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::identity::TenantId;
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(urls_conn: &Connection, slugs: &[String], owner_tenant_id: &TenantId) {
for slug in slugs {
let _ = crate::db::slugs::release_url_slug(urls_conn, slug, owner_tenant_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.
#[allow(clippy::too_many_arguments)]
pub fn create_urls_bulk(
content_db: &Mutex<Connection>,
reserved_db: &Mutex<Connection>,
global_urls_db: &Mutex<Connection>,
global_landing_pages_db: &Mutex<Connection>,
users_db: &Mutex<Connection>,
owner_user_id: i64,
owner_tenant_id: TenantId,
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,
reserved_db,
global_urls_db,
global_landing_pages_db,
&owner_tenant_id,
item,
&mut reserved_slugs,
) {
Ok(url) => created_urls.push(url),
Err(e) => {
let _ = tx.rollback();
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
}
return Err(e);
}
}
}
if let Err(e) = tx.commit() {
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
}
return Err(BulkUrlError::Internal(format!(
"Failed to commit transaction: {e}"
)));
}
// Activate slugs in v0.8 global_urls.db
{
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
for url in &created_urls {
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id);
}
}
// 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<'_>,
reserved_db: &Mutex<Connection>,
global_urls_db: &Mutex<Connection>,
global_landing_pages_db: &Mutex<Connection>,
owner_tenant_id: &TenantId,
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 !crate::utils::validation::validate_redirect_code(&code) {
return Err(BulkUrlError::BadRequest(format!(
"Short code or slug '{code}' is invalid (must be 6 hex characters or !custom-slug)"
)));
}
{
let reserved_conn = crate::utils::lock_db(reserved_db, "reserved_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db")
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
let available =
crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_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::slugs::reserve_url_slug(
&reserved_conn,
&urls_conn,
&pages_conn,
&code,
owner_tenant_id,
) {
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}")))
}
+258
View File
@@ -0,0 +1,258 @@
//! 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 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(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
let users = db
.users
.lock()
.map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
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, 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());
}
}
+7 -7
View File
@@ -1,23 +1,23 @@
use crate::db::Db;
use crate::error::AppError;
use crate::models::LandingPage;
pub fn create_landing_page(
db: &Db,
conn: &rusqlite::Connection,
code: &str,
slug: &str,
title: &str,
html_content: &str,
state: &str,
) -> Result<LandingPage, AppError> {
let conn = db.content.lock().unwrap();
let page =
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?;
Ok(page)
}
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
let conn = db.content.lock().unwrap();
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
pub fn get_landing_page_by_code(
conn: &rusqlite::Connection,
code: &str,
) -> Result<Option<LandingPage>, AppError> {
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
Ok(page)
}
+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;
+365
View File
@@ -0,0 +1,365 @@
use crate::db::topology::Topology;
use crate::identity::TenantId;
use chrono::{DateTime, Utc};
use rusqlite::Connection;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RegistryIssueType {
MissingTenant,
MissingTarget,
CorruptDatabase,
AccessFailure,
TrueOrphan,
Conflict,
StaleReservation,
InvalidStatus,
InvalidTargetType,
}
#[derive(Debug, Clone)]
pub struct RegistryIssue {
pub slug: String,
pub target_type: String,
pub owner_tenant_id: String,
pub database_path: PathBuf,
pub target_id: String,
pub issue_type: RegistryIssueType,
pub description: String,
}
pub struct RegistryValidator;
impl RegistryValidator {
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
/// and returns a list of detected issues categorized per safety policies.
pub fn scan(
_system_conn: &Connection,
users_conn: &Connection,
data_dir: &Path,
slug_filter: Option<&str>,
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
let topology = Topology::new(data_dir);
let mut issues = Vec::new();
let urls_path = topology.global_urls_db();
let pages_path = topology.global_landing_pages_db();
let reserved_path = topology.reserved_db();
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
issues.push(RegistryIssue {
slug: "*".to_string(),
target_type: "system".to_string(),
owner_tenant_id: "".to_string(),
database_path: urls_path,
target_id: "".to_string(),
issue_type: RegistryIssueType::AccessFailure,
description: "One or more v0.8 slug databases are missing from disk".to_string(),
});
return Ok(issues);
}
let urls_conn = Connection::open(&urls_path)?;
let pages_conn = Connection::open(&pages_path)?;
let reserved_conn = Connection::open(&reserved_path)?;
// 1. Scan global_urls.db
Self::scan_table(
&urls_conn,
users_conn,
&reserved_conn,
&pages_conn,
&topology,
"url",
slug_filter,
&mut issues,
)?;
// 2. Scan global_landing_pages.db
Self::scan_table(
&pages_conn,
users_conn,
&reserved_conn,
&urls_conn,
&topology,
"page",
slug_filter,
&mut issues,
)?;
Ok(issues)
}
#[allow(clippy::too_many_arguments)]
fn scan_table(
conn: &Connection,
users_conn: &Connection,
reserved_conn: &Connection,
other_conn: &Connection,
topology: &Topology,
target_type: &str,
slug_filter: Option<&str>,
issues: &mut Vec<RegistryIssue>,
) -> Result<(), Box<dyn std::error::Error>> {
let (query, params_vec) = if let Some(slug) = slug_filter {
(
format!(
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
if target_type == "url" { "url" } else { "landing_page" }
),
vec![slug.to_string()],
)
} else {
(
format!(
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
if target_type == "url" {
"url"
} else {
"landing_page"
}
),
vec![],
)
};
let mut stmt = conn.prepare(&query)?;
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
while let Some(row) = rows.next()? {
let slug: String = row.get(0)?;
let owner_tenant_id_str: String = row.get(1)?;
let target_id: String = row.get(2)?;
let created_at_str: String = row.get(3)?;
let status: String = row.get(4)?;
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
let content_db_path = match tenant_id_res {
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
};
// 1. Conflict with reserved.db
let is_reserved: bool = reserved_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
if is_reserved {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: topology.reserved_db(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::Conflict,
description: format!("Slug '{}' conflicts with a reserved system route", slug),
});
}
// 2. Conflict with the other slug database
let other_table = if target_type == "url" {
"global_landing_pages"
} else {
"global_urls"
};
let in_other: bool = other_conn
.query_row(
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
[&slug],
|r| r.get(0),
)
.unwrap_or(false);
if in_other {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::Conflict,
description: format!(
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
slug
),
});
}
// 3. Status check
if status != "active"
&& status != "disabled"
&& status != "reserving"
&& status != "retired"
{
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::InvalidStatus,
description: format!("Slug '{}' has invalid status '{}'", slug, status),
});
}
// If retired, no active target check is needed
if status == "retired" {
continue;
}
// 4. Owner tenant check in users.db
let tid = match tenant_id_res {
Ok(t) => t,
Err(_) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path,
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTenant,
description: format!(
"Slug '{}' has invalid TenantId '{}'",
slug, owner_tenant_id_str
),
});
continue;
}
};
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
let owner_exists = match owner_opt {
Some(ref u) => u.status != "deleted",
None => false,
};
if !owner_exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::MissingTenant,
description: format!(
"Slug '{}' references missing or deleted owner tenant '{}'",
slug, owner_tenant_id_str
),
});
continue;
}
// 5. Stale reservation 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.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
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
),
});
}
}
}
// 6. Target record check in tenant content DB
if status == "active" || status == "disabled" {
if !content_db_path.exists() {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::TrueOrphan,
description: format!(
"Slug '{}' owner content database does not exist at {:?}",
slug, content_db_path
),
});
} else {
match Connection::open(&content_db_path) {
Ok(tenant_conn) => {
let exists = if target_type == "url" {
tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
} else {
tenant_conn
.query_row(
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
[&target_id],
|r| r.get(0),
)
.unwrap_or(false)
};
if !exists {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
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 tenant content database",
slug, target_type, target_id
),
});
}
}
Err(rusqlite::Error::SqliteFailure(err, _))
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
{
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::CorruptDatabase,
description: format!(
"Slug '{}' owner content database is corrupt at {:?}",
slug, content_db_path
),
});
}
Err(e) => {
issues.push(RegistryIssue {
slug: slug.clone(),
target_type: target_type.to_string(),
owner_tenant_id: owner_tenant_id_str.clone(),
database_path: content_db_path.clone(),
target_id: target_id.clone(),
issue_type: RegistryIssueType::AccessFailure,
description: format!(
"Slug '{}' owner content database could not be accessed: {}",
slug, e
),
});
}
}
}
}
}
Ok(())
}
}
+9 -7
View File
@@ -1,22 +1,24 @@
use crate::db::Db;
use crate::error::AppError;
use crate::models::Url;
pub fn create_url(
db: &Db,
conn: &rusqlite::Connection,
code: &str,
destination: &str,
title: Option<&str>,
description: Option<&str>,
tags: &[String],
) -> Result<Url, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
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 url = crate::db::content::create_url(conn, code, destination, title, description, tags)?;
Ok(url)
}
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
let conn = db.content.lock().unwrap();
let url = crate::db::content::get_url_by_code(&conn, code)?;
pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
let url = crate::db::content::get_url_by_code(conn, code)?;
Ok(url)
}
+292
View File
@@ -0,0 +1,292 @@
//! Cross-tenant slug transfer business logic.
//!
//! Copies URL/page content between tenant content DBs, then updates v0.8 slug
//! databases ownership. Handlers own admin auth and HTTP mapping.
use crate::db::tenant::TenantOpenMode;
use crate::identity::TenantId;
use crate::state::{AppState, UserDbs};
use crate::utils::lock_db;
use chrono::Utc;
#[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 old_owner_tenant_id: TenantId,
pub new_owner_tenant_id: TenantId,
pub target_type: String,
pub new_target_id: String,
}
/// Look up slug ownership in v0.8 slug databases (`global_urls.db` / `global_landing_pages.db`).
pub fn lookup_slug(
state: &AppState,
slug: &str,
) -> Result<crate::db::slugs::ResolvedSlugInfo, TransferError> {
match state.lookup_slug(slug) {
Ok(Some(info)) => Ok(info),
Ok(None) => Err(TransferError::NotFound("Slug not found")),
Err(e) => Err(TransferError::Internal(e.to_string())),
}
}
/// 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 + v0.8 slug registry + quotas + history).
pub fn transfer_slug(
state: &AppState,
req: &SlugTransferRequest,
admin_username: &str,
) -> Result<SlugTransferResult, TransferError> {
let slug_info = lookup_slug(state, &req.slug)?;
let target_type = slug_info.target_type.as_str().to_string();
let old_owner_tenant_id = TenantId::parse(&slug_info.owner_tenant_id).map_err(|_| {
TransferError::Internal(format!(
"Invalid owner tenant ID '{}' on slug '{}'",
slug_info.owner_tenant_id, req.slug
))
})?;
// Look up old owner user row in users.db
let (old_owner_user_id, new_owner_tenant_id) = {
let users_conn = lock_db(&state.users_db, "users_db")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let old_user = crate::db::users::get_user_by_tenant_id(&users_conn, old_owner_tenant_id)
.map_err(|e| TransferError::Internal(e.to_string()))?
.ok_or_else(|| {
TransferError::Internal(format!(
"Current owner user for tenant {old_owner_tenant_id} not found"
))
})?;
let new_user = crate::db::users::get_user_by_id(&users_conn, req.new_owner_user_id)
.map_err(|e| TransferError::Internal(e.to_string()))?
.ok_or_else(|| {
TransferError::BadRequest(format!(
"Target user ID {} not found",
req.new_owner_user_id
))
})?;
if new_user.account_type == "admin" {
return Err(TransferError::BadRequest(
"Target user cannot be an Admin account (must be a tenant account)".into(),
));
}
let new_tid = new_user.tenant_id.ok_or_else(|| {
TransferError::BadRequest("Target user has no TenantId allocated".into())
})?;
(old_user.id, new_tid)
};
if old_owner_tenant_id == new_owner_tenant_id {
return Err(TransferError::BadRequest(
"New owner must be different from the current owner".into(),
));
}
let old_dbs = state
.open_tenant(old_owner_tenant_id, TenantOpenMode::CoreJob)
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
let new_dbs = state
.open_tenant(new_owner_tenant_id, TenantOpenMode::Provision)
.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,
)?;
// Update authoritative v0.8 slug databases
{
let urls_conn = lock_db(&state.db.global_urls, "global_urls")
.map_err(|e| TransferError::Internal(e.to_string()))?;
let pages_conn = lock_db(&state.db.global_landing_pages, "global_landing_pages")
.map_err(|e| TransferError::Internal(e.to_string()))?;
crate::db::slugs::transfer_slug_owner(
&urls_conn,
&pages_conn,
&req.slug,
&new_owner_tenant_id,
&new_target_id,
)
.map_err(|e| TransferError::Internal(format!("Failed to update slug registry: {e}")))?;
}
// Write audit event and history
{
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(
"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 tenant {} (user {}) to tenant {} (user {})",
old_owner_tenant_id, old_owner_user_id, new_owner_tenant_id, req.new_owner_user_id
)),
);
}
Ok(SlugTransferResult {
old_owner_user_id,
new_owner_user_id: req.new_owner_user_id,
old_owner_tenant_id,
new_owner_tenant_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);
}
}
+114 -65
View File
@@ -1,26 +1,20 @@
use crate::analytics::queue::AnalyticsQueue;
use crate::config::Config;
use crate::db::Db;
use rusqlite::Connection;
use crate::identity::TenantId;
use rusqlite::{Connection, OptionalExtension};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::time::Instant;
#[derive(Clone)]
pub struct UserDbs {
pub content: Arc<Mutex<Connection>>,
pub analytics: Arc<Mutex<Connection>>,
pub profile: Arc<Mutex<Connection>>,
}
pub use crate::db::tenant::{TenantOpenMode, UserDbs};
#[derive(Clone)]
pub struct AppState {
pub admin_db: Arc<Mutex<Connection>>,
pub content_db: Arc<Mutex<Connection>>,
pub analytics_db: Arc<Mutex<Connection>>,
pub system_db: Arc<Mutex<Connection>>,
pub users_db: Arc<Mutex<Connection>>,
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
pub user_dbs: Arc<Mutex<HashMap<String, UserDbs>>>,
pub db: Db,
pub config: Config,
pub analytics_queue: AnalyticsQueue,
@@ -28,71 +22,126 @@ pub struct AppState {
}
impl AppState {
/// Compatibility opener: Core `users.id` must refer to a registered,
/// non-deleted user. Unknown ids never create a database.
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
let mut pool = self.user_dbs.lock().unwrap();
if let Some(dbs) = pool.get(&user_id) {
return Ok(dbs.clone());
self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
}
// Open connection and run migrations
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
std::fs::create_dir_all(&user_dir)?;
let content_path = user_dir.join("content.db");
let analytics_path = user_dir.join("analytics.db");
let profile_path = user_dir.join("profile.db");
let mut content_conn = Connection::open(content_path)?;
let mut analytics_conn = Connection::open(analytics_path)?;
let profile_conn = Connection::open(profile_path)?;
crate::db::sqlite::enable_wal(&content_conn, "content")?;
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
// Run migrations
crate::db::migrations::run_migrations(
&mut content_conn,
"content",
crate::db::migrations::CONTENT_MIGRATIONS,
Some(&self.system_db),
pub fn get_user_dbs_with_mode(
&self,
user_id: i64,
mode: TenantOpenMode,
) -> Result<UserDbs, crate::error::AppError> {
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
crate::db::tenant::open_for_row_id(
&users,
&self.db.topology,
&self.system_db,
&mut pool,
user_id,
mode,
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
crate::db::migrations::run_migrations(
&mut analytics_conn,
"analytics",
crate::db::migrations::ANALYTICS_MIGRATIONS,
Some(&self.system_db),
}
/// Frozen tenant opener. Unknown TenantId never creates a database.
pub fn open_tenant(
&self,
tenant_id: TenantId,
mode: TenantOpenMode,
) -> Result<UserDbs, crate::error::AppError> {
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
crate::db::tenant::open_for_tenant_id(
&users,
&self.db.topology,
&self.system_db,
&mut pool,
tenant_id,
mode,
)
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
}
profile_conn.execute_batch(
"CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);",
)?;
pub fn lookup_slug(
&self,
slug: &str,
) -> Result<Option<crate::db::slugs::ResolvedSlugInfo>, crate::error::AppError> {
let urls_conn = crate::utils::lock_db(&self.db.global_urls, "global_urls")?;
let pages_conn =
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?;
if let Some(info) = crate::db::slugs::lookup_slug(&urls_conn, &pages_conn, slug)? {
return Ok(Some(info));
}
let dbs = UserDbs {
content: Arc::new(Mutex::new(content_conn)),
analytics: Arc::new(Mutex::new(analytics_conn)),
profile: Arc::new(Mutex::new(profile_conn)),
// Fallback to system.db.global_slugs if table exists (backward compatibility during transition)
let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?;
let has_table: 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_table {
let row_opt: Option<(i64, String, String, String)> = system_conn
.query_row(
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
[slug],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.optional()?;
if let Some((owner_id, target_type, target_id, status)) = row_opt {
let tt = match target_type.as_str() {
"page" => crate::db::slugs::SlugTargetType::LandingPage,
_ => crate::db::slugs::SlugTargetType::Url,
};
pool.insert(user_id, dbs.clone());
Ok(dbs)
return Ok(Some(crate::db::slugs::ResolvedSlugInfo {
slug: slug.to_string(),
owner_tenant_id: owner_id.to_string(),
target_type: tt,
target_id,
created_at: String::new(),
updated_at: String::new(),
status,
retired_at: None,
}));
}
}
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;", [])?;
Ok(None)
}
pub fn open_slug_owner(
&self,
owner_tenant_id: &str,
mode: TenantOpenMode,
) -> Result<UserDbs, crate::error::AppError> {
if owner_tenant_id == "legacy_admin" || owner_tenant_id == "1" {
return self.get_user_dbs_with_mode(1, mode);
}
if let Ok(tid) = TenantId::parse(owner_tenant_id) {
return self.open_tenant(tid, mode);
}
if let Ok(uid) = owner_tenant_id.parse::<i64>() {
return self.get_user_dbs_with_mode(uid, mode);
}
Err(crate::error::AppError::NotFound(format!(
"invalid owner identity: {}",
owner_tenant_id
)))
}
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.system_db, "system_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.global_urls, "global_urls")?.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?
.execute("VACUUM;", [])?;
crate::utils::lock_db(&self.db.reserved, "reserved")?.execute("VACUUM;", [])?;
Ok(())
}
}
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response},
};
pub struct AdminPageRow {
pub page: LandingPage,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)]
#[template(path = "pages.html")]
pub struct PagesTemplate {
pub admin_username: String,
pub pages: Vec<LandingPage>,
pub pages: Vec<AdminPageRow>,
pub csrf_token: String,
pub error: Option<String>,
pub current_page: usize,
+7 -1
View File
@@ -5,11 +5,17 @@ use axum::{
response::{Html, IntoResponse, Response},
};
pub struct AdminUrlRow {
pub url: Url,
pub owner_tenant_id: String,
pub owner_username: Option<String>,
}
#[derive(Template)]
#[template(path = "urls.html")]
pub struct UrlsTemplate {
pub admin_username: String,
pub urls: Vec<Url>,
pub urls: Vec<AdminUrlRow>,
pub csrf_token: String,
pub error: Option<String>,
pub tag_filter: Option<String>,
+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
}
}
+8 -4
View File
@@ -19,10 +19,14 @@ 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"),
("slugs/global_urls.db", "Global URLs DB"),
("slugs/global_landing_pages.db", "Global Landing Pages DB"),
("slugs/reserved.db", "Reserved Slugs 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()
}
+548
View File
@@ -0,0 +1,548 @@
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> {
crate::db::users::get_user_by_username(conn, username)
}
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())
)
}
pub(crate) fn clear_admin_cookie(jar: CookieJar) -> CookieJar {
let cookie = Cookie::build("bzod_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
jar.add(cookie)
}
pub(crate) fn clear_user_cookie(jar: CookieJar) -> CookieJar {
let cookie = Cookie::build("bzod_user_session")
.path("/")
.max_age(time::Duration::ZERO)
.build();
jar.add(cookie)
}
pub(crate) fn invalidate_session_in_db(state: &AppState, session_id: &str) {
if session_id.trim().is_empty() {
return;
}
if let Ok(conn) = state.users_db.lock() {
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [session_id]);
}
}
// 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 created_user_res = {
let conn = match state.users_db.lock() {
Ok(c) => c,
Err(_) => {
return Redirect::to("/admin/login?error=Internal error").into_response();
}
};
crate::db::users::create_admin_user(&conn, &form.username, &hash)
};
match created_user_res {
Ok(u) => {
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();
// Invalidate any existing sessions from the jar
if let Some(old_admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, old_admin_cookie.value());
}
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
invalidate_session_in_db(&state, old_user_cookie.value());
}
{
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 = clear_user_cookie(response_jar);
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 {
if let Some(user_cookie) = jar.get("bzod_user_session") {
invalidate_session_in_db(&state, user_cookie.value());
}
if let Some(admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, admin_cookie.value());
}
let mut response_jar = jar.clone();
response_jar = clear_user_cookie(response_jar);
response_jar = clear_admin_cookie(response_jar);
(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();
match crate::db::users::get_user_by_username(&conn, &form.username) {
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();
// Invalidate any existing sessions from the jar
if let Some(old_admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, old_admin_cookie.value());
}
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
invalidate_session_in_db(&state, old_user_cookie.value());
}
{
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);
if user.account_type == "admin" {
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("/login")
.max_age(time::Duration::ZERO)
.build();
let mut response_jar = jar.clone();
response_jar = clear_user_cookie(response_jar);
response_jar = response_jar.add(cookie).add(clear_temp);
(response_jar, Redirect::to("/admin/dashboard")).into_response()
} else {
if user.tenant_id.is_none() {
return Redirect::to("/login?error=Invalid tenant configuration")
.into_response();
}
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 = clear_admin_cookie(response_jar);
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 Some(admin_cookie) = jar.get("bzod_session") {
invalidate_session_in_db(&state, admin_cookie.value());
}
if let Some(user_cookie) = jar.get("bzod_user_session") {
invalidate_session_in_db(&state, user_cookie.value());
}
let mut response_jar = jar.clone();
response_jar = clear_admin_cookie(response_jar);
response_jar = clear_user_cookie(response_jar);
(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));
}
}
Loaded 100 of 173 files, more files were not shown because too many files have changed in this diff. Show more