Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5d827322 | ||
|
|
feed352c2e | ||
|
|
e42d1a5d80 | ||
|
|
c2e138f823 | ||
|
|
d398341f01 | ||
|
|
d7e0ac7679 | ||
|
|
f138a645f8 | ||
|
|
ac66945c93 | ||
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 | ||
|
|
2cd3c2d965 | ||
|
|
b66703082f | ||
|
|
f49698bb5c | ||
|
|
7069ca9db7 | ||
|
|
faf8fc0eda | ||
|
|
bf29ccab56 | ||
|
|
667503c8f6 | ||
|
|
83218ba602 | ||
|
|
58c0af6510 | ||
|
|
f4947489af |
No files matched your search
@@ -1,17 +1,19 @@
|
|||||||
# BZOD Platform Configuration
|
# BZOD Platform Configuration
|
||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=8080
|
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
|
# Security Settings
|
||||||
COOKIE_SECURE=false
|
COOKIE_SECURE=false
|
||||||
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
|
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
|
||||||
|
|
||||||
# Bootstrap Admin Credentials
|
# Bootstrap Admin Credentials
|
||||||
# Default username: admin
|
|
||||||
# Default password: admin
|
|
||||||
ADMIN_USERNAME=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)
|
# Cron & Cleaner Intervals (in minutes)
|
||||||
LINK_CHECK_INTERVAL_MINS=60
|
LINK_CHECK_INTERVAL_MINS=60
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
/target
|
/target/
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
*.db-wal
|
*.db-wal
|
||||||
@@ -6,3 +6,7 @@ data/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
.idea/
|
.idea/
|
||||||
|
bzod.env
|
||||||
|
|
||||||
|
# Local database backups
|
||||||
|
backups/
|
||||||
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
version = "0.5.1"
|
version = "0.8.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"argon2",
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "bzod"
|
name = "bzod"
|
||||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||||
version = "0.5.2"
|
version = "0.8.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
repository = "https://github.com/thakares/nx9-url-shortener"
|
repository = "https://github.com/thakares/nx9-url-shortener"
|
||||||
|
|||||||
@@ -1,73 +1,95 @@
|
|||||||
# ==========================================
|
# ==========================================
|
||||||
# Stage 1: Builder (with optimized caching)
|
# Stage 1: Builder
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM rust:1.89-bookworm AS builder
|
FROM rust:1.89-bookworm AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Install build dependencies
|
# Build dependencies
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
pkg-config \
|
pkg-config \
|
||||||
libssl-dev \
|
libssl-dev \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
|
||||||
# Create dummy source for dependency caching
|
# Dummy build to cache Rust dependencies
|
||||||
RUN mkdir -p src && \
|
RUN mkdir -p src && \
|
||||||
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
|
printf 'fn main() {}\n' > src/main.rs && \
|
||||||
cargo build --release && \
|
cargo build --release --locked && \
|
||||||
rm -rf src target/release/deps/bzod*
|
rm -rf src
|
||||||
|
|
||||||
# Copy real source code + assets
|
# Actual application source and runtime assets
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY templates ./templates
|
COPY templates ./templates
|
||||||
COPY www ./www
|
COPY www ./www
|
||||||
|
|
||||||
# Build the real application
|
# Reproducible production build
|
||||||
RUN cargo build --release
|
RUN cargo build --release --locked
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# Stage 2: Runtime (slim)
|
# Stage 2: Runtime
|
||||||
# ==========================================
|
# ==========================================
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim AS runtime
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Runtime dependencies
|
# Runtime dependencies only
|
||||||
RUN apt-get update && apt-get install -y \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
openssl \
|
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
curl \
|
curl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& 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 --from=builder /app/target/release/bzod /usr/local/bin/bzod
|
||||||
|
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||||
|
|
||||||
# Copy assets
|
# Application-owned immutable assets
|
||||||
COPY --from=builder /app/templates ./templates
|
COPY --from=builder /app/templates /app/templates
|
||||||
COPY --from=builder /app/www ./www
|
COPY --from=builder /app/www /app/www
|
||||||
|
|
||||||
# Create non-root user
|
# Persistent runtime directories.
|
||||||
RUN groupadd -g 1000 bzod && \
|
# /app/images is intentionally external/persistent in Compose.
|
||||||
useradd -u 1000 -g bzod -m -s /bin/bash bzod
|
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
|
# Runtime configuration
|
||||||
RUN mkdir -p /app/data && \
|
ENV NX9_BZOD_DATA_DIR=/app/data \
|
||||||
chown -R bzod:bzod /app
|
CONFIG_DIR=/app/config \
|
||||||
|
IMAGES_DIR=/app/images \
|
||||||
USER bzod
|
|
||||||
|
|
||||||
ENV DATA_DIR=/app/data \
|
|
||||||
PORT=8654 \
|
PORT=8654 \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
|
|
||||||
EXPOSE 8654
|
EXPOSE 8654
|
||||||
|
|
||||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
HEALTHCHECK \
|
||||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
--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"]
|
CMD ["serve"]
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,226 +1,475 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
SERVICE_USER="bzod"
|
# ============================================================
|
||||||
INSTALL_PATH="/usr/local/bin/bzod"
|
# Configuration
|
||||||
CONFIG_DIR="/etc/bzod"
|
# ============================================================
|
||||||
DATA_DIR="/var/lib/bzod/data"
|
|
||||||
ENV_FILE="${CONFIG_DIR}/bzod.env"
|
BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
|
||||||
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
|
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'
|
RED='\033[0;31m'
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
|
YELLOW='\033[1;33m'
|
||||||
NC='\033[0m'
|
NC='\033[0m'
|
||||||
|
|
||||||
# Temporary file cleanup
|
info() {
|
||||||
TMP_BINARY=""
|
echo -e "${BLUE}$*${NC}"
|
||||||
cleanup() {
|
|
||||||
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
|
|
||||||
}
|
}
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
|
success() {
|
||||||
echo -e "Production deployment started...\n"
|
echo -e "${GREEN}$*${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
if [ "$EUID" -ne 0 ]; then
|
warning() {
|
||||||
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
|
echo -e "${YELLOW}$*${NC}"
|
||||||
|
}
|
||||||
|
|
||||||
|
error() {
|
||||||
|
echo -e "${RED}$*${NC}" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
die() {
|
||||||
|
error "$*"
|
||||||
exit 1
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Root check
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
if [[ "${EUID}" -ne 0 ]]; then
|
||||||
|
die "This script must be run as root. Use: sudo bash deploy.sh"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 1. Install Base Dependencies
|
echo
|
||||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
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 update -qq
|
||||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
apt-get install -y \
|
||||||
|
ca-certificates \
|
||||||
|
curl
|
||||||
|
|
||||||
# 2. Install Binary (safe atomic download)
|
install -m 0755 -d /etc/apt/keyrings
|
||||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
|
||||||
|
|
||||||
ARCH="$(uname -m)"
|
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||||
case $ARCH in
|
curl -fsSL \
|
||||||
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
|
https://download.docker.com/linux/debian/gpg \
|
||||||
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
|
-o /etc/apt/keyrings/docker.asc
|
||||||
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
|
|
||||||
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
REPO="thakares/nx9-url-shortener"
|
chmod a+r /etc/apt/keyrings/docker.asc
|
||||||
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
|
fi
|
||||||
|
|
||||||
if [ ! -s "${TMP_BINARY}" ]; then
|
. /etc/os-release
|
||||||
echo -e "${BLUE}Falling back to local build...${NC}"
|
|
||||||
if ! command -v cargo >/dev/null 2>&1; then
|
echo \
|
||||||
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
|
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
||||||
exit 1
|
https://download.docker.com/linux/debian \
|
||||||
fi
|
${VERSION_CODENAME} stable" \
|
||||||
apt-get install -y pkg-config build-essential
|
> /etc/apt/sources.list.d/docker.list
|
||||||
cargo build --release
|
|
||||||
cp target/release/bzod "${TMP_BINARY}"
|
apt-get update -qq
|
||||||
echo -e "${GREEN}✓ Built from source${NC}"
|
|
||||||
fi
|
apt-get install -y \
|
||||||
|
docker-ce \
|
||||||
|
docker-ce-cli \
|
||||||
|
containerd.io \
|
||||||
|
docker-buildx-plugin \
|
||||||
|
docker-compose-plugin
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Atomic replace with backup
|
if ! docker info >/dev/null 2>&1; then
|
||||||
if [ -f "${INSTALL_PATH}" ]; then
|
systemctl enable --now docker
|
||||||
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
|
if ! docker compose version >/dev/null 2>&1; then
|
||||||
|
die "Docker Compose plugin is unavailable."
|
||||||
# Verify
|
|
||||||
if [ ! -x "${INSTALL_PATH}" ]; then
|
|
||||||
echo -e "${RED}Binary installation failed${NC}"
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
|
success "✓ Docker and Docker Compose available"
|
||||||
echo -e "${RED}Binary verification failed${NC}"
|
|
||||||
exit 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Show installed version
|
# ============================================================
|
||||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
# 2. Create persistent directories
|
||||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
# ============================================================
|
||||||
|
|
||||||
# 3. Create System User
|
info "[2/8] Creating persistent application directories..."
|
||||||
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
|
|
||||||
if ! id -u "${SERVICE_USER}" &>/dev/null; then
|
mkdir -p \
|
||||||
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
|
"${NX9_BZOD_DATA_DIR}" \
|
||||||
fi
|
"${CONFIG_DIR}" \
|
||||||
|
"${IMAGES_DIR}" \
|
||||||
|
"${COMPOSE_DIR}" \
|
||||||
|
"${BACKUP_ROOT}"
|
||||||
|
|
||||||
# 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"
|
|
||||||
chmod 700 "${CONFIG_DIR}"
|
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
|
HOST=0.0.0.0
|
||||||
PORT=8654
|
PORT=8654
|
||||||
DATA_DIR=${DATA_DIR}
|
|
||||||
|
NX9_BZOD_DATA_DIR=/app/data
|
||||||
|
CONFIG_DIR=/app/config
|
||||||
|
IMAGES_DIR=/app/images
|
||||||
|
|
||||||
COOKIE_SECURE=true
|
COOKIE_SECURE=true
|
||||||
RUST_LOG=info
|
RUST_LOG=info
|
||||||
SESSION_SECRET=$(openssl rand -hex 32)
|
BASE_URL=${BASE_URL}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod 600 "${ENV_FILE}"
|
chmod 600 "${ENV_FILE}"
|
||||||
chown root:"${SERVICE_USER}" "${ENV_FILE}"
|
|
||||||
|
success "✓ New Docker configuration created"
|
||||||
|
|
||||||
else
|
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
|
fi
|
||||||
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
|
|
||||||
|
|
||||||
[Service]
|
# ============================================================
|
||||||
Type=simple
|
# 4. Create Compose definition
|
||||||
User=${SERVICE_USER}
|
# ============================================================
|
||||||
Group=${SERVICE_USER}
|
|
||||||
WorkingDirectory=/var/lib/bzod
|
|
||||||
EnvironmentFile=${ENV_FILE}
|
|
||||||
ExecStart=${INSTALL_PATH} serve
|
|
||||||
|
|
||||||
Restart=on-failure
|
info "[4/8] Writing Docker Compose configuration..."
|
||||||
RestartSec=5s
|
|
||||||
|
|
||||||
# Security Hardening
|
cat > "${COMPOSE_FILE}" <<'EOF'
|
||||||
ProtectSystem=strict
|
services:
|
||||||
ProtectHome=yes
|
|
||||||
PrivateTmp=yes
|
|
||||||
PrivateDevices=yes
|
|
||||||
ProtectKernelTunables=yes
|
|
||||||
ProtectKernelModules=yes
|
|
||||||
ProtectControlGroups=yes
|
|
||||||
ProtectHostname=yes
|
|
||||||
RestrictSUIDSGID=yes
|
|
||||||
LockPersonality=yes
|
|
||||||
NoNewPrivileges=yes
|
|
||||||
ReadWritePaths=/var/lib/bzod
|
|
||||||
|
|
||||||
[Install]
|
bzod:
|
||||||
WantedBy=multi-user.target
|
image: ${BZOD_IMAGE}:${BZOD_VERSION}
|
||||||
|
container_name: bzod
|
||||||
|
|
||||||
|
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
|
EOF
|
||||||
|
|
||||||
chmod 644 "${SYSTEMD_UNIT}"
|
# Append BZOD_ROOT because compose needs it.
|
||||||
systemctl daemon-reload
|
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
|
||||||
|
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
|
||||||
# 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}"
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
systemctl enable --now bzod
|
# Port variable expected by compose.
|
||||||
|
if ! grep -q '^PORT=' "${ENV_FILE}"; then
|
||||||
# 8. Validation + Rollback
|
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
|
||||||
sleep 3
|
|
||||||
|
|
||||||
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
|
fi
|
||||||
journalctl -u bzod -n 50 --no-pager
|
|
||||||
|
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}"
|
||||||
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up backup on success
|
success "✓ Container is running"
|
||||||
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
|
|
||||||
|
|
||||||
# Soft health check
|
# ------------------------------------------------------------
|
||||||
if command -v curl >/dev/null 2>&1; then
|
# Health check
|
||||||
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
|
# ------------------------------------------------------------
|
||||||
echo -e "${GREEN}✓ HTTP health check passed${NC}"
|
|
||||||
|
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
|
else
|
||||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
warning "⚠ HTTP health check did not respond yet"
|
||||||
fi
|
warning "The container is running; inspect logs if necessary:"
|
||||||
|
echo
|
||||||
|
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Final Message
|
# ============================================================
|
||||||
IP=$(hostname -I | awk '{print $1}' | head -n1)
|
# Verify image and binary
|
||||||
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"
|
|
||||||
|
|
||||||
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."
|
||||||
@@ -1,52 +1,87 @@
|
|||||||
name: app-bzod
|
name: app-bzod
|
||||||
|
|
||||||
services:
|
services:
|
||||||
bzod:
|
bzod:
|
||||||
build:
|
build:
|
||||||
context: /DATA/AppData/bzod
|
context: /DATA/AppData/nx9-url-shortener
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
cpu_shares: 90
|
|
||||||
command: []
|
|
||||||
container_name: bzod
|
container_name: bzod
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
memory: 31940M
|
memory: 31940M
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||||
|
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||||
|
- CONFIG_DIR=/app/config
|
||||||
- COOKIE_SECURE=false
|
- COOKIE_SECURE=false
|
||||||
- DATA_DIR=/app/data
|
- NX9_BZOD_DATA_DIR=/app/data
|
||||||
- HOST=0.0.0.0
|
- HOST=0.0.0.0
|
||||||
|
- IMAGES_DIR=/app/images
|
||||||
- PORT=8654
|
- PORT=8654
|
||||||
- RUST_LOG=info
|
- RUST_LOG=info
|
||||||
|
- BASE_URL=${BASE_URL}
|
||||||
|
|
||||||
hostname: bzod
|
hostname: bzod
|
||||||
image: nx9-url-shortener:v0.4.0
|
|
||||||
|
image: nx9-url-shortener:v0.8.0
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- mode: ingress
|
- mode: ingress
|
||||||
target: 8654
|
target: 8654
|
||||||
published: "8654"
|
published: "8654"
|
||||||
protocol: tcp
|
protocol: tcp
|
||||||
|
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
security_opt:
|
||||||
|
- no-new-privileges:true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/bzod/data
|
source: /DATA/AppData/nx9-url-shortener/data
|
||||||
target: /app/data
|
target: /app/data
|
||||||
bind:
|
bind:
|
||||||
create_host_path: true
|
create_host_path: true
|
||||||
|
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/bzod/config
|
source: /DATA/AppData/nx9-url-shortener/config
|
||||||
target: /app/config
|
target: /app/config
|
||||||
bind:
|
bind:
|
||||||
create_host_path: true
|
create_host_path: true
|
||||||
|
|
||||||
- type: bind
|
- type: bind
|
||||||
source: /DATA/AppData/bzod/www
|
source: /DATA/AppData/nx9-url-shortener/www
|
||||||
target: /app/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: []
|
devices: []
|
||||||
|
|
||||||
cap_add: []
|
cap_add: []
|
||||||
|
|
||||||
|
command: []
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
- default
|
- default
|
||||||
|
|
||||||
privileged: false
|
privileged: false
|
||||||
|
|
||||||
|
cpu_shares: 90
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
default:
|
default:
|
||||||
name: app_default
|
name: app_default
|
||||||
|
|
||||||
x-casaos:
|
x-casaos:
|
||||||
author: self
|
author: self
|
||||||
category: self
|
category: self
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
exec /usr/local/bin/bzod "$@"
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Administrator Guide
|
# BZOD Administrator Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Architecture Guide
|
# BZOD Architecture Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,7 +89,22 @@ Responsible for:
|
|||||||
Major modules:
|
Major modules:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
admin.rs
|
admin/ (modular feature directory)
|
||||||
|
auth.rs (authentication and session handling)
|
||||||
|
dashboard.rs (dashboard rendering)
|
||||||
|
urls.rs (URL management handlers)
|
||||||
|
pages.rs (landing page management handlers)
|
||||||
|
analytics.rs (analytics and export handlers)
|
||||||
|
settings.rs (settings and configuration handlers)
|
||||||
|
users.rs (user management handlers)
|
||||||
|
sessions.rs (session administration)
|
||||||
|
quotas.rs (quota management)
|
||||||
|
health.rs (health diagnostics)
|
||||||
|
backups.rs (backup and restore handlers)
|
||||||
|
api_keys.rs (API key management)
|
||||||
|
audit.rs (audit log handlers)
|
||||||
|
moderation.rs (content moderation handlers)
|
||||||
|
mod.rs (module exports and shared helpers)
|
||||||
api.rs
|
api.rs
|
||||||
pages.rs
|
pages.rs
|
||||||
redirect.rs
|
redirect.rs
|
||||||
@@ -339,9 +354,11 @@ Locate owner database
|
|||||||
↓
|
↓
|
||||||
Resolve URL
|
Resolve URL
|
||||||
↓
|
↓
|
||||||
|
Validate destination
|
||||||
|
↓
|
||||||
Record analytics
|
Record analytics
|
||||||
↓
|
↓
|
||||||
302 Redirect
|
301 Redirect (with safe Location header construction)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -590,7 +607,7 @@ Coverage includes:
|
|||||||
* Upgrade validation
|
* Upgrade validation
|
||||||
* Multi-user isolation
|
* Multi-user isolation
|
||||||
|
|
||||||
v0.5.0 includes more than 90 automated tests.
|
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -635,7 +652,7 @@ Planned for future releases:
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
BZOD v0.5.0 is built around a simple principle:
|
BZOD is built around a simple principle:
|
||||||
|
|
||||||
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Backup & Restore Guide
|
# Backup & Restore Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.8.0
|
||||||
Applies To: BZOD Multi-User Platform
|
Applies To: BZOD Multi-User Platform
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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.
|
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)
|
# v0.5.1 - General Availability (GA)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||||
|
|
||||||
The current command list for BZOD v0.5.1 is:
|
The current command list for BZOD v0.8.0 is:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
$ bzod --help
|
$ bzod --help
|
||||||
@@ -30,6 +30,8 @@ Commands:
|
|||||||
list-users List all standard/system users
|
list-users List all standard/system users
|
||||||
backup-user Backup a standard user's databases to a .tar.zst package
|
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
|
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)
|
help Print this message or the help of the given subcommand(s)
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
@@ -119,6 +121,19 @@ Performs:
|
|||||||
* Database availability checks
|
* Database availability checks
|
||||||
* Storage verification
|
* Storage verification
|
||||||
* System health diagnostics
|
* 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
|
* SECURITY.md
|
||||||
* API.md
|
* API.md
|
||||||
* ARCHITECTURE.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,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.
|
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
# BZOD Database Architecture
|
# 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,21 @@ cd nx9-url-shortener
|
|||||||
docker compose up -d --build
|
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
|
```bash
|
||||||
docker exec -it bzod bzod create-admin
|
docker exec -it bzod bzod create-admin
|
||||||
@@ -86,7 +100,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8654
|
PORT: 8654
|
||||||
DATA_DIR: /app/data
|
NX9_BZOD_DATA_DIR: /app/data
|
||||||
COOKIE_SECURE: "false"
|
COOKIE_SECURE: "false"
|
||||||
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -191,10 +205,10 @@ BZOD Docker Container
|
|||||||
# Environment Variables
|
# Environment Variables
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
| ------------- | ------------------ | --------- |
|
| ----------------- | ------------------ | ------------- |
|
||||||
| HOST | Bind address | 0.0.0.0 |
|
| HOST | Bind address | 0.0.0.0 |
|
||||||
| PORT | Listen port | 8654 |
|
| PORT | Listen port | 8654 |
|
||||||
| DATA_DIR | Database directory | /app/data |
|
| NX9_BZOD_DATA_DIR | Database directory | (required) |
|
||||||
| COOKIE_SECURE | Secure cookies | false |
|
| COOKIE_SECURE | Secure cookies | false |
|
||||||
| RUST_LOG | Logging level | info |
|
| RUST_LOG | Logging level | info |
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Installation Guide
|
# BZOD Installation Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
|||||||
Example:
|
Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz
|
wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Extract:
|
Extract:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz
|
tar -xzf bzod-v0.8.0-linux-amd64.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Multi-User Architecture Guide
|
# BZOD Multi-User Architecture Guide
|
||||||
|
|
||||||
Version: v0.5.1
|
Version: v0.8.0
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
||||||
|
|
||||||
**Release Date:** 2026-06-20
|
**Release Date:** 2026-06-20
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BZOD Security Guide
|
# 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
|
* Disaster recovery
|
||||||
* Operational simplicity
|
* Operational simplicity
|
||||||
|
|
||||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0.
|
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.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
|
# Audit Logging
|
||||||
|
|
||||||
Security-sensitive actions are logged.
|
Security-sensitive actions are logged.
|
||||||
@@ -599,7 +620,7 @@ If compromise is suspected:
|
|||||||
|
|
||||||
# Security Testing
|
# Security Testing
|
||||||
|
|
||||||
BZOD v0.5.0 includes tests covering:
|
BZOD v0.8.0 includes tests covering:
|
||||||
|
|
||||||
* Authentication
|
* Authentication
|
||||||
* Authorization
|
* Authorization
|
||||||
@@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering:
|
|||||||
* Upgrade migrations
|
* Upgrade migrations
|
||||||
* Backup integrity
|
* Backup integrity
|
||||||
* Disaster recovery
|
* Disaster recovery
|
||||||
|
* Redirect destination validation
|
||||||
|
* HTTP Location header safety
|
||||||
|
|
||||||
These tests are executed during CI and release validation.
|
These tests are executed during CI and release validation.
|
||||||
|
|
||||||
@@ -642,7 +665,7 @@ These may be addressed in future releases.
|
|||||||
|
|
||||||
# Summary
|
# Summary
|
||||||
|
|
||||||
BZOD v0.5.0 provides:
|
BZOD v0.8.0 provides:
|
||||||
|
|
||||||
* Centralized authentication
|
* Centralized authentication
|
||||||
* Secure session management
|
* Secure session management
|
||||||
|
|||||||
@@ -325,6 +325,38 @@ and:
|
|||||||
|
|
||||||
for final landing page render.
|
for final landing page render.
|
||||||
|
|
||||||
|
Root landing page:
|
||||||
|
|
||||||
|
```text
|
||||||
|
GET /
|
||||||
|
```
|
||||||
|
|
||||||
|
must serve the static landing page.
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
```http
|
||||||
|
200 OK
|
||||||
|
Content-Type: text/html
|
||||||
|
```
|
||||||
|
|
||||||
|
Redirect security:
|
||||||
|
|
||||||
|
Redirect destinations are validated against:
|
||||||
|
|
||||||
|
* Invalid URL schemes
|
||||||
|
* CRLF injection attempts
|
||||||
|
* Control character injection
|
||||||
|
* Malformed HTTP Location header values
|
||||||
|
|
||||||
|
Invalid destinations must return:
|
||||||
|
|
||||||
|
```http
|
||||||
|
500 Internal Server Error
|
||||||
|
```
|
||||||
|
|
||||||
|
and must not panic or produce malformed HTTP responses.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 12. Backup Validation
|
# 12. Backup Validation
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
# Upgrade Guide
|
# 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
|
# Overview
|
||||||
|
|
||||||
BZOD v0.5.1 is a platform hardening release focused on:
|
BZOD v0.5.1 is a platform hardening release focused on:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 399 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 199 KiB |
|
Before Width: | Height: | Size: 110 KiB After Width: | Height: | Size: 331 KiB |
|
Before Width: | Height: | Size: 102 KiB After Width: | Height: | Size: 227 KiB |
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 235 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 111 KiB After Width: | Height: | Size: 183 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 187 KiB |
@@ -8,15 +8,19 @@ pub struct AnalyticsQueue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AnalyticsQueue {
|
impl AnalyticsQueue {
|
||||||
pub fn new(db: Db, capacity: usize) -> Self {
|
pub fn new(
|
||||||
|
db: Db,
|
||||||
|
capacity: usize,
|
||||||
|
shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) -> (Self, tokio::task::JoinHandle<()>) {
|
||||||
let (sender, receiver) = mpsc::channel(capacity);
|
let (sender, receiver) = mpsc::channel(capacity);
|
||||||
|
|
||||||
// Spawn background worker to batch-write records
|
// Spawn background worker to batch-write records
|
||||||
tokio::spawn(async move {
|
let handle = tokio::spawn(async move {
|
||||||
super::worker::run_worker(db, receiver).await;
|
super::worker::run_worker(db, receiver, shutdown_rx).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
Self { sender }
|
(Self { sender }, handle)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt to queue a visit. Non-blocking.
|
// Attempt to queue a visit. Non-blocking.
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
|
|||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use crate::models::VisitRecord;
|
use crate::models::VisitRecord;
|
||||||
|
|
||||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
pub async fn run_worker(
|
||||||
|
db: Db,
|
||||||
|
mut receiver: mpsc::Receiver<VisitRecord>,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let mut batch = Vec::new();
|
let mut batch = Vec::new();
|
||||||
let batch_size = 50;
|
let batch_size = 50;
|
||||||
let flush_interval = Duration::from_secs(2);
|
let flush_interval = Duration::from_secs(2);
|
||||||
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
|||||||
flush_batch(&db, &mut batch);
|
flush_batch(&db, &mut batch);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Analytics worker flushing pending records");
|
||||||
|
flush_batch(&db, &mut batch);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"Flushing {} visits to user analytics databases",
|
"Flushing {} visits to tenant analytics databases",
|
||||||
batch.len()
|
batch.len()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group visits by owner_user_id
|
// Group visits by owner_tenant_id (or legacy user 1 fallback)
|
||||||
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
|
let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
|
||||||
std::collections::HashMap::new();
|
std::collections::HashMap::new();
|
||||||
|
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
|
||||||
|
|
||||||
for record in batch.drain(..) {
|
for record in batch.drain(..) {
|
||||||
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
|
if let Some(tenant_id) = record.owner_tenant_id {
|
||||||
groups.entry(user_id).or_default().push(record);
|
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 {
|
for (tenant_id, tenant_visits) in groups {
|
||||||
let db_path = db
|
let db_path = db.topology.tenant_analytics_db(tenant_id);
|
||||||
.data_dir
|
if !db_path.exists() {
|
||||||
.join("users")
|
error!(
|
||||||
.join(user_id.to_string())
|
"Refusing to write analytics for non-existent tenant analytics path: {:?}",
|
||||||
.join("analytics.db");
|
db_path
|
||||||
if let Some(parent) = db_path.parent() {
|
);
|
||||||
let _ = std::fs::create_dir_all(parent);
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
match rusqlite::Connection::open(&db_path) {
|
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_wal(&conn, "analytics");
|
||||||
let _ = crate::db::sqlite::enable_foreign_keys(&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!(
|
error!(
|
||||||
"Failed to write analytics batch to user {} database: {:?}",
|
"Failed to write analytics batch to tenant {} database: {:?}",
|
||||||
user_id, e
|
tenant_id, e
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(
|
error!(
|
||||||
"Failed to open analytics database for user {}: {:?}",
|
"Failed to open analytics database for tenant {}: {:?}",
|
||||||
user_id, e
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -198,26 +198,9 @@ pub fn authenticate_user_session(
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get tenant user (status must be 'active')
|
// Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some)
|
||||||
let mut stmt = users_conn.prepare(
|
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
|
||||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||||
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()?;
|
|
||||||
|
|
||||||
if let Some(user) = user_opt {
|
if let Some(user) = user_opt {
|
||||||
Ok(Some((user, session.id)))
|
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()?;
|
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||||
|
|
||||||
if let Some(user_id) = user_id_opt {
|
if let Some(user_id) = user_id_opt {
|
||||||
let mut stmt = users_conn.prepare(
|
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||||
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()?;
|
|
||||||
|
|
||||||
if let Some(user) = user_opt {
|
if let Some(user) = user_opt {
|
||||||
return Ok(Some(ApiActor::User(user)));
|
return Ok(Some(ApiActor::User(user)));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -44,7 +44,8 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Open databases
|
// 2. Open databases
|
||||||
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
|
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() {
|
if !legacy_content_path.exists() {
|
||||||
info!(
|
info!(
|
||||||
@@ -55,11 +56,7 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
|
|
||||||
db.init_user_databases(target_admin_id)?;
|
db.init_user_databases(target_admin_id)?;
|
||||||
let target_content_path = config
|
let target_content_path = topology.content_db_i64(target_admin_id)?;
|
||||||
.data_dir
|
|
||||||
.join("users")
|
|
||||||
.join(target_admin_id.to_string())
|
|
||||||
.join("content.db");
|
|
||||||
|
|
||||||
let mut legacy_conn = Connection::open(&legacy_content_path)?;
|
let mut legacy_conn = Connection::open(&legacy_content_path)?;
|
||||||
let mut target_conn = Connection::open(&target_content_path)?;
|
let mut target_conn = Connection::open(&target_content_path)?;
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,10 @@ struct UserBackupMetadata {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
account_type: String,
|
account_type: String,
|
||||||
metadata: Option<String>,
|
metadata: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tenant_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
uuid: Option<String>,
|
||||||
quotas: UserBackupQuotas,
|
quotas: UserBackupQuotas,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +98,7 @@ pub async fn run(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 4. Force checkpoint on user's databases
|
// 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")) {
|
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
|
||||||
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||||
}
|
}
|
||||||
@@ -119,6 +123,8 @@ pub async fn run(
|
|||||||
created_at: user.created_at,
|
created_at: user.created_at,
|
||||||
account_type: user.account_type,
|
account_type: user.account_type,
|
||||||
metadata: user.metadata,
|
metadata: user.metadata,
|
||||||
|
tenant_id: user.tenant_id.map(|t| t.to_string()),
|
||||||
|
uuid: user.uuid,
|
||||||
quotas,
|
quotas,
|
||||||
};
|
};
|
||||||
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
|
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tracing::{error, info};
|
|||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
data_dir: Option<String>,
|
data_dir: Option<String>,
|
||||||
mut config: Config,
|
mut config: Config,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
@@ -25,16 +26,18 @@ pub async fn run(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let password = read_input("Enter password: ");
|
let final_password = match password {
|
||||||
if password.trim().is_empty() {
|
Some(p) => p,
|
||||||
|
None => read_input("Enter password: "),
|
||||||
|
};
|
||||||
|
if final_password.trim().is_empty() {
|
||||||
error!("Password cannot be empty");
|
error!("Password cannot be empty");
|
||||||
return Ok(());
|
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 conn = db.users.lock().unwrap();
|
||||||
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
||||||
db.init_user_databases(u.id)?;
|
|
||||||
info!(
|
info!(
|
||||||
"Successfully created admin user: {} (ID: {})",
|
"Successfully created admin user: {} (ID: {})",
|
||||||
u.username, u.id
|
u.username, u.id
|
||||||
|
|||||||
@@ -15,11 +15,6 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
let db = Db::init(&config)?;
|
let db = Db::init(&config)?;
|
||||||
|
|
||||||
if user_id == 1 && !force {
|
|
||||||
error!("Deleting legacy_admin system account requires --force flag");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Capture user details
|
// Capture user details
|
||||||
let user_details = {
|
let user_details = {
|
||||||
let conn = db.users.lock().unwrap();
|
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)
|
if user_details.account_type == "admin" && !force {
|
||||||
{
|
error!("Deleting administrator account requires --force flag");
|
||||||
let mut system_conn = db.system.lock().unwrap();
|
return Ok(());
|
||||||
let tx = system_conn.transaction()?;
|
}
|
||||||
|
|
||||||
// Get all slugs owned by the user
|
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
|
||||||
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
|
|
||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
for slug in slugs {
|
if let Some(ref tid) = user_details.tenant_id {
|
||||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
let tid_str = tid.to_string();
|
||||||
let _ = tx.execute(
|
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)
|
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||||
rusqlite::params![slug, user_id, now, "cli"],
|
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
|
// 2. Delete tenant directory from disk
|
||||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
if let Some(ref tid) = user_details.tenant_id {
|
||||||
|
let user_dir = db.topology.tenant_dir(*tid);
|
||||||
if user_dir.exists() {
|
if user_dir.exists() {
|
||||||
let _ = std::fs::remove_dir_all(&user_dir);
|
let _ = std::fs::remove_dir_all(&user_dir);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
|
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,16 +24,27 @@ pub async fn run(
|
|||||||
|
|
||||||
let mut all_healthy = true;
|
let mut all_healthy = true;
|
||||||
|
|
||||||
// Define target databases in the new layout
|
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||||
let admin_dir = config.data_dir.join("admin");
|
|
||||||
let legacy_user_dir = config.data_dir.join("users").join("1");
|
|
||||||
|
|
||||||
let dbs = vec![
|
let dbs = vec![
|
||||||
("admin", admin_dir.join("admin.db")),
|
("admin", topology.admin_db()),
|
||||||
("system", admin_dir.join("system.db")),
|
("system", topology.system_db()),
|
||||||
("users", admin_dir.join("users.db")),
|
("users", topology.users_registry_db()),
|
||||||
("legacy content", legacy_user_dir.join("content.db")),
|
("global_urls", topology.global_urls_db()),
|
||||||
("legacy analytics", legacy_user_dir.join("analytics.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 {
|
for (db_name, db_path) in dbs {
|
||||||
@@ -92,8 +103,8 @@ pub async fn run(
|
|||||||
// Global Slug Registry Integrity Check
|
// Global Slug Registry Integrity Check
|
||||||
println!("Global Slug Registry Integrity Check");
|
println!("Global Slug Registry Integrity Check");
|
||||||
println!("====================================");
|
println!("====================================");
|
||||||
let system_db_path = admin_dir.join("system.db");
|
let system_db_path = topology.system_db();
|
||||||
let users_db_path = admin_dir.join("users.db");
|
let users_db_path = topology.users_registry_db();
|
||||||
|
|
||||||
if system_db_path.exists() && users_db_path.exists() {
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
match (
|
match (
|
||||||
@@ -101,32 +112,69 @@ pub async fn run(
|
|||||||
Connection::open(&users_db_path),
|
Connection::open(&users_db_path),
|
||||||
) {
|
) {
|
||||||
(Ok(sys_conn), Ok(usr_conn)) => {
|
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||||
match crate::db::users::verify_global_slug_registry_integrity(
|
match crate::services::registry_validator::RegistryValidator::scan(
|
||||||
&sys_conn,
|
&sys_conn,
|
||||||
&usr_conn,
|
&usr_conn,
|
||||||
&config.data_dir,
|
&config.data_dir,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok((errors, warnings)) => {
|
Ok(issues) => {
|
||||||
if errors.is_empty() && warnings.is_empty() {
|
if issues.is_empty() {
|
||||||
println!(" Status: HEALTHY (no issues found)");
|
println!(" Status: HEALTHY (no issues found)");
|
||||||
} else {
|
} else {
|
||||||
if !errors.is_empty() {
|
println!(" Status: ISSUES DETECTED");
|
||||||
println!(" Errors (Action Required):");
|
|
||||||
for err in &errors {
|
|
||||||
println!(" - {}", err);
|
|
||||||
}
|
|
||||||
all_healthy = false;
|
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):");
|
println!();
|
||||||
for warn in &warnings {
|
println!("Owner:");
|
||||||
println!(" - {}", warn);
|
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) => {
|
Err(e) => {
|
||||||
println!(" Status: ERROR running integrity check: {}", e);
|
println!(" Status: ERROR running registry scan: {}", e);
|
||||||
all_healthy = false;
|
all_healthy = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,28 @@ pub async fn run(
|
|||||||
return Err("Invalid short code or custom slug format".into());
|
return Err("Invalid short code or custom slug format".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
let url_opt = {
|
let owner_info = {
|
||||||
let conn = db.content.lock().unwrap();
|
let conn = db.global_urls.lock().unwrap();
|
||||||
crate::db::content::get_url_by_code(&conn, &normalized_code)?
|
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 {
|
match url_opt {
|
||||||
Some(url) => {
|
Some(url) => {
|
||||||
println!("{}", url.destination);
|
println!("{}", url.destination);
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -15,12 +15,91 @@ pub async fn run(
|
|||||||
if dry_run {
|
if dry_run {
|
||||||
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
||||||
info!("Data directory: {:?}", config.data_dir);
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
info!("Running database migrations...");
|
info!("Running database schema migrations...");
|
||||||
let _db = Db::init(&config)?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,31 @@
|
|||||||
use clap::{Parser, Subcommand};
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
pub mod admin_migrate;
|
||||||
|
pub mod audit_destinations;
|
||||||
pub mod backup;
|
pub mod backup;
|
||||||
|
pub mod backup_user;
|
||||||
pub mod create_admin;
|
pub mod create_admin;
|
||||||
|
pub mod create_user;
|
||||||
|
pub mod delete_user;
|
||||||
|
pub mod disable_user;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
|
pub mod enable_user;
|
||||||
pub mod expand;
|
pub mod expand;
|
||||||
|
pub mod init_admin;
|
||||||
|
pub mod list_users;
|
||||||
pub mod migrate;
|
pub mod migrate;
|
||||||
|
pub mod repair;
|
||||||
|
pub mod reset_password;
|
||||||
pub mod restore;
|
pub mod restore;
|
||||||
|
pub mod restore_user;
|
||||||
pub mod serve;
|
pub mod serve;
|
||||||
pub mod shorten;
|
pub mod shorten;
|
||||||
pub mod stats;
|
pub mod stats;
|
||||||
pub mod validate;
|
pub mod validate;
|
||||||
|
|
||||||
pub mod admin_migrate;
|
|
||||||
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)]
|
#[derive(Parser)]
|
||||||
#[command(name = "bzod")]
|
#[command(name = "bzod")]
|
||||||
|
#[command(version)]
|
||||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -72,6 +75,11 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
data_dir: Option<String>,
|
data_dir: Option<String>,
|
||||||
},
|
},
|
||||||
|
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
|
||||||
|
AuditDestinations {
|
||||||
|
#[arg(long)]
|
||||||
|
data_dir: Option<String>,
|
||||||
|
},
|
||||||
/// Create a new administrator user in the database
|
/// Create a new administrator user in the database
|
||||||
CreateAdmin {
|
CreateAdmin {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -79,6 +87,11 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
data_dir: Option<String>,
|
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
|
/// Run database diagnostics and health checks
|
||||||
Doctor {
|
Doctor {
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
@@ -179,4 +192,55 @@ pub enum Commands {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
force: bool,
|
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(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -1,21 +1,246 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
|
use crate::services::registry_validator::RegistryIssueType;
|
||||||
use flate2::read::GzDecoder;
|
use flate2::read::GzDecoder;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{self, Write};
|
use std::io::{self, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::{Path, PathBuf};
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
|
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
|
||||||
|
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
|
||||||
|
let manifest_path = temp_dir.join("backup_manifest.json");
|
||||||
|
if !manifest_path.exists() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match std::fs::read_to_string(&manifest_path) {
|
||||||
|
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
|
||||||
|
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
|
||||||
|
Err(_) => false,
|
||||||
|
},
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
|
||||||
|
fn is_flat_layout(temp_dir: &Path) -> bool {
|
||||||
|
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
|
||||||
|
///
|
||||||
|
/// This function:
|
||||||
|
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
|
||||||
|
/// 2. Reads the actual administrator identity from admin.db (preserving
|
||||||
|
/// the original username and argon2id password hash — no manufacturing).
|
||||||
|
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
|
||||||
|
/// 4. Creates an admin account with the original credentials.
|
||||||
|
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
|
||||||
|
/// accounts for any missing tenants.
|
||||||
|
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
|
||||||
|
|
||||||
|
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||||
|
let admin_db_path = temp_dir.join("admin").join("admin.db");
|
||||||
|
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||||
|
|
||||||
|
// Check if users.db already has the users table (i.e., not a legacy backup)
|
||||||
|
{
|
||||||
|
let conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
|
let has_users_table: bool = conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
if has_users_table {
|
||||||
|
info!("users.db already has users table; skipping legacy bootstrap");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
|
||||||
|
|
||||||
|
// Step 1: Run migrations to create the users.db schema
|
||||||
|
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||||
|
crate::db::sqlite::enable_wal(&users_conn, "users")?;
|
||||||
|
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
|
||||||
|
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
|
||||||
|
|
||||||
|
// Step 2: Read the actual administrator identity from admin.db
|
||||||
|
let (admin_username, admin_password_hash) = {
|
||||||
|
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
|
||||||
|
|
||||||
|
// The legacy admin.db users table has schema:
|
||||||
|
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
|
||||||
|
// Read the actual admin — typically the first (and often only) user.
|
||||||
|
let result: Result<(String, String), _> = admin_conn.query_row(
|
||||||
|
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||||
|
[],
|
||||||
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||||
|
);
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((username, hash)) => {
|
||||||
|
info!(
|
||||||
|
"Preserved administrator identity from legacy admin.db: username='{}'",
|
||||||
|
username
|
||||||
|
);
|
||||||
|
(username, hash)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
return Err(format!(
|
||||||
|
"Failed to read administrator credentials from legacy admin.db: {}",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
|
||||||
|
// This account owns the content.db/analytics.db from the flat backup (users/1/).
|
||||||
|
// It uses the original admin's password hash so no synthetic credentials are introduced.
|
||||||
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||||
|
rusqlite::params![
|
||||||
|
1i64,
|
||||||
|
"legacy_admin",
|
||||||
|
&admin_password_hash,
|
||||||
|
"disabled",
|
||||||
|
&now,
|
||||||
|
"system"
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
|
||||||
|
info!("Created legacy_admin system account (id=1) for tenant content ownership");
|
||||||
|
|
||||||
|
// Step 4: Create the actual admin account with original credentials
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (username, password_hash, status, created_at, account_type)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||||
|
rusqlite::params![
|
||||||
|
&admin_username,
|
||||||
|
&admin_password_hash,
|
||||||
|
"active",
|
||||||
|
&now,
|
||||||
|
"admin"
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let admin_id = users_conn.last_insert_rowid();
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
|
||||||
|
info!(
|
||||||
|
"Created admin account '{}' (id={}) with original credentials",
|
||||||
|
admin_username, admin_id
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
|
||||||
|
if system_db_path.exists() {
|
||||||
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
|
let has_global_slugs: bool = system_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if has_global_slugs {
|
||||||
|
let mut stmt =
|
||||||
|
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
|
||||||
|
let mut rows = stmt.query([])?;
|
||||||
|
while let Some(row) = rows.next()? {
|
||||||
|
let owner_id: i64 = row.get(0)?;
|
||||||
|
// Skip user 1 (legacy_admin) and the admin we just created
|
||||||
|
if owner_id == 1 || owner_id == admin_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this user already exists in users.db
|
||||||
|
let exists: bool = users_conn
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
|
[owner_id],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
// Create a disabled placeholder so RegistryValidator can resolve ownership.
|
||||||
|
// The tenant's actual databases were not included in the flat backup.
|
||||||
|
let placeholder_name = format!("restored_user_{}", owner_id);
|
||||||
|
users_conn.execute(
|
||||||
|
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||||
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||||
|
rusqlite::params![
|
||||||
|
owner_id,
|
||||||
|
&placeholder_name,
|
||||||
|
&admin_password_hash,
|
||||||
|
"disabled",
|
||||||
|
&now,
|
||||||
|
"standard",
|
||||||
|
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
|
||||||
|
warn!(
|
||||||
|
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
|
||||||
|
owner_id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify registry issues into hard errors vs warnings for legacy restore.
|
||||||
|
///
|
||||||
|
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
|
||||||
|
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
|
||||||
|
/// TenantAdminHasIsolatedContent
|
||||||
|
fn classify_registry_issues(
|
||||||
|
issues: &[crate::services::registry_validator::RegistryIssue],
|
||||||
|
is_legacy: bool,
|
||||||
|
) -> (
|
||||||
|
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||||
|
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||||
|
) {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
let mut warnings = Vec::new();
|
||||||
|
|
||||||
|
for issue in issues {
|
||||||
|
match issue.issue_type {
|
||||||
|
RegistryIssueType::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(
|
pub fn perform_restore(
|
||||||
file_path: &std::path::Path,
|
file_path: &Path,
|
||||||
data_dir: &std::path::Path,
|
data_dir: &Path,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// 1. Open the archive
|
// 1. Open and unpack the archive to a temporary directory
|
||||||
let f = File::open(file_path)?;
|
let f = File::open(file_path)?;
|
||||||
let tar_gz = GzDecoder::new(f);
|
let tar_gz = GzDecoder::new(f);
|
||||||
let mut archive = Archive::new(tar_gz);
|
let mut archive = Archive::new(tar_gz);
|
||||||
|
|
||||||
// 2. Unpack to temporary directory first
|
|
||||||
let temp_dir =
|
let temp_dir =
|
||||||
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&temp_dir)?;
|
std::fs::create_dir_all(&temp_dir)?;
|
||||||
@@ -25,9 +250,34 @@ pub fn perform_restore(
|
|||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Run validation on temp_dir
|
// 2. Detect backup format
|
||||||
let mut temp_config = Config::load();
|
let is_legacy = is_legacy_flat_backup(&temp_dir);
|
||||||
temp_config.data_dir = temp_dir.clone();
|
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
|
// Namespace audit
|
||||||
match crate::db::users::audit_slug_namespace(&temp_config) {
|
match crate::db::users::audit_slug_namespace(&temp_config) {
|
||||||
@@ -46,29 +296,51 @@ pub fn perform_restore(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Registry integrity check
|
// Registry integrity check
|
||||||
let system_db_path = if temp_dir.join("admin/system.db").exists() {
|
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||||
temp_dir.join("admin/system.db")
|
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||||
} else {
|
|
||||||
temp_dir.join("system.db")
|
|
||||||
};
|
|
||||||
let users_db_path = if temp_dir.join("admin/users.db").exists() {
|
|
||||||
temp_dir.join("admin/users.db")
|
|
||||||
} else {
|
|
||||||
temp_dir.join("users.db")
|
|
||||||
};
|
|
||||||
|
|
||||||
if system_db_path.exists() && users_db_path.exists() {
|
if system_db_path.exists() && users_db_path.exists() {
|
||||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||||
let users_conn = rusqlite::Connection::open(&users_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,
|
&system_conn,
|
||||||
&users_conn,
|
&users_conn,
|
||||||
&temp_dir,
|
&temp_dir,
|
||||||
|
None,
|
||||||
) {
|
) {
|
||||||
Ok((errors, _warnings)) => {
|
Ok(issues) => {
|
||||||
if !errors.is_empty() {
|
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);
|
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) => {
|
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() {
|
if data_dir.exists() {
|
||||||
let _ = std::fs::remove_dir_all(data_dir);
|
let _ = std::fs::remove_dir_all(data_dir);
|
||||||
}
|
}
|
||||||
std::fs::create_dir_all(data_dir)?;
|
std::fs::create_dir_all(data_dir)?;
|
||||||
|
|
||||||
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||||
std::fs::create_dir_all(dst)?;
|
std::fs::create_dir_all(dst)?;
|
||||||
for entry in std::fs::read_dir(src)? {
|
for entry in std::fs::read_dir(src)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
use crate::identity::TenantId;
|
||||||
|
use chrono::Utc;
|
||||||
|
use rusqlite::OptionalExtension;
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tar::Archive;
|
use tar::Archive;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
use uuid::Uuid;
|
||||||
use zstd::Decoder;
|
use zstd::Decoder;
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
#[derive(serde::Serialize, serde::Deserialize)]
|
||||||
@@ -15,6 +19,10 @@ struct UserBackupMetadata {
|
|||||||
created_at: String,
|
created_at: String,
|
||||||
account_type: String,
|
account_type: String,
|
||||||
metadata: Option<String>,
|
metadata: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
tenant_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
uuid: Option<String>,
|
||||||
quotas: UserBackupQuotas,
|
quotas: UserBackupQuotas,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,26 +78,72 @@ pub async fn run(
|
|||||||
|
|
||||||
info!("Restoring user {} from backup...", metadata.username);
|
info!("Restoring user {} from backup...", metadata.username);
|
||||||
|
|
||||||
// 2. Resolve target user ID and upsert user record in users.db
|
// 2. Resolve identity per Correction #1:
|
||||||
let target_user_id = {
|
// 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 users_conn = db.users.lock().unwrap();
|
||||||
let existing_user =
|
let existing_user =
|
||||||
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
|
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
|
||||||
|
|
||||||
match existing_user {
|
match existing_user {
|
||||||
Some(u) => {
|
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(
|
users_conn.execute(
|
||||||
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
|
"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, u.id],
|
rusqlite::params![
|
||||||
|
metadata.password_hash,
|
||||||
|
metadata.status,
|
||||||
|
metadata.account_type,
|
||||||
|
metadata.metadata,
|
||||||
|
tenant_id.as_str(),
|
||||||
|
user_uuid,
|
||||||
|
u.id
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
users_conn.execute(
|
users_conn.execute(
|
||||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
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 => {
|
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
|
let id_taken: bool = users_conn
|
||||||
.query_row(
|
.query_row(
|
||||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||||
@@ -100,16 +154,35 @@ pub async fn run(
|
|||||||
|
|
||||||
let new_id = if !id_taken {
|
let new_id = if !id_taken {
|
||||||
users_conn.execute(
|
users_conn.execute(
|
||||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, 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);",
|
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],
|
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
|
metadata.id
|
||||||
} else {
|
} else {
|
||||||
users_conn.execute(
|
users_conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
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],
|
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()
|
users_conn.last_insert_rowid()
|
||||||
};
|
};
|
||||||
@@ -117,19 +190,23 @@ pub async fn run(
|
|||||||
users_conn.execute(
|
users_conn.execute(
|
||||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
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>/
|
// 3. Extract database files to /data/users/<TenantId>/
|
||||||
let dest_dir = config
|
let dest_dir = db.topology.tenant_dir(target_tenant_id);
|
||||||
.data_dir
|
|
||||||
.join("users")
|
|
||||||
.join(target_user_id.to_string());
|
|
||||||
std::fs::create_dir_all(&dest_dir)?;
|
std::fs::create_dir_all(&dest_dir)?;
|
||||||
|
std::fs::create_dir_all(dest_dir.join("extensions"))?;
|
||||||
|
|
||||||
let f2 = File::open(&file_path)?;
|
let f2 = File::open(&file_path)?;
|
||||||
let zst_dec2 = Decoder::new(f2)?;
|
let zst_dec2 = Decoder::new(f2)?;
|
||||||
@@ -156,27 +233,192 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Register slugs in global_slugs using the shared helper
|
// 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
|
||||||
{
|
let content_path = dest_dir.join("content.db");
|
||||||
let system_conn = db.system.lock().unwrap();
|
if content_path.exists() {
|
||||||
crate::db::users::register_restored_user_slugs(
|
let restored_content_conn = rusqlite::Connection::open(&content_path)?;
|
||||||
&system_conn,
|
let now = Utc::now().to_rfc3339();
|
||||||
target_user_id,
|
|
||||||
&dest_dir.join("content.db"),
|
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
|
// 5. Reconcile quotas for restored user
|
||||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
|
||||||
crate::db::users::reconcile_user_quotas(
|
crate::db::users::reconcile_user_quotas(
|
||||||
&db.users.lock().unwrap(),
|
&db.users.lock().unwrap(),
|
||||||
target_user_id,
|
target_user_id,
|
||||||
&restored_content_conn,
|
&restored_content_conn,
|
||||||
)?;
|
)?;
|
||||||
|
}
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
"User '{}' (ID: {}) successfully restored from backup.",
|
"User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
|
||||||
metadata.username, target_user_id
|
metadata.username, target_user_id, target_tenant_id
|
||||||
);
|
);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,30 @@ use std::path::PathBuf;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tracing::info;
|
use tracing::info;
|
||||||
|
|
||||||
|
async fn shutdown_signal() {
|
||||||
|
let ctrl_c = async {
|
||||||
|
tokio::signal::ctrl_c()
|
||||||
|
.await
|
||||||
|
.expect("failed to install Ctrl+C handler");
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
let terminate = async {
|
||||||
|
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||||
|
.expect("failed to install signal handler")
|
||||||
|
.recv()
|
||||||
|
.await;
|
||||||
|
};
|
||||||
|
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let terminate = std::future::pending::<()>();
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = ctrl_c => {},
|
||||||
|
_ = terminate => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
@@ -29,39 +53,63 @@ pub async fn run(
|
|||||||
// Init DBs
|
// Init DBs
|
||||||
let db = Db::init(&config)?;
|
let db = Db::init(&config)?;
|
||||||
|
|
||||||
|
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||||
|
let mut join_handles = Vec::new();
|
||||||
|
|
||||||
// Init Queue
|
// Init Queue
|
||||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
|
||||||
|
join_handles.push(("analytics_worker", analytics_handle));
|
||||||
|
|
||||||
// Spawn background tasks
|
// Spawn background tasks
|
||||||
let link_checker_db = db.clone();
|
let link_checker_db = db.clone();
|
||||||
let link_checker_interval = config.link_check_interval_mins;
|
let link_checker_interval = config.link_check_interval_mins;
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"link_checker",
|
||||||
tokio::spawn(async move {
|
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_db = db.clone();
|
||||||
let aggregator_interval = config.aggregation_interval_mins;
|
let aggregator_interval = config.aggregation_interval_mins;
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"aggregator",
|
||||||
tokio::spawn(async move {
|
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_db = db.clone();
|
||||||
let retention_days = config.data_retention_days;
|
let retention_days = config.data_retention_days;
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"retention_cleaner",
|
||||||
tokio::spawn(async move {
|
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
|
// Spawn optional backup scheduler
|
||||||
let backup_db = db.clone();
|
let backup_db = db.clone();
|
||||||
let backup_config = config.clone();
|
let backup_config = config.clone();
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"backup_scheduler",
|
||||||
tokio::spawn(async move {
|
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 expiry_db = db.clone();
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"expiry_checker",
|
||||||
tokio::spawn(async move {
|
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_db = db.clone();
|
||||||
let reconcile_interval_hours = {
|
let reconcile_interval_hours = {
|
||||||
@@ -75,14 +123,16 @@ pub async fn run(
|
|||||||
.and_then(|val| val.parse::<u64>().ok())
|
.and_then(|val| val.parse::<u64>().ok())
|
||||||
.unwrap_or(24)
|
.unwrap_or(24)
|
||||||
};
|
};
|
||||||
|
let rx = shutdown_rx.clone();
|
||||||
|
join_handles.push((
|
||||||
|
"quota_reconciliation",
|
||||||
tokio::spawn(async move {
|
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 {
|
let state = AppState {
|
||||||
admin_db: db.admin.clone(),
|
admin_db: db.admin.clone(),
|
||||||
content_db: db.content.clone(),
|
|
||||||
analytics_db: db.analytics.clone(),
|
|
||||||
system_db: db.system.clone(),
|
system_db: db.system.clone(),
|
||||||
users_db: db.users.clone(),
|
users_db: db.users.clone(),
|
||||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
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?;
|
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||||
|
|
||||||
info!("Listening for requests on http://{}", addr);
|
info!("Listening for requests on http://{}", addr);
|
||||||
axum::serve(listener, router).await?;
|
|
||||||
|
axum::serve(listener, router)
|
||||||
|
.with_graceful_shutdown(async move {
|
||||||
|
shutdown_signal().await;
|
||||||
|
info!("Shutdown signal received");
|
||||||
|
info!("Stopping HTTP server...");
|
||||||
|
let _ = shutdown_tx.send(true);
|
||||||
|
})
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
info!("Stopping background workers...");
|
||||||
|
|
||||||
|
let timeout_duration = std::time::Duration::from_secs(10);
|
||||||
|
let deadline = tokio::time::Instant::now() + timeout_duration;
|
||||||
|
|
||||||
|
for (name, handle) in join_handles {
|
||||||
|
match tokio::time::timeout_at(deadline, handle).await {
|
||||||
|
Ok(Ok(_)) => {}
|
||||||
|
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
|
||||||
|
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("Background workers stopped");
|
||||||
|
info!("BZOD shutdown complete");
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -18,6 +18,24 @@ pub async fn run(
|
|||||||
}
|
}
|
||||||
let db = Db::init(&config)?;
|
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
|
// 2. Validate/normalize slug/code
|
||||||
let code = match slug {
|
let code = match slug {
|
||||||
Some(s) => {
|
Some(s) => {
|
||||||
@@ -33,17 +51,24 @@ pub async fn run(
|
|||||||
None => crate::utils::random::generate_token(3),
|
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();
|
let urls_conn = db.global_urls.lock().unwrap();
|
||||||
if !crate::db::users::is_slug_available(&system_conn, &code)? {
|
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||||
return Err("Short code/slug already exists".into());
|
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
|
// 4. Persist URL in tenant content DB
|
||||||
let conn = db.content.lock().unwrap();
|
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(
|
let res = crate::db::content::create_url_extended(
|
||||||
&conn,
|
&conn,
|
||||||
&code,
|
&code,
|
||||||
@@ -58,18 +83,15 @@ pub async fn run(
|
|||||||
|
|
||||||
match res {
|
match res {
|
||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
// Activate slug in system.db
|
// Activate slug in global_urls
|
||||||
{
|
{
|
||||||
let system_conn = db.system.lock().unwrap();
|
let urls_conn = db.global_urls.lock().unwrap();
|
||||||
system_conn.execute(
|
crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
|
||||||
"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],
|
|
||||||
)?;
|
|
||||||
}
|
}
|
||||||
// Increment quota for user ID 1
|
// Increment quota
|
||||||
{
|
{
|
||||||
let users_conn = db.users.lock().unwrap();
|
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 {
|
let proto = if config.cookie_secure {
|
||||||
@@ -87,8 +109,8 @@ pub async fn run(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let system_conn = db.system.lock().unwrap();
|
let urls_conn = db.global_urls.lock().unwrap();
|
||||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
|
||||||
Err(e.into())
|
Err(e.into())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,16 +15,24 @@ pub async fn run(
|
|||||||
println!("Storage Directory: {:?}", config.data_dir);
|
println!("Storage Directory: {:?}", config.data_dir);
|
||||||
|
|
||||||
let files = vec![
|
let files = vec![
|
||||||
("admin.db", config.data_dir.join("admin/admin.db")),
|
("admin.db", db.topology.admin_db()),
|
||||||
("system.db", config.data_dir.join("admin/system.db")),
|
("system.db", db.topology.system_db()),
|
||||||
("users.db", config.data_dir.join("admin/users.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",
|
"legacy content.db",
|
||||||
config.data_dir.join("users/1/content.db"),
|
db.topology
|
||||||
|
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"legacy analytics.db",
|
"legacy analytics.db",
|
||||||
config.data_dir.join("users/1/analytics.db"),
|
db.topology
|
||||||
|
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -47,8 +55,29 @@ pub async fn run(
|
|||||||
println!("Users Count: {}", users_count);
|
println!("Users Count: {}", users_count);
|
||||||
|
|
||||||
let (urls_total, urls_active, urls_dead) = {
|
let (urls_total, urls_active, urls_dead) = {
|
||||||
let conn = db.content.lock().unwrap();
|
let conn = db.global_urls.lock().unwrap();
|
||||||
crate::db::content::get_url_counts(&conn)?
|
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!(
|
println!(
|
||||||
"Shortened URLs: {} total ({} active / {} dead)",
|
"Shortened URLs: {} total ({} active / {} dead)",
|
||||||
@@ -56,14 +85,19 @@ pub async fn run(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let pages_count = {
|
let pages_count = {
|
||||||
let conn = db.content.lock().unwrap();
|
let conn = db.global_landing_pages.lock().unwrap();
|
||||||
crate::db::content::get_landing_page_count(&conn)?
|
conn.query_row(
|
||||||
|
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||||
|
[],
|
||||||
|
|r| r.get(0),
|
||||||
|
)
|
||||||
|
.unwrap_or(0)
|
||||||
};
|
};
|
||||||
println!("Landing Pages: {}", pages_count);
|
println!("Landing Pages: {}", pages_count);
|
||||||
|
|
||||||
let total_visits = {
|
let total_visits = {
|
||||||
let conn = db.analytics.lock().unwrap();
|
let users_conn = db.users.lock().unwrap();
|
||||||
crate::db::analytics::get_total_clicks(&conn)?
|
crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
|
||||||
};
|
};
|
||||||
println!("Redirect Clicks: {}", total_visits);
|
println!("Redirect Clicks: {}", total_visits);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,45 @@ use std::env;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
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)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct Config {
|
pub struct Config {
|
||||||
pub host: String,
|
pub host: String,
|
||||||
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn load() -> Self {
|
pub fn load() -> Result<Self, ConfigError> {
|
||||||
// 1. Built-in defaults
|
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 host = "0.0.0.0".to_string();
|
||||||
let mut port = 8080u16;
|
let mut port = 8080u16;
|
||||||
let mut data_dir = PathBuf::from("./data");
|
|
||||||
let mut admin_username = "admin".to_string();
|
let mut admin_username = "admin".to_string();
|
||||||
let mut bootstrap_password_sha256 = "".to_string();
|
let mut bootstrap_password_sha256 = "".to_string();
|
||||||
let mut session_secret =
|
let mut session_secret =
|
||||||
@@ -63,12 +114,18 @@ impl Config {
|
|||||||
let mut backup_dir = PathBuf::from("./backups");
|
let mut backup_dir = PathBuf::from("./backups");
|
||||||
let mut base_url = None;
|
let mut base_url = None;
|
||||||
|
|
||||||
// 2. Load bzod.toml if it exists
|
let mut resolved_data_dir: Option<PathBuf> = None;
|
||||||
let mut toml_path = "bzod.toml".to_string();
|
let mut data_dir_source: Option<DataDirSource> = None;
|
||||||
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
|
|
||||||
toml_path = "config/bzod.toml".to_string();
|
// 2. Load bzod.toml / config.toml if it exists
|
||||||
}
|
let possible_tomls = [
|
||||||
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
|
"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 Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
|
||||||
if let Some(h) = toml_config.host {
|
if let Some(h) = toml_config.host {
|
||||||
host = h;
|
host = h;
|
||||||
@@ -77,7 +134,10 @@ impl Config {
|
|||||||
port = p;
|
port = p;
|
||||||
}
|
}
|
||||||
if let Some(d) = toml_config.data_dir {
|
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 {
|
if let Some(u) = toml_config.admin_username {
|
||||||
admin_username = u;
|
admin_username = u;
|
||||||
@@ -118,16 +178,20 @@ impl Config {
|
|||||||
if let Some(bu) = toml_config.base_url {
|
if let Some(bu) = toml_config.base_url {
|
||||||
base_url = Some(bu);
|
base_url = Some(bu);
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load .env if present
|
// 3. Load .env if present
|
||||||
|
if load_dotenv {
|
||||||
let _ = dotenvy::dotenv();
|
let _ = dotenvy::dotenv();
|
||||||
if fs::metadata("config/.env").is_ok() {
|
if fs::metadata("config/.env").is_ok() {
|
||||||
let _ = dotenvy::from_path("config/.env");
|
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") {
|
if let Ok(h) = env::var("HOST") {
|
||||||
host = h;
|
host = h;
|
||||||
}
|
}
|
||||||
@@ -136,8 +200,11 @@ impl Config {
|
|||||||
port = p;
|
port = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Ok(d_str) = env::var("DATA_DIR") {
|
if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
|
||||||
data_dir = PathBuf::from(d_str);
|
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") {
|
if let Ok(u) = env::var("ADMIN_USERNAME") {
|
||||||
admin_username = u;
|
admin_username = u;
|
||||||
@@ -187,7 +254,26 @@ impl Config {
|
|||||||
base_url = Some(bu);
|
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,
|
host,
|
||||||
port,
|
port,
|
||||||
data_dir,
|
data_dir,
|
||||||
@@ -202,6 +288,6 @@ impl Config {
|
|||||||
backup_interval_mins,
|
backup_interval_mins,
|
||||||
backup_dir,
|
backup_dir,
|
||||||
base_url,
|
base_url,
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
|
|||||||
accept_language: row.get("accept_language")?,
|
accept_language: row.get("accept_language")?,
|
||||||
country: row.get("country")?,
|
country: row.get("country")?,
|
||||||
status_code: row.get("status_code")?,
|
status_code: row.get("status_code")?,
|
||||||
|
owner_tenant_id: None,
|
||||||
owner_user_id: row.get("owner_user_id")?,
|
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")?,
|
accept_language: row.get("accept_language")?,
|
||||||
country: row.get("country")?,
|
country: row.get("country")?,
|
||||||
status_code: row.get("status_code")?,
|
status_code: row.get("status_code")?,
|
||||||
|
owner_tenant_id: None,
|
||||||
owner_user_id: row.get("owner_user_id")?,
|
owner_user_id: row.get("owner_user_id")?,
|
||||||
})
|
})
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
@@ -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,7 +35,21 @@ pub fn run_migrations(
|
|||||||
);
|
);
|
||||||
|
|
||||||
let tx = conn.transaction()?;
|
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()?;
|
tx.commit()?;
|
||||||
|
|
||||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
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';
|
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;
|
||||||
|
"#,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@@ -7,46 +7,67 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
|||||||
use rusqlite::Connection;
|
use rusqlite::Connection;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
pub mod admin;
|
pub mod admin;
|
||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod audit_events;
|
pub mod audit_events;
|
||||||
pub mod content;
|
pub mod content;
|
||||||
|
pub mod identity_migrate;
|
||||||
pub mod migrations;
|
pub mod migrations;
|
||||||
pub mod preview;
|
pub mod preview;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
|
pub mod schema_v08;
|
||||||
|
pub mod slug_migrate;
|
||||||
|
pub mod slugs;
|
||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
|
pub mod tenant;
|
||||||
|
pub mod topology;
|
||||||
pub mod users;
|
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)]
|
#[derive(Clone)]
|
||||||
pub struct Db {
|
pub struct Db {
|
||||||
pub admin: Arc<Mutex<Connection>>,
|
pub admin: Arc<Mutex<Connection>>,
|
||||||
pub content: Arc<Mutex<Connection>>,
|
|
||||||
pub analytics: Arc<Mutex<Connection>>,
|
|
||||||
pub system: Arc<Mutex<Connection>>,
|
pub system: Arc<Mutex<Connection>>,
|
||||||
pub users: 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 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 {
|
impl Db {
|
||||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use tracing::info;
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
let topology = Topology::new(&config.data_dir);
|
||||||
if !config.data_dir.exists() {
|
|
||||||
fs::create_dir_all(&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 admin_dir = topology.admin_dir();
|
||||||
let users_dir = config.data_dir.join("users");
|
|
||||||
fs::create_dir_all(&admin_dir)?;
|
|
||||||
fs::create_dir_all(&users_dir)?;
|
|
||||||
|
|
||||||
// Automated Legacy Migration: check if legacy files are at the root
|
// Automated Legacy Migration: check if legacy files are at the root
|
||||||
let legacy_admin_db = config.data_dir.join("admin.db");
|
let legacy_admin_db = topology.legacy_flat_admin_db();
|
||||||
let legacy_content_db = config.data_dir.join("content.db");
|
|
||||||
let legacy_analytics_db = config.data_dir.join("analytics.db");
|
|
||||||
|
|
||||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||||
if legacy_admin_db.exists() {
|
if legacy_admin_db.exists() {
|
||||||
@@ -60,7 +81,7 @@ impl Db {
|
|||||||
"system.db-shm",
|
"system.db-shm",
|
||||||
];
|
];
|
||||||
for f in files {
|
for f in files {
|
||||||
let src = config.data_dir.join(f);
|
let src = topology.root().join(f);
|
||||||
if src.exists() {
|
if src.exists() {
|
||||||
let dst = admin_dir.join(f);
|
let dst = admin_dir.join(f);
|
||||||
let _ = fs::rename(&src, &dst);
|
let _ = fs::rename(&src, &dst);
|
||||||
@@ -88,24 +109,9 @@ impl Db {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let admin_path = admin_dir.join("admin.db");
|
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
|
||||||
let system_path = admin_dir.join("system.db");
|
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
|
||||||
let users_db_path = admin_dir.join("users.db");
|
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
|
||||||
|
|
||||||
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")?;
|
|
||||||
|
|
||||||
// Run migrations for system.db first
|
// Run migrations for system.db first
|
||||||
info!("Running system migrations");
|
info!("Running system migrations");
|
||||||
@@ -168,182 +174,54 @@ impl Db {
|
|||||||
let now = Utc::now().to_rfc3339();
|
let now = Utc::now().to_rfc3339();
|
||||||
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
|
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 mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
|
||||||
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
|
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() {
|
|
||||||
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
|
|
||||||
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
|
|
||||||
for f in content_files {
|
|
||||||
let src = config.data_dir.join(f);
|
|
||||||
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(
|
run_migrations(
|
||||||
&mut content_conn,
|
&mut global_urls_conn,
|
||||||
"content",
|
"global_urls",
|
||||||
CONTENT_MIGRATIONS,
|
GLOBAL_URLS_MIGRATIONS,
|
||||||
Some(&system_arc),
|
Some(&system_arc),
|
||||||
)?;
|
)?;
|
||||||
run_migrations(
|
run_migrations(
|
||||||
&mut analytics_conn,
|
&mut global_landing_pages_conn,
|
||||||
"analytics",
|
"global_landing_pages",
|
||||||
ANALYTICS_MIGRATIONS,
|
GLOBAL_LANDING_PAGES_MIGRATIONS,
|
||||||
Some(&system_arc),
|
Some(&system_arc),
|
||||||
)?;
|
)?;
|
||||||
|
run_migrations(
|
||||||
// If we just migrated legacy content, populate the global_slugs table in system.db
|
&mut reserved_conn,
|
||||||
if legacy_migration_needed {
|
"reserved",
|
||||||
info!("Populating global slug index with legacy content...");
|
RESERVED_SLUGS_MIGRATIONS,
|
||||||
let mut sys_lock = system_arc.lock().unwrap();
|
Some(&system_arc),
|
||||||
let tx = sys_lock.transaction()?;
|
)?;
|
||||||
|
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
|
||||||
// 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.");
|
|
||||||
}
|
|
||||||
|
|
||||||
let db = Self {
|
let db = Self {
|
||||||
admin: Arc::new(Mutex::new(admin_conn)),
|
admin: Arc::new(Mutex::new(admin_conn)),
|
||||||
content: Arc::new(Mutex::new(content_conn)),
|
|
||||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
|
||||||
system: system_arc,
|
system: system_arc,
|
||||||
users: Arc::new(Mutex::new(users_conn)),
|
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(),
|
data_dir: config.data_dir.clone(),
|
||||||
|
topology,
|
||||||
};
|
};
|
||||||
|
|
||||||
let _ = db.reconcile_global_slugs(config);
|
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
|
||||||
|
|
||||||
// Post-init: Clean up stale reservations
|
|
||||||
{
|
{
|
||||||
let system_conn = db.system.lock().unwrap();
|
if let (Ok(urls_conn), Ok(pages_conn)) =
|
||||||
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
|
(db.global_urls.lock(), db.global_landing_pages.lock())
|
||||||
|
{
|
||||||
|
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
|
||||||
Ok(count) => {
|
Ok(count) => {
|
||||||
if count > 0 {
|
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) => {
|
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)
|
Ok(db)
|
||||||
@@ -382,24 +238,33 @@ impl Db {
|
|||||||
let admin = self.admin.lock().unwrap();
|
let admin = self.admin.lock().unwrap();
|
||||||
let _ = admin.execute("VACUUM;", []);
|
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 = self.system.lock().unwrap();
|
||||||
let _ = system.execute("VACUUM;", []);
|
let _ = system.execute("VACUUM;", []);
|
||||||
|
|
||||||
let users = self.users.lock().unwrap();
|
let users = self.users.lock().unwrap();
|
||||||
let _ = users.execute("VACUUM;", []);
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
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());
|
let user = {
|
||||||
fs::create_dir_all(&user_dir)?;
|
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 content_path = user_dir.join("content.db");
|
||||||
let analytics_path = user_dir.join("analytics.db");
|
let analytics_path = user_dir.join("analytics.db");
|
||||||
@@ -442,7 +307,7 @@ impl Db {
|
|||||||
|
|
||||||
pub fn reconcile_global_slugs(
|
pub fn reconcile_global_slugs(
|
||||||
&self,
|
&self,
|
||||||
config: &Config,
|
_config: &Config,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
|
||||||
@@ -460,8 +325,10 @@ impl Db {
|
|||||||
drop(stmt);
|
drop(stmt);
|
||||||
|
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
let content_path = match self.topology.content_db_i64(user_id) {
|
||||||
let content_path = user_dir.join("content.db");
|
Ok(p) => p,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
|
||||||
if content_path.exists() {
|
if content_path.exists() {
|
||||||
let content_conn = Connection::open(&content_path)?;
|
let content_conn = Connection::open(&content_path)?;
|
||||||
@@ -521,16 +388,14 @@ impl Db {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod db_init_tests {
|
mod db_init_tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_db_init() {
|
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() {
|
if temp_dir.exists() {
|
||||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||||
}
|
}
|
||||||
let mut config = Config::load();
|
let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
|
||||||
config.data_dir = temp_dir.clone();
|
|
||||||
let db = Db::init(&config);
|
let db = Db::init(&config);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,70 @@
|
|||||||
|
use crate::identity::TenantId;
|
||||||
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
|
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use rusqlite::{params, Connection, OptionalExtension};
|
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 ---
|
// --- User Operations ---
|
||||||
|
|
||||||
pub fn is_reserved_username(username: &str) -> bool {
|
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 created_at = Utc::now().to_rfc3339();
|
||||||
let status = "active";
|
let status = "active";
|
||||||
let account_type = "admin";
|
let account_type = "admin";
|
||||||
|
let user_uuid = allocate_unique_uuid(conn)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
|
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
|
||||||
params![username, password_hash, status, created_at, account_type],
|
params![
|
||||||
|
username,
|
||||||
|
password_hash,
|
||||||
|
status,
|
||||||
|
created_at,
|
||||||
|
account_type,
|
||||||
|
user_uuid,
|
||||||
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
let id = conn.last_insert_rowid();
|
let id = conn.last_insert_rowid();
|
||||||
@@ -39,6 +110,8 @@ pub fn create_admin_user(
|
|||||||
account_type: account_type.to_string(),
|
account_type: account_type.to_string(),
|
||||||
organization_id: None,
|
organization_id: None,
|
||||||
metadata: 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 created_at = Utc::now().to_rfc3339();
|
||||||
let status = "active";
|
let status = "active";
|
||||||
|
let tenant_id = allocate_unique_tenant_id(conn)?;
|
||||||
|
let user_uuid = allocate_unique_uuid(conn)?;
|
||||||
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||||
params![
|
params![
|
||||||
username,
|
username,
|
||||||
password_hash,
|
password_hash,
|
||||||
status,
|
status,
|
||||||
created_at,
|
created_at,
|
||||||
account_type,
|
account_type,
|
||||||
metadata
|
metadata,
|
||||||
|
tenant_id.as_str(),
|
||||||
|
user_uuid,
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
@@ -87,27 +164,37 @@ pub fn create_user(
|
|||||||
account_type: account_type.to_string(),
|
account_type: account_type.to_string(),
|
||||||
organization_id: None,
|
organization_id: None,
|
||||||
metadata: metadata.map(|s| s.to_string()),
|
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>> {
|
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
|
||||||
FROM users WHERE id = ?1;",
|
|
||||||
params![id],
|
params![id],
|
||||||
|row| {
|
map_user,
|
||||||
Ok(TenantUser {
|
)
|
||||||
id: row.get(0)?,
|
.optional()
|
||||||
username: row.get(1)?,
|
}
|
||||||
password_hash: row.get(2)?,
|
|
||||||
status: row.get(3)?,
|
pub fn get_user_by_tenant_id(
|
||||||
created_at: row.get(4)?,
|
conn: &Connection,
|
||||||
last_login: row.get(5)?,
|
tenant_id: TenantId,
|
||||||
account_type: row.get(6)?,
|
) -> rusqlite::Result<Option<TenantUser>> {
|
||||||
organization_id: row.get(7)?,
|
conn.query_row(
|
||||||
metadata: row.get(8)?,
|
&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()
|
.optional()
|
||||||
}
|
}
|
||||||
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
|
|||||||
username: &str,
|
username: &str,
|
||||||
) -> rusqlite::Result<Option<TenantUser>> {
|
) -> rusqlite::Result<Option<TenantUser>> {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
|
||||||
FROM users WHERE username = ?1;",
|
|
||||||
params![username],
|
params![username],
|
||||||
|row| {
|
map_user,
|
||||||
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()
|
.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>> {
|
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
|
||||||
FROM users ORDER BY username ASC;",
|
))?;
|
||||||
)?;
|
let rows = stmt.query_map([], map_user)?;
|
||||||
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 users = Vec::new();
|
let mut users = Vec::new();
|
||||||
for u in rows {
|
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 ---
|
// --- 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> {
|
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||||
// 1. Check reserved list
|
// 1. Check reserved list
|
||||||
let reserved: bool = system_conn
|
let reserved: bool = system_conn
|
||||||
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
|
|||||||
Ok(!exists)
|
Ok(!exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated(
|
||||||
|
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
|
||||||
|
)]
|
||||||
pub fn register_global_slug(
|
pub fn register_global_slug(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -490,6 +557,9 @@ pub fn register_global_slug(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated(
|
||||||
|
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
|
||||||
|
)]
|
||||||
pub fn release_global_slug(
|
pub fn release_global_slug(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -508,6 +578,7 @@ pub fn release_global_slug(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
|
||||||
pub fn soft_delete_global_slug(
|
pub fn soft_delete_global_slug(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
|
|||||||
let mut invalid_entries = Vec::new();
|
let mut invalid_entries = Vec::new();
|
||||||
let warnings = 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
|
// 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 legacy_content_path.exists() {
|
||||||
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
||||||
// URLs
|
// URLs
|
||||||
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
|
|||||||
if let Ok(mut rows) = stmt.query([]) {
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
while let Some(row) = rows.next().unwrap_or(None) {
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
if let Ok(code) = row.get::<_, String>(0) {
|
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([]) {
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
while let Some(row) = rows.next().unwrap_or(None) {
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
if let Ok(code) = row.get::<_, String>(0) {
|
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
|
// 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() {
|
if users_dir.exists() {
|
||||||
for entry in std::fs::read_dir(users_dir)? {
|
for entry in std::fs::read_dir(users_dir)? {
|
||||||
let entry = entry?;
|
let entry = entry?;
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.is_dir() {
|
if path.is_dir() {
|
||||||
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
|
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");
|
let content_db_path = path.join("content.db");
|
||||||
if content_db_path.exists() {
|
if content_db_path.exists() {
|
||||||
if let Ok(conn) = Connection::open(&content_db_path) {
|
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([]) {
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
while let Some(row) = rows.next().unwrap_or(None) {
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
if let Ok(code) = row.get::<_, String>(0) {
|
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([]) {
|
if let Ok(mut rows) = stmt.query([]) {
|
||||||
while let Some(row) = rows.next().unwrap_or(None) {
|
while let Some(row) = rows.next().unwrap_or(None) {
|
||||||
if let Ok(code) = row.get::<_, String>(0) {
|
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(
|
pub fn cleanup_stale_reservations(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
data_dir: &std::path::Path,
|
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));
|
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||||
if age > chrono::Duration::minutes(15) {
|
if age > chrono::Duration::minutes(15) {
|
||||||
// Check if target record exists by looking up code = slug in owner's content.db
|
// 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 topology = crate::db::topology::Topology::new(data_dir);
|
||||||
let p1 = data_dir.join("users").join("1").join("content.db");
|
let content_db_path = {
|
||||||
if p1.exists() {
|
let users_path = topology.users_registry_db();
|
||||||
p1
|
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 {
|
} 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 {
|
} else {
|
||||||
data_dir
|
topology
|
||||||
.join("users")
|
.content_db_i64(owner_user_id)
|
||||||
.join(owner_user_id.to_string())
|
.unwrap_or_else(|_| topology.legacy_flat_content_db())
|
||||||
.join("content.db")
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut target_exists = false;
|
let mut target_exists = false;
|
||||||
@@ -722,216 +816,9 @@ pub fn cleanup_stale_reservations(
|
|||||||
Ok(cleaned_count)
|
Ok(cleaned_count)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn verify_global_slug_registry_integrity(
|
#[deprecated(
|
||||||
system_conn: &Connection,
|
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
|
||||||
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 {
|
|
||||||
data_dir.join("users").join("1").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
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Admin Content Reverse Consistency Check (Legacy DB)
|
|
||||||
let admin_content_db_path = data_dir.join("users").join("1").join("content.db");
|
|
||||||
|
|
||||||
if admin_content_db_path.exists() {
|
|
||||||
if let Ok(admin_content_conn) = Connection::open(&admin_content_db_path) {
|
|
||||||
// Check URLs
|
|
||||||
if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM urls;") {
|
|
||||||
if let Ok(mut rows) = stmt.query([]) {
|
|
||||||
while let Ok(Some(row)) = rows.next() {
|
|
||||||
let code: String = row.get(0).unwrap_or_default();
|
|
||||||
let id: String = row.get(1).unwrap_or_default();
|
|
||||||
let exists: bool = system_conn.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);",
|
|
||||||
rusqlite::params![code, id],
|
|
||||||
|r| r.get(0)
|
|
||||||
).unwrap_or(false);
|
|
||||||
if !exists {
|
|
||||||
warnings.push(format!("Orphaned admin URL detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check Landing Pages
|
|
||||||
if let Ok(mut stmt) = admin_content_conn.prepare("SELECT code, id FROM landing_pages;")
|
|
||||||
{
|
|
||||||
if let Ok(mut rows) = stmt.query([]) {
|
|
||||||
while let Ok(Some(row)) = rows.next() {
|
|
||||||
let code: String = row.get(0).unwrap_or_default();
|
|
||||||
let id: String = row.get(1).unwrap_or_default();
|
|
||||||
let exists: bool = system_conn.query_row(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM global_slugs WHERE slug = ?1 AND owner_user_id = 1 AND target_id = ?2);",
|
|
||||||
rusqlite::params![code, id],
|
|
||||||
|r| r.get(0)
|
|
||||||
).unwrap_or(false);
|
|
||||||
if !exists {
|
|
||||||
warnings.push(format!("Orphaned admin Landing Page detected in legacy content DB: code='{}', id='{}' is missing from global_slugs", code, id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Check for admin content in non-legacy tenant DBs
|
|
||||||
if let Ok(mut stmt) = users_conn
|
|
||||||
.prepare("SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;")
|
|
||||||
{
|
|
||||||
if let Ok(mut rows) = stmt.query([]) {
|
|
||||||
while let Ok(Some(row)) = rows.next() {
|
|
||||||
let id: i64 = row.get(0).unwrap_or(0);
|
|
||||||
let username: String = row.get(1).unwrap_or_default();
|
|
||||||
let tenant_db_path = data_dir
|
|
||||||
.join("users")
|
|
||||||
.join(id.to_string())
|
|
||||||
.join("content.db");
|
|
||||||
if tenant_db_path.exists() {
|
|
||||||
if let Ok(conn) = Connection::open(&tenant_db_path) {
|
|
||||||
let url_count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
|
||||||
.unwrap_or(0);
|
|
||||||
let page_count: i64 = conn
|
|
||||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
|
||||||
.unwrap_or(0);
|
|
||||||
if url_count > 0 || page_count > 0 {
|
|
||||||
warnings.push(format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok((errors, warnings))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn register_restored_user_slugs(
|
pub fn register_restored_user_slugs(
|
||||||
system_conn: &Connection,
|
system_conn: &Connection,
|
||||||
target_user_id: i64,
|
target_user_id: i64,
|
||||||
@@ -1077,3 +964,29 @@ pub fn reconcile_user_quotas(
|
|||||||
|
|
||||||
Ok(())
|
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)
|
||||||
|
}
|
||||||
@@ -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')));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
|
|||||||
use crate::analytics::aggregate_day;
|
use crate::analytics::aggregate_day;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
pub async fn run_aggregator(
|
||||||
|
db: Db,
|
||||||
|
interval_mins: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Analytics aggregator shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background analytics aggregator...");
|
info!("Running background analytics aggregator...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ use crate::db::Db;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
pub async fn run_backup_scheduler(
|
||||||
|
db: Db,
|
||||||
|
config: Config,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
if !config.backup_enabled {
|
if !config.backup_enabled {
|
||||||
info!("Background backup scheduler is disabled.");
|
info!("Background backup scheduler is disabled.");
|
||||||
return;
|
return;
|
||||||
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
|||||||
);
|
);
|
||||||
loop {
|
loop {
|
||||||
// Run backup every configured interval
|
// Run backup every configured interval
|
||||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Backup scheduler shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background database backup...");
|
info!("Running background database backup...");
|
||||||
|
|
||||||
let job_id = log_job_start(&db.system, "database_backup");
|
let job_id = log_job_start(&db.system, "database_backup");
|
||||||
@@ -55,20 +65,32 @@ pub async fn perform_backup(
|
|||||||
if let Ok(conn) = db.admin.lock() {
|
if let Ok(conn) = db.admin.lock() {
|
||||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
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() {
|
if let Ok(conn) = db.system.lock() {
|
||||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
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);", []);
|
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||||
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
|
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
|
||||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
|
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 {
|
for user_id in user_ids {
|
||||||
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
|
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
|
||||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
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);", []);
|
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();
|
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 enc = GzEncoder::new(file, Compression::default());
|
||||||
let mut tar = Builder::new(enc);
|
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() {
|
if admin_dir.exists() {
|
||||||
tar.append_dir_all("admin", &admin_dir)?;
|
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() {
|
if users_dir.exists() {
|
||||||
tar.append_dir_all("users", &users_dir)?;
|
tar.append_dir_all("users", &users_dir)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,73 @@
|
|||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::info;
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
/// Background job that marks expired URLs.
|
/// Background job that marks expired URLs.
|
||||||
///
|
///
|
||||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||||
/// gets flipped to `expired = 1`.
|
/// gets flipped to `expired = 1`.
|
||||||
pub async fn run_expiry_checker(db: Db) {
|
///
|
||||||
|
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
|
||||||
|
/// authoritative and returns 410 without depending on this sweeper. The sweeper
|
||||||
|
/// is maintenance (persist `expired=1`) and must remain idempotent.
|
||||||
|
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Expiry checker shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
let conn = db.users.lock().unwrap();
|
let conn = match db.users.lock() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: users_db mutex poisoned");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(_) => continue,
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: failed to list users");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||||
Ok(r) => r,
|
Ok(r) => r,
|
||||||
Err(_) => continue,
|
Err(e) => {
|
||||||
|
error!(error = %e, "expiry job: failed to map user ids");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
rows.filter_map(|r| r.ok()).collect()
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut total_expired = 0;
|
let mut total_expired = 0;
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
|
match super::open_user_content_conn(&db, user_id) {
|
||||||
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
|
Ok(conn) => match crate::db::content::expire_urls(&conn) {
|
||||||
total_expired += count;
|
Ok(count) => total_expired += count,
|
||||||
|
Err(e) => {
|
||||||
|
warn!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"expiry job: expire_urls failed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(e) => {
|
||||||
|
// Missing content.db for a user is common; only log open errors that are unexpected.
|
||||||
|
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
|
||||||
|
warn!(
|
||||||
|
owner_user_id = user_id,
|
||||||
|
error = %e,
|
||||||
|
"expiry job: could not open content.db"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ use uuid::Uuid;
|
|||||||
use super::{log_job_end, log_job_start};
|
use super::{log_job_end, log_job_start};
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
pub async fn run_link_checker(
|
||||||
|
db: Db,
|
||||||
|
interval_mins: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let client = Client::builder()
|
let client = Client::builder()
|
||||||
.timeout(Duration::from_secs(10))
|
.timeout(Duration::from_secs(10))
|
||||||
.user_agent("bzod-link-checker/0.1")
|
.user_agent("bzod-link-checker/0.1")
|
||||||
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Sleep first to give server time to start up
|
// Sleep first to give server time to start up
|
||||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Link checker shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
info!("Running background link health check...");
|
info!("Running background link health check...");
|
||||||
|
|
||||||
let job_id = log_job_start(&db.system, "link_checker");
|
let job_id = log_job_start(&db.system, "link_checker");
|
||||||
|
|||||||
@@ -46,11 +46,12 @@ pub fn open_user_content_conn(
|
|||||||
db: &Db,
|
db: &Db,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||||
let db_path = db
|
let db_path = {
|
||||||
.data_dir
|
let users = db.users.lock().map_err(|_| {
|
||||||
.join("users")
|
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||||
.join(user_id.to_string())
|
})?;
|
||||||
.join("content.db");
|
crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
|
||||||
|
};
|
||||||
let conn = rusqlite::Connection::open(db_path)?;
|
let conn = rusqlite::Connection::open(db_path)?;
|
||||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||||
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
|
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
|
||||||
@@ -61,11 +62,12 @@ pub fn open_user_analytics_conn(
|
|||||||
db: &Db,
|
db: &Db,
|
||||||
user_id: i64,
|
user_id: i64,
|
||||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||||
let db_path = db
|
let db_path = {
|
||||||
.data_dir
|
let users = db.users.lock().map_err(|_| {
|
||||||
.join("users")
|
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||||
.join(user_id.to_string())
|
})?;
|
||||||
.join("analytics.db");
|
crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
|
||||||
|
};
|
||||||
let conn = rusqlite::Connection::open(db_path)?;
|
let conn = rusqlite::Connection::open(db_path)?;
|
||||||
crate::db::sqlite::enable_wal(&conn, "analytics")?;
|
crate::db::sqlite::enable_wal(&conn, "analytics")?;
|
||||||
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
|
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
|
||||||
|
|||||||
@@ -2,10 +2,20 @@ use crate::db::Db;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info};
|
||||||
|
|
||||||
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
pub async fn run_quota_reconciliation(
|
||||||
|
db: Db,
|
||||||
|
interval_hours: u64,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
loop {
|
loop {
|
||||||
// Sleep first
|
// Sleep first
|
||||||
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Quota reconciliation shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background quota reconciliation...");
|
info!("Running background quota reconciliation...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
@@ -27,9 +37,9 @@ pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
|||||||
rows.filter_map(|r| r.ok()).collect()
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let users_conn = db.users.lock().unwrap();
|
|
||||||
for user_id in user_ids {
|
for user_id in user_ids {
|
||||||
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
|
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
|
||||||
|
let users_conn = db.users.lock().unwrap();
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
|
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ use tracing::{error, info};
|
|||||||
use super::{log_job_end, log_job_start};
|
use super::{log_job_end, log_job_start};
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
|
|
||||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
pub async fn run_retention_cleaner(
|
||||||
|
db: Db,
|
||||||
|
retention_days_opt: Option<i64>,
|
||||||
|
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||||
|
) {
|
||||||
let retention_days = match retention_days_opt {
|
let retention_days = match retention_days_opt {
|
||||||
Some(days) => days,
|
Some(days) => days,
|
||||||
None => return,
|
None => return,
|
||||||
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
|||||||
|
|
||||||
loop {
|
loop {
|
||||||
// Check once every 24 hours
|
// Check once every 24 hours
|
||||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
|
||||||
|
_ = shutdown_rx.changed() => {
|
||||||
|
info!("Retention cleaner shutting down...");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
info!("Running background data retention cleanup...");
|
info!("Running background data retention cleanup...");
|
||||||
|
|
||||||
let user_ids: Vec<i64> = {
|
let user_ids: Vec<i64> = {
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
pub mod analytics;
|
pub mod analytics;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
|
pub mod build_info;
|
||||||
pub mod charts;
|
pub mod charts;
|
||||||
pub mod cli;
|
pub mod cli;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod db;
|
pub mod db;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
|
pub mod identity;
|
||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod services;
|
pub mod services;
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.init();
|
.init();
|
||||||
|
|
||||||
let cli = Cli::parse();
|
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 {
|
match cli.command {
|
||||||
Commands::Serve {
|
Commands::Serve {
|
||||||
@@ -38,8 +44,14 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
Commands::Validate { data_dir } => {
|
Commands::Validate { data_dir } => {
|
||||||
bzod::cli::validate::run(data_dir, config).await?;
|
bzod::cli::validate::run(data_dir, config).await?;
|
||||||
}
|
}
|
||||||
|
Commands::AuditDestinations { data_dir } => {
|
||||||
|
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||||
|
}
|
||||||
Commands::CreateAdmin { username, data_dir } => {
|
Commands::CreateAdmin { username, data_dir } => {
|
||||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
bzod::cli::create_admin::run(username, None, data_dir, config).await?;
|
||||||
|
}
|
||||||
|
Commands::InitAdmin { data_dir } => {
|
||||||
|
bzod::cli::init_admin::run(data_dir, config).await?;
|
||||||
}
|
}
|
||||||
Commands::Doctor { data_dir } => {
|
Commands::Doctor { data_dir } => {
|
||||||
bzod::cli::doctor::run(data_dir, config).await?;
|
bzod::cli::doctor::run(data_dir, config).await?;
|
||||||
@@ -103,6 +115,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
|
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
|
||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
|
Commands::Repair { command } => {
|
||||||
|
bzod::cli::repair::run(command, config).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -27,6 +27,24 @@ pub struct TenantUser {
|
|||||||
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
|
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
|
||||||
pub organization_id: Option<i64>,
|
pub organization_id: Option<i64>,
|
||||||
pub metadata: Option<String>,
|
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)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::identity::TenantId;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
@@ -12,6 +13,9 @@ pub struct VisitRecord {
|
|||||||
pub accept_language: String,
|
pub accept_language: String,
|
||||||
pub country: String,
|
pub country: String,
|
||||||
pub status_code: u16,
|
pub status_code: u16,
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_tenant_id: Option<TenantId>,
|
||||||
|
#[serde(default)]
|
||||||
pub owner_user_id: Option<i64>,
|
pub owner_user_id: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}")))
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
use crate::db::Db;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::LandingPage;
|
use crate::models::LandingPage;
|
||||||
|
|
||||||
pub fn create_landing_page(
|
pub fn create_landing_page(
|
||||||
db: &Db,
|
conn: &rusqlite::Connection,
|
||||||
code: &str,
|
code: &str,
|
||||||
slug: &str,
|
slug: &str,
|
||||||
title: &str,
|
title: &str,
|
||||||
html_content: &str,
|
html_content: &str,
|
||||||
state: &str,
|
state: &str,
|
||||||
) -> Result<LandingPage, AppError> {
|
) -> Result<LandingPage, AppError> {
|
||||||
let conn = db.content.lock().unwrap();
|
|
||||||
let page =
|
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)
|
Ok(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
|
pub fn get_landing_page_by_code(
|
||||||
let conn = db.content.lock().unwrap();
|
conn: &rusqlite::Connection,
|
||||||
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
|
code: &str,
|
||||||
|
) -> Result<Option<LandingPage>, AppError> {
|
||||||
|
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
|
||||||
Ok(page)
|
Ok(page)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod audit;
|
pub mod audit;
|
||||||
|
pub mod backup_layout;
|
||||||
pub mod bulk;
|
pub mod bulk;
|
||||||
|
pub mod bulk_urls;
|
||||||
|
pub mod destination_audit;
|
||||||
pub mod landing_pages;
|
pub mod landing_pages;
|
||||||
pub mod qr;
|
pub mod qr;
|
||||||
|
pub mod registry_validator;
|
||||||
pub mod shortener;
|
pub mod shortener;
|
||||||
|
pub mod slug_transfer;
|
||||||
|
pub mod urls;
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,22 +1,24 @@
|
|||||||
use crate::db::Db;
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::models::Url;
|
use crate::models::Url;
|
||||||
|
|
||||||
pub fn create_url(
|
pub fn create_url(
|
||||||
db: &Db,
|
conn: &rusqlite::Connection,
|
||||||
code: &str,
|
code: &str,
|
||||||
destination: &str,
|
destination: &str,
|
||||||
title: Option<&str>,
|
title: Option<&str>,
|
||||||
description: Option<&str>,
|
description: Option<&str>,
|
||||||
tags: &[String],
|
tags: &[String],
|
||||||
) -> Result<Url, AppError> {
|
) -> Result<Url, AppError> {
|
||||||
let conn = db.content.lock().unwrap();
|
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||||
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
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)
|
Ok(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
|
||||||
let conn = db.content.lock().unwrap();
|
let url = crate::db::content::get_url_by_code(conn, code)?;
|
||||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
|
||||||
Ok(url)
|
Ok(url)
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
use crate::analytics::queue::AnalyticsQueue;
|
use crate::analytics::queue::AnalyticsQueue;
|
||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::db::Db;
|
use crate::db::Db;
|
||||||
use rusqlite::Connection;
|
use crate::identity::TenantId;
|
||||||
|
use rusqlite::{Connection, OptionalExtension};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
#[derive(Clone)]
|
pub use crate::db::tenant::{TenantOpenMode, UserDbs};
|
||||||
pub struct UserDbs {
|
|
||||||
pub content: Arc<Mutex<Connection>>,
|
|
||||||
pub analytics: Arc<Mutex<Connection>>,
|
|
||||||
pub profile: Arc<Mutex<Connection>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub admin_db: Arc<Mutex<Connection>>,
|
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 system_db: Arc<Mutex<Connection>>,
|
||||||
pub users_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 db: Db,
|
||||||
pub config: Config,
|
pub config: Config,
|
||||||
pub analytics_queue: AnalyticsQueue,
|
pub analytics_queue: AnalyticsQueue,
|
||||||
@@ -28,71 +22,126 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl 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> {
|
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||||
let mut pool = self.user_dbs.lock().unwrap();
|
self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
|
||||||
if let Some(dbs) = pool.get(&user_id) {
|
|
||||||
return Ok(dbs.clone());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open connection and run migrations
|
pub fn get_user_dbs_with_mode(
|
||||||
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
|
&self,
|
||||||
std::fs::create_dir_all(&user_dir)?;
|
user_id: i64,
|
||||||
|
mode: TenantOpenMode,
|
||||||
let content_path = user_dir.join("content.db");
|
) -> Result<UserDbs, crate::error::AppError> {
|
||||||
let analytics_path = user_dir.join("analytics.db");
|
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
|
||||||
let profile_path = user_dir.join("profile.db");
|
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||||
|
crate::db::tenant::open_for_row_id(
|
||||||
let mut content_conn = Connection::open(content_path)?;
|
&users,
|
||||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
&self.db.topology,
|
||||||
let profile_conn = Connection::open(profile_path)?;
|
&self.system_db,
|
||||||
|
&mut pool,
|
||||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
user_id,
|
||||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
mode,
|
||||||
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),
|
|
||||||
)
|
)
|
||||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
}
|
||||||
crate::db::migrations::run_migrations(
|
|
||||||
&mut analytics_conn,
|
/// Frozen tenant opener. Unknown TenantId never creates a database.
|
||||||
"analytics",
|
pub fn open_tenant(
|
||||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
&self,
|
||||||
Some(&self.system_db),
|
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(
|
pub fn lookup_slug(
|
||||||
"CREATE TABLE IF NOT EXISTS settings (
|
&self,
|
||||||
key TEXT PRIMARY KEY,
|
slug: &str,
|
||||||
value TEXT NOT NULL
|
) -> 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 {
|
// Fallback to system.db.global_slugs if table exists (backward compatibility during transition)
|
||||||
content: Arc::new(Mutex::new(content_conn)),
|
let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?;
|
||||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
let has_table: bool = system_conn
|
||||||
profile: Arc::new(Mutex::new(profile_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,
|
||||||
};
|
};
|
||||||
|
return Ok(Some(crate::db::slugs::ResolvedSlugInfo {
|
||||||
pool.insert(user_id, dbs.clone());
|
slug: slug.to_string(),
|
||||||
Ok(dbs)
|
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> {
|
Ok(None)
|
||||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
}
|
||||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
|
||||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
pub fn open_slug_owner(
|
||||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
&self,
|
||||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,11 +5,17 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct AdminPageRow {
|
||||||
|
pub page: LandingPage,
|
||||||
|
pub owner_tenant_id: String,
|
||||||
|
pub owner_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "pages.html")]
|
#[template(path = "pages.html")]
|
||||||
pub struct PagesTemplate {
|
pub struct PagesTemplate {
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub pages: Vec<LandingPage>,
|
pub pages: Vec<AdminPageRow>,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub current_page: usize,
|
pub current_page: usize,
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ use axum::{
|
|||||||
response::{Html, IntoResponse, Response},
|
response::{Html, IntoResponse, Response},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub struct AdminUrlRow {
|
||||||
|
pub url: Url,
|
||||||
|
pub owner_tenant_id: String,
|
||||||
|
pub owner_username: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "urls.html")]
|
#[template(path = "urls.html")]
|
||||||
pub struct UrlsTemplate {
|
pub struct UrlsTemplate {
|
||||||
pub admin_username: String,
|
pub admin_username: String,
|
||||||
pub urls: Vec<Url>,
|
pub urls: Vec<AdminUrlRow>,
|
||||||
pub csrf_token: String,
|
pub csrf_token: String,
|
||||||
pub error: Option<String>,
|
pub error: Option<String>,
|
||||||
pub tag_filter: Option<String>,
|
pub tag_filter: Option<String>,
|
||||||
|
|||||||
@@ -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:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod db_lock;
|
||||||
pub mod hashing;
|
pub mod hashing;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
pub mod random;
|
pub mod random;
|
||||||
@@ -5,8 +6,9 @@ pub mod system;
|
|||||||
pub mod time;
|
pub mod time;
|
||||||
pub mod validation;
|
pub mod validation;
|
||||||
|
|
||||||
|
pub use db_lock::{lock_db, lock_db_str};
|
||||||
pub use hashing::sha256_hash;
|
pub use hashing::sha256_hash;
|
||||||
pub use network::get_client_ip;
|
pub use network::{get_client_ip, resolve_cookie_secure};
|
||||||
pub use random::generate_token;
|
pub use random::generate_token;
|
||||||
pub use system::{get_db_file_info, get_memory_usage};
|
pub use system::{get_db_file_info, get_memory_usage};
|
||||||
pub use time::format_duration;
|
pub use time::format_duration;
|
||||||
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
|
|||||||
}
|
}
|
||||||
"127.0.0.1".to_string()
|
"127.0.0.1".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
|
||||||
|
pub(crate) fn extract_hostname(host_header: &str) -> &str {
|
||||||
|
if host_header.starts_with('[') {
|
||||||
|
if let Some(end_idx) = host_header.find(']') {
|
||||||
|
return &host_header[1..end_idx];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
host_header.split(':').next().unwrap_or(host_header)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
|
||||||
|
///
|
||||||
|
/// Policy:
|
||||||
|
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
|
||||||
|
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
|
||||||
|
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
|
||||||
|
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
|
||||||
|
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
|
||||||
|
/// the cookie during local development over cleartext HTTP.
|
||||||
|
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
|
||||||
|
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
|
||||||
|
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
|
||||||
|
// 1. Explicit HTTPS via reverse proxy
|
||||||
|
if let Some(proto) = headers
|
||||||
|
.get("x-forwarded-proto")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
{
|
||||||
|
if proto.eq_ignore_ascii_case("https") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Exact loopback development (prevent cookie drop)
|
||||||
|
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||||
|
let hostname = extract_hostname(host_hdr);
|
||||||
|
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Global config (normally true)
|
||||||
|
config_secure
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use axum::http::HeaderValue;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_hostname() {
|
||||||
|
assert_eq!(extract_hostname("localhost"), "localhost");
|
||||||
|
assert_eq!(extract_hostname("localhost:8080"), "localhost");
|
||||||
|
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
|
||||||
|
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
|
||||||
|
assert_eq!(extract_hostname("[::1]"), "::1");
|
||||||
|
assert_eq!(extract_hostname("[::1]:8080"), "::1");
|
||||||
|
assert_eq!(extract_hostname("example.com"), "example.com");
|
||||||
|
assert_eq!(extract_hostname("example.com:443"), "example.com");
|
||||||
|
assert_eq!(
|
||||||
|
extract_hostname("localhost.example.com"),
|
||||||
|
"localhost.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resolve_cookie_secure() {
|
||||||
|
let mut headers = HeaderMap::new();
|
||||||
|
|
||||||
|
// No headers, global config is true -> Secure=true
|
||||||
|
assert!(resolve_cookie_secure(true, &headers));
|
||||||
|
// No headers, global config is false -> Secure=false
|
||||||
|
assert!(!resolve_cookie_secure(false, &headers));
|
||||||
|
|
||||||
|
// Exact loopback matches -> Secure=false
|
||||||
|
let loopback_hosts = [
|
||||||
|
"localhost",
|
||||||
|
"localhost:8080",
|
||||||
|
"127.0.0.1",
|
||||||
|
"127.0.0.1:8080",
|
||||||
|
"[::1]",
|
||||||
|
"[::1]:8080",
|
||||||
|
];
|
||||||
|
for host in loopback_hosts {
|
||||||
|
headers.insert("host", HeaderValue::from_static(host));
|
||||||
|
assert!(
|
||||||
|
!resolve_cookie_secure(true, &headers),
|
||||||
|
"Failed for host: {}",
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-loopback localhost subdomains -> Secure=true (relying on config)
|
||||||
|
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
|
||||||
|
for host in public_hosts {
|
||||||
|
headers.insert("host", HeaderValue::from_static(host));
|
||||||
|
assert!(
|
||||||
|
resolve_cookie_secure(true, &headers),
|
||||||
|
"Failed for host: {}",
|
||||||
|
host
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Forwarded-Proto = https overrides loopback
|
||||||
|
headers.insert("host", HeaderValue::from_static("localhost"));
|
||||||
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||||
|
assert!(resolve_cookie_secure(true, &headers));
|
||||||
|
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
|
||||||
|
|
||||||
|
// X-Forwarded-Proto = http DOES NOT override global config
|
||||||
|
headers.insert("host", HeaderValue::from_static("example.com"));
|
||||||
|
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
|
||||||
|
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
|
|||||||
("admin/admin.db", "Admin DB"),
|
("admin/admin.db", "Admin DB"),
|
||||||
("admin/system.db", "System DB"),
|
("admin/system.db", "System DB"),
|
||||||
("admin/users.db", "Users 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/content.db", "Legacy Content DB"),
|
||||||
("users/1/analytics.db", "Legacy Analytics DB"),
|
("users/1/analytics.db", "Legacy Analytics DB"),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
|
|||||||
pub fn validate_page_code(code: &str) -> bool {
|
pub fn validate_page_code(code: &str) -> bool {
|
||||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Classification of a stored or proposed redirect destination.
|
||||||
|
///
|
||||||
|
/// Used by write-path validation and read-only legacy data audits. Order of checks
|
||||||
|
/// matches `validate_redirect_destination` so both share the same rules.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum DestinationClass {
|
||||||
|
ValidHttp,
|
||||||
|
ValidHttps,
|
||||||
|
Empty,
|
||||||
|
TooLong,
|
||||||
|
ControlCharacters,
|
||||||
|
NonAscii,
|
||||||
|
UnsupportedScheme,
|
||||||
|
Malformed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DestinationClass {
|
||||||
|
pub fn is_valid(self) -> bool {
|
||||||
|
matches!(self, Self::ValidHttp | Self::ValidHttps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scheme_prefix(dest: &str) -> Option<&str> {
|
||||||
|
let end = dest.find(':')?;
|
||||||
|
let scheme = &dest[..end];
|
||||||
|
if scheme.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
if scheme
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||||
|
{
|
||||||
|
Some(scheme)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Classify a destination using the same rules as write-path validation.
|
||||||
|
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
|
||||||
|
let dest = destination.trim();
|
||||||
|
if dest.is_empty() {
|
||||||
|
return DestinationClass::Empty;
|
||||||
|
}
|
||||||
|
if dest.len() > 2048 {
|
||||||
|
return DestinationClass::TooLong;
|
||||||
|
}
|
||||||
|
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
|
||||||
|
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
|
||||||
|
return DestinationClass::ControlCharacters;
|
||||||
|
}
|
||||||
|
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
|
||||||
|
if !dest.is_ascii() {
|
||||||
|
return DestinationClass::NonAscii;
|
||||||
|
}
|
||||||
|
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
|
||||||
|
if let Some(scheme) = scheme_prefix(dest) {
|
||||||
|
let scheme_l = scheme.to_ascii_lowercase();
|
||||||
|
if scheme_l != "http" && scheme_l != "https" {
|
||||||
|
return DestinationClass::UnsupportedScheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
match reqwest::Url::parse(dest) {
|
||||||
|
Ok(url) => {
|
||||||
|
if url.host_str().is_none() {
|
||||||
|
return DestinationClass::Malformed;
|
||||||
|
}
|
||||||
|
match url.scheme() {
|
||||||
|
"http" => DestinationClass::ValidHttp,
|
||||||
|
"https" => DestinationClass::ValidHttps,
|
||||||
|
_ => DestinationClass::UnsupportedScheme,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => DestinationClass::Malformed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
|
||||||
|
///
|
||||||
|
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
|
||||||
|
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
|
||||||
|
/// values already exist in older data.
|
||||||
|
pub fn validate_redirect_destination(destination: &str) -> bool {
|
||||||
|
classify_redirect_destination(destination).is_valid()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_crlf_destination() {
|
||||||
|
assert!(!validate_redirect_destination(
|
||||||
|
"https://example.com/\r\nX-Injected: 1"
|
||||||
|
));
|
||||||
|
assert!(!validate_redirect_destination(
|
||||||
|
"https://example.com/\nX-Injected: 1"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_non_http_schemes() {
|
||||||
|
assert!(!validate_redirect_destination("javascript:alert(1)"));
|
||||||
|
assert!(!validate_redirect_destination("data:text/html,hi"));
|
||||||
|
assert!(!validate_redirect_destination("/relative/path"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_normal_https() {
|
||||||
|
assert!(validate_redirect_destination(
|
||||||
|
"https://example.com/path?q=1#frag"
|
||||||
|
));
|
||||||
|
assert!(validate_redirect_destination("http://localhost:8080/x"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn classifies_destination_categories() {
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("https://ok.example/"),
|
||||||
|
DestinationClass::ValidHttps
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("http://ok.example/"),
|
||||||
|
DestinationClass::ValidHttp
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("javascript:alert(1)"),
|
||||||
|
DestinationClass::UnsupportedScheme
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("https://x/\r\nX:1"),
|
||||||
|
DestinationClass::ControlCharacters
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
classify_redirect_destination("not a url"),
|
||||||
|
DestinationClass::Malformed
|
||||||
|
);
|
||||||
|
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,300 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupFileRow {
|
||||||
|
pub filename: String,
|
||||||
|
pub size_str: String,
|
||||||
|
pub created_str: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct BackupHistoryRow {
|
||||||
|
pub id: String,
|
||||||
|
pub backup_path: String,
|
||||||
|
pub status: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub size_bytes: i64,
|
||||||
|
pub error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/backups
|
||||||
|
pub async fn backups_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut files = vec![];
|
||||||
|
if let Ok(dir_entries) = std::fs::read_dir(&state.config.backup_dir) {
|
||||||
|
for entry in dir_entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() {
|
||||||
|
if let Some(filename) = path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
{
|
||||||
|
if filename.ends_with(".tar.gz") {
|
||||||
|
let meta = entry.metadata().unwrap();
|
||||||
|
let size_str = format_size(meta.len());
|
||||||
|
let created_str = meta
|
||||||
|
.created()
|
||||||
|
.ok()
|
||||||
|
.map(|c| {
|
||||||
|
let datetime: chrono::DateTime<chrono::Utc> = c.into();
|
||||||
|
datetime.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| "-".to_string());
|
||||||
|
files.push(BackupFileRow {
|
||||||
|
filename,
|
||||||
|
size_str,
|
||||||
|
created_str,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
files.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||||
|
|
||||||
|
let history = {
|
||||||
|
let conn = state.system_db.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, backup_path, status, created_at, size_bytes, error_message FROM backup_history ORDER BY created_at DESC LIMIT 30;").unwrap();
|
||||||
|
let rows = stmt
|
||||||
|
.query_map([], |row| {
|
||||||
|
Ok(BackupHistoryRow {
|
||||||
|
id: row.get(0)?,
|
||||||
|
backup_path: row.get(1)?,
|
||||||
|
status: row.get(2)?,
|
||||||
|
created_at: row.get(3)?,
|
||||||
|
size_bytes: row.get(4)?,
|
||||||
|
error_message: row.get(5)?,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
rows.filter_map(|r| r.ok()).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
let csrf_token = generate_csrf_token(&session_id);
|
||||||
|
|
||||||
|
let template = crate::templates::BackupsTemplate {
|
||||||
|
admin_username: user.username,
|
||||||
|
files,
|
||||||
|
history,
|
||||||
|
csrf_token,
|
||||||
|
success: params.get("success").cloned(),
|
||||||
|
error: params.get("error").cloned(),
|
||||||
|
};
|
||||||
|
|
||||||
|
template.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/create
|
||||||
|
pub async fn backups_create_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::jobs::backup::perform_backup(&state.db, &state.config).await {
|
||||||
|
Ok(path) => {
|
||||||
|
let filename = std::path::Path::new(&path)
|
||||||
|
.file_name()
|
||||||
|
.and_then(|n| n.to_str())
|
||||||
|
.unwrap_or("backup.tar.gz");
|
||||||
|
Redirect::to(&format!(
|
||||||
|
"/admin/backups?success=Backup created successfully: {}",
|
||||||
|
filename
|
||||||
|
))
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to generate backup: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /admin/backups/download/:filename
|
||||||
|
pub async fn backups_download_get(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
) -> Response {
|
||||||
|
if require_auth(&state, &jar).await.is_err() {
|
||||||
|
return StatusCode::UNAUTHORIZED.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return StatusCode::BAD_REQUEST.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_path = state.config.backup_dir.join(&filename);
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return StatusCode::NOT_FOUND.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::read(&backup_path) {
|
||||||
|
Ok(bytes) => {
|
||||||
|
let body = axum::body::Body::from(bytes);
|
||||||
|
Response::builder()
|
||||||
|
.header("content-type", "application/octet-stream")
|
||||||
|
.header(
|
||||||
|
"content-disposition",
|
||||||
|
format!("attachment; filename=\"{}\"", filename),
|
||||||
|
)
|
||||||
|
.body(body)
|
||||||
|
.unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response())
|
||||||
|
}
|
||||||
|
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/delete/:filename
|
||||||
|
pub async fn backups_delete_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
Path(filename): Path<String>,
|
||||||
|
Form(form): Form<HashMap<String, String>>,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||||
|
if !verify_csrf(&session_id, &form_csrf) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid filename").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let backup_path = state.config.backup_dir.join(&filename);
|
||||||
|
if !backup_path.exists() {
|
||||||
|
return Redirect::to("/admin/backups?error=Backup file not found").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match std::fs::remove_file(&backup_path) {
|
||||||
|
Ok(_) => {
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"BACKUP_DELETE",
|
||||||
|
"backup",
|
||||||
|
&filename,
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/backups?success=Backup archive deleted").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to delete backup file: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/backups/restore
|
||||||
|
pub async fn backups_restore_post(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
jar: CookieJar,
|
||||||
|
mut multipart: axum::extract::Multipart,
|
||||||
|
) -> Response {
|
||||||
|
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(redir) => return redir.into_response(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut backup_bytes = vec![];
|
||||||
|
let mut confirm_text = String::new();
|
||||||
|
let mut csrf_token = String::new();
|
||||||
|
|
||||||
|
while let Ok(Some(field)) = multipart.next_field().await {
|
||||||
|
let name = field.name().unwrap_or_default().to_string();
|
||||||
|
if name == "backup_file" {
|
||||||
|
if let Ok(bytes) = field.bytes().await {
|
||||||
|
backup_bytes = bytes.to_vec();
|
||||||
|
}
|
||||||
|
} else if name == "confirm_text" {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
confirm_text = text.trim().to_string();
|
||||||
|
}
|
||||||
|
} else if name == "csrf_token" {
|
||||||
|
if let Ok(text) = field.text().await {
|
||||||
|
csrf_token = text.trim().to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verify_csrf(&session_id, &csrf_token) {
|
||||||
|
return Redirect::to("/admin/backups?error=Invalid CSRF token").into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if confirm_text != "RESTORE" {
|
||||||
|
return Redirect::to(
|
||||||
|
"/admin/backups?error=Confirmation text mismatch. Please type RESTORE.",
|
||||||
|
)
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
if backup_bytes.is_empty() {
|
||||||
|
return Redirect::to("/admin/backups?error=Backup file is empty or missing.")
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
let temp_file_path = state.config.data_dir.join("temp-restore-upload.tar.gz");
|
||||||
|
if let Err(e) = std::fs::write(&temp_file_path, &backup_bytes) {
|
||||||
|
return Redirect::to(&format!(
|
||||||
|
"/admin/backups?error=Failed to save uploaded file: {}",
|
||||||
|
e
|
||||||
|
))
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
|
match crate::cli::restore::run(
|
||||||
|
temp_file_path.to_string_lossy().to_string(),
|
||||||
|
None,
|
||||||
|
state.config.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => {
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
let conn = state.users_db.lock().unwrap();
|
||||||
|
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||||
|
|
||||||
|
let system_conn = state.system_db.lock().unwrap();
|
||||||
|
let _ = crate::db::audit_events::write_audit_event(
|
||||||
|
&system_conn,
|
||||||
|
&user.username,
|
||||||
|
"RESTORE_EXECUTION",
|
||||||
|
"backup",
|
||||||
|
"upload",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
Redirect::to("/admin/login?success=Restore successful. Please log in again.")
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let _ = std::fs::remove_file(&temp_file_path);
|
||||||
|
Redirect::to(&format!("/admin/backups?error=Restore failed: {}", e)).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||