Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f138a645f8 | ||
|
|
ac66945c93 | ||
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 | ||
|
|
2cd3c2d965 | ||
|
|
b66703082f | ||
|
|
f49698bb5c | ||
|
|
7069ca9db7 | ||
|
|
faf8fc0eda | ||
|
|
bf29ccab56 | ||
|
|
667503c8f6 | ||
|
|
83218ba602 | ||
|
|
58c0af6510 | ||
|
|
f4947489af |
No files matched your search
@@ -8,10 +8,10 @@ COOKIE_SECURE=false
|
||||
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
|
||||
|
||||
# Bootstrap Admin Credentials
|
||||
# Default username: admin
|
||||
# Default password: admin
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
|
||||
# REQUIRED for a fresh deployment.
|
||||
# Use a strong unique password.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Cron & Cleaner Intervals (in minutes)
|
||||
LINK_CHECK_INTERVAL_MINS=60
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/target
|
||||
/target/
|
||||
data/
|
||||
*.db
|
||||
*.db-wal
|
||||
@@ -6,3 +6,4 @@ data/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
bzod.env
|
||||
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.5.1"
|
||||
version = "0.7.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"askama",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "bzod"
|
||||
description = "Self-hosted multi-user URL management, landing page and QR analytics platform"
|
||||
version = "0.5.2"
|
||||
version = "0.7.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
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
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only Cargo files first (best caching)
|
||||
# Dependency metadata first for Docker layer caching
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
|
||||
# Create dummy source for dependency caching
|
||||
# Dummy build to cache Rust dependencies
|
||||
RUN mkdir -p src && \
|
||||
echo "fn main() { println!(\"dummy\"); }" > src/main.rs && \
|
||||
cargo build --release && \
|
||||
rm -rf src target/release/deps/bzod*
|
||||
printf 'fn main() {}\n' > src/main.rs && \
|
||||
cargo build --release --locked && \
|
||||
rm -rf src
|
||||
|
||||
# Copy real source code + assets
|
||||
# Actual application source and runtime assets
|
||||
COPY src ./src
|
||||
COPY templates ./templates
|
||||
COPY www ./www
|
||||
|
||||
# Build the real application
|
||||
RUN cargo build --release
|
||||
# Reproducible production build
|
||||
RUN cargo build --release --locked
|
||||
|
||||
|
||||
# ==========================================
|
||||
# Stage 2: Runtime (slim)
|
||||
# Stage 2: Runtime
|
||||
# ==========================================
|
||||
FROM debian:bookworm-slim
|
||||
FROM debian:bookworm-slim AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Runtime dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
openssl \
|
||||
# Runtime dependencies only
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy binary from builder
|
||||
# Create unprivileged runtime user
|
||||
RUN groupadd --gid 1000 bzod && \
|
||||
useradd --uid 1000 --gid 1000 \
|
||||
--create-home \
|
||||
--shell /usr/sbin/nologin \
|
||||
bzod
|
||||
|
||||
# Application binary
|
||||
COPY --from=builder /app/target/release/bzod /usr/local/bin/bzod
|
||||
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Copy assets
|
||||
COPY --from=builder /app/templates ./templates
|
||||
COPY --from=builder /app/www ./www
|
||||
# Application-owned immutable assets
|
||||
COPY --from=builder /app/templates /app/templates
|
||||
COPY --from=builder /app/www /app/www
|
||||
|
||||
# Create non-root user
|
||||
RUN groupadd -g 1000 bzod && \
|
||||
useradd -u 1000 -g bzod -m -s /bin/bash bzod
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R bzod:bzod /app
|
||||
|
||||
USER bzod
|
||||
# Persistent runtime directories.
|
||||
# /app/images is intentionally external/persistent in Compose.
|
||||
RUN mkdir -p \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images && \
|
||||
chown -R bzod:bzod \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images \
|
||||
/app/templates \
|
||||
/app/www \
|
||||
/usr/local/bin/bzod \
|
||||
/usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Runtime configuration
|
||||
ENV DATA_DIR=/app/data \
|
||||
CONFIG_DIR=/app/config \
|
||||
IMAGES_DIR=/app/images \
|
||||
PORT=8654 \
|
||||
HOST=0.0.0.0 \
|
||||
COOKIE_SECURE=true
|
||||
|
||||
EXPOSE 8654
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=5s \
|
||||
--start-period=10s \
|
||||
--retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
|
||||
|
||||
ENTRYPOINT ["bzod"]
|
||||
CMD ["serve"]
|
||||
USER bzod
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["serve"]
|
||||
@@ -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
|
||||
# BZOD Production Deployment Script
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# BZOD Production Docker Deployment
|
||||
#
|
||||
# Privacy-First URL Shortener & Landing Page Platform
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# Or:
|
||||
# sudo bash deploy.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# BZOD_VERSION=0.7.1
|
||||
# BZOD_IMAGE=nx9-url-shortener
|
||||
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
||||
# BZOD_PORT=8654
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SERVICE_USER="bzod"
|
||||
INSTALL_PATH="/usr/local/bin/bzod"
|
||||
CONFIG_DIR="/etc/bzod"
|
||||
DATA_DIR="/var/lib/bzod/data"
|
||||
ENV_FILE="${CONFIG_DIR}/bzod.env"
|
||||
SYSTEMD_UNIT="/etc/systemd/system/bzod.service"
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
BZOD_VERSION="${BZOD_VERSION:-0.7.1}"
|
||||
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
|
||||
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
|
||||
BZOD_PORT="${BZOD_PORT:-8654}"
|
||||
BASE_URL="${BASE_URL:-https://bzo.in}"
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
|
||||
|
||||
DATA_DIR="${BZOD_ROOT}/data"
|
||||
CONFIG_DIR="${BZOD_ROOT}/config"
|
||||
IMAGES_DIR="${BZOD_ROOT}/images"
|
||||
COMPOSE_DIR="${BZOD_ROOT}/compose"
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
|
||||
ENV_FILE="${COMPOSE_DIR}/bzod.env"
|
||||
BACKUP_ROOT="${BZOD_ROOT}/backups"
|
||||
|
||||
IMAGE="${BZOD_IMAGE}:${BZOD_VERSION}"
|
||||
|
||||
# ============================================================
|
||||
# Output
|
||||
# ============================================================
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Temporary file cleanup
|
||||
TMP_BINARY=""
|
||||
cleanup() {
|
||||
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
|
||||
info() {
|
||||
echo -e "${BLUE}$*${NC}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
|
||||
echo -e "Production deployment started...\n"
|
||||
success() {
|
||||
echo -e "${GREEN}$*${NC}"
|
||||
}
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
|
||||
exit 1
|
||||
fi
|
||||
warning() {
|
||||
echo -e "${YELLOW}$*${NC}"
|
||||
}
|
||||
|
||||
# 1. Install Base Dependencies
|
||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
||||
apt-get update -qq
|
||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
||||
error() {
|
||||
echo -e "${RED}$*${NC}" >&2
|
||||
}
|
||||
|
||||
# 2. Install Binary (safe atomic download)
|
||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
||||
|
||||
ARCH="$(uname -m)"
|
||||
case $ARCH in
|
||||
x86_64) BINARY_NAME="bzod-x86_64-unknown-linux-gnu" ;;
|
||||
aarch64|arm64) BINARY_NAME="bzod-aarch64-unknown-linux-gnu" ;;
|
||||
armv7l) BINARY_NAME="bzod-armv7-unknown-linux-gnueabihf" ;;
|
||||
*) echo -e "${RED}Unsupported architecture: $ARCH${NC}"; exit 1 ;;
|
||||
esac
|
||||
|
||||
REPO="thakares/nx9-url-shortener"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/latest/download/${BINARY_NAME}"
|
||||
|
||||
TMP_BINARY=$(mktemp)
|
||||
|
||||
echo "Trying GitHub Releases..."
|
||||
if curl --retry 5 --retry-delay 2 --retry-connrefused \
|
||||
-L -f -o "${TMP_BINARY}" "${RELEASE_URL}" 2>/dev/null; then
|
||||
echo -e "${GREEN}✓ Downloaded from GitHub Releases${NC}"
|
||||
else
|
||||
echo -e "${BLUE}GitHub Releases not available. Trying GHCR...${NC}"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
TMP_GHCR=$(mktemp)
|
||||
docker pull ghcr.io/${REPO}:latest >/dev/null 2>&1 || true
|
||||
if docker run --rm --entrypoint cat ghcr.io/${REPO}:latest /usr/local/bin/bzod > "${TMP_GHCR}" 2>/dev/null && [ -s "${TMP_GHCR}" ]; then
|
||||
mv "${TMP_GHCR}" "${TMP_BINARY}"
|
||||
echo -e "${GREEN}✓ Extracted from GHCR${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -s "${TMP_BINARY}" ]; then
|
||||
echo -e "${BLUE}Falling back to local build...${NC}"
|
||||
if ! command -v cargo >/dev/null 2>&1; then
|
||||
echo -e "${RED}Neither pre-built binary nor cargo available.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
apt-get install -y pkg-config build-essential
|
||||
cargo build --release
|
||||
cp target/release/bzod "${TMP_BINARY}"
|
||||
echo -e "${GREEN}✓ Built from source${NC}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Atomic replace with backup
|
||||
if [ -f "${INSTALL_PATH}" ]; then
|
||||
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
|
||||
|
||||
# Verify
|
||||
if [ ! -x "${INSTALL_PATH}" ]; then
|
||||
echo -e "${RED}Binary installation failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified${NC}" || {
|
||||
echo -e "${RED}Binary verification failed${NC}"
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Show installed version
|
||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||
# ============================================================
|
||||
# Root check
|
||||
# ============================================================
|
||||
|
||||
# 3. Create System User
|
||||
echo -e "\n${BLUE}[3/8] Creating system user '${SERVICE_USER}'...${NC}"
|
||||
if ! id -u "${SERVICE_USER}" &>/dev/null; then
|
||||
useradd -r -s /usr/sbin/nologin -m -d /var/lib/bzod "${SERVICE_USER}"
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
die "This script must be run as root. Use: sudo bash deploy.sh"
|
||||
fi
|
||||
|
||||
# 4. Setup Directories
|
||||
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
|
||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
|
||||
echo
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo
|
||||
echo "Version: ${BZOD_VERSION}"
|
||||
echo "Image: ${IMAGE}"
|
||||
echo "Application: ${BZOD_ROOT}"
|
||||
echo "Data: ${DATA_DIR}"
|
||||
echo "Config: ${CONFIG_DIR}"
|
||||
echo "Images: ${IMAGES_DIR}"
|
||||
echo "Port: ${BZOD_PORT}"
|
||||
echo
|
||||
|
||||
# ============================================================
|
||||
# 1. Install Docker
|
||||
# ============================================================
|
||||
|
||||
info "[1/8] Checking Docker..."
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
info "Docker is not installed. Installing Docker..."
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL \
|
||||
https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
. /etc/os-release
|
||||
|
||||
echo \
|
||||
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
|
||||
https://download.docker.com/linux/debian \
|
||||
${VERSION_CODENAME} stable" \
|
||||
> /etc/apt/sources.list.d/docker.list
|
||||
|
||||
apt-get update -qq
|
||||
|
||||
apt-get install -y \
|
||||
docker-ce \
|
||||
docker-ce-cli \
|
||||
containerd.io \
|
||||
docker-buildx-plugin \
|
||||
docker-compose-plugin
|
||||
fi
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
die "Docker Compose plugin is unavailable."
|
||||
fi
|
||||
|
||||
success "✓ Docker and Docker Compose available"
|
||||
|
||||
# ============================================================
|
||||
# 2. Create persistent directories
|
||||
# ============================================================
|
||||
|
||||
info "[2/8] Creating persistent application directories..."
|
||||
|
||||
mkdir -p \
|
||||
"${DATA_DIR}" \
|
||||
"${CONFIG_DIR}" \
|
||||
"${IMAGES_DIR}" \
|
||||
"${COMPOSE_DIR}" \
|
||||
"${BACKUP_ROOT}"
|
||||
|
||||
chmod 700 "${CONFIG_DIR}"
|
||||
chmod 755 "${IMAGES_DIR}"
|
||||
|
||||
success "✓ Persistent directories ready"
|
||||
|
||||
# ============================================================
|
||||
# 3. Configuration
|
||||
# ============================================================
|
||||
|
||||
info "[3/8] Preparing configuration..."
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
|
||||
cat > "${ENV_FILE}" <<EOF
|
||||
BZOD_VERSION=${BZOD_VERSION}
|
||||
BZOD_IMAGE=${BZOD_IMAGE}
|
||||
|
||||
# 5. Configuration (preserve on upgrades)
|
||||
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo -e "${BLUE}Generating new secure configuration...${NC}"
|
||||
cat <<EOF > "${ENV_FILE}"
|
||||
HOST=0.0.0.0
|
||||
PORT=8654
|
||||
DATA_DIR=${DATA_DIR}
|
||||
|
||||
DATA_DIR=/app/data
|
||||
CONFIG_DIR=/app/config
|
||||
IMAGES_DIR=/app/images
|
||||
|
||||
COOKIE_SECURE=true
|
||||
RUST_LOG=info
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
BASE_URL=${BASE_URL}
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_FILE}"
|
||||
chown root:"${SERVICE_USER}" "${ENV_FILE}"
|
||||
|
||||
success "✓ New Docker configuration created"
|
||||
|
||||
else
|
||||
echo -e "${GREEN}Existing configuration preserved${NC}"
|
||||
|
||||
warning "Existing Docker configuration preserved"
|
||||
|
||||
# Update image/version while preserving all other settings.
|
||||
sed -i \
|
||||
-E "s#^BZOD_VERSION=.*#BZOD_VERSION=${BZOD_VERSION}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
sed -i \
|
||||
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
if grep -q '^BASE_URL=' "${ENV_FILE}"; then
|
||||
sed -i \
|
||||
-E "s#^BASE_URL=.*#BASE_URL=${BASE_URL}#" \
|
||||
"${ENV_FILE}" || true
|
||||
else
|
||||
echo "BASE_URL=${BASE_URL}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# 6. Systemd Service
|
||||
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
|
||||
cat <<EOF > "${SYSTEMD_UNIT}"
|
||||
[Unit]
|
||||
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# ============================================================
|
||||
# 4. Create Compose definition
|
||||
# ============================================================
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=/var/lib/bzod
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${INSTALL_PATH} serve
|
||||
info "[4/8] Writing Docker Compose configuration..."
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
cat > "${COMPOSE_FILE}" <<'EOF'
|
||||
services:
|
||||
|
||||
# Security Hardening
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
NoNewPrivileges=yes
|
||||
ReadWritePaths=/var/lib/bzod
|
||||
bzod:
|
||||
image: ${BZOD_IMAGE}:${BZOD_VERSION}
|
||||
container_name: bzod
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-8654}:8654"
|
||||
|
||||
environment:
|
||||
HOST: "${HOST:-0.0.0.0}"
|
||||
PORT: "${PORT:-8654}"
|
||||
|
||||
DATA_DIR: "/app/data"
|
||||
CONFIG_DIR: "/app/config"
|
||||
IMAGES_DIR: "/app/images"
|
||||
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
|
||||
RUST_LOG: "${RUST_LOG:-info}"
|
||||
BASE_URL: "${BASE_URL:-https://bzo.in}"
|
||||
|
||||
volumes:
|
||||
|
||||
# Persistent application databases.
|
||||
- ${BZOD_ROOT}/data:/app/data
|
||||
|
||||
# Persistent application configuration.
|
||||
- ${BZOD_ROOT}/config:/app/config
|
||||
|
||||
# User-uploaded / application images.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# /app/images is required by the image router.
|
||||
- ${BZOD_ROOT}/images:/app/images
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-fsS",
|
||||
"http://127.0.0.1:8654/status"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
EOF
|
||||
|
||||
chmod 644 "${SYSTEMD_UNIT}"
|
||||
systemctl daemon-reload
|
||||
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
if [ ! -f "${DATA_DIR}/content.db" ] && [ ! -f "${DATA_DIR}/admin.db" ] && [ ! -f "${DATA_DIR}/analytics.db" ]; then
|
||||
runuser -u "${SERVICE_USER}" -- "${INSTALL_PATH}" init-db --data-dir "${DATA_DIR}"
|
||||
echo -e "${GREEN}✓ Databases initialized${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
||||
# Append BZOD_ROOT because compose needs it.
|
||||
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
|
||||
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
systemctl enable --now bzod
|
||||
# Port variable expected by compose.
|
||||
if ! grep -q '^PORT=' "${ENV_FILE}"; then
|
||||
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# 8. Validation + Rollback
|
||||
sleep 3
|
||||
success "✓ Docker Compose configuration written"
|
||||
|
||||
# ============================================================
|
||||
# 5. Backup existing installation
|
||||
# ============================================================
|
||||
|
||||
info "[5/8] Creating pre-upgrade backup..."
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
if [[ -d "${DATA_DIR}" ]]; then
|
||||
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data"
|
||||
fi
|
||||
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
|
||||
fi
|
||||
|
||||
if [[ -d "${IMAGES_DIR}" ]]; then
|
||||
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
|
||||
fi
|
||||
|
||||
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
|
||||
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
|
||||
|
||||
success "✓ Backup created:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
# ============================================================
|
||||
# 6. Pull new image
|
||||
# ============================================================
|
||||
|
||||
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
|
||||
|
||||
# Build locally from the current deployment tree. The package/repository is
|
||||
# nx9-url-shortener; the application binary and container remain named bzod.
|
||||
if ! docker build \
|
||||
--tag "${IMAGE}" \
|
||||
--file "${BZOD_ROOT}/Dockerfile" \
|
||||
"${BZOD_ROOT}"; then
|
||||
die "Unable to build ${IMAGE}"
|
||||
fi
|
||||
|
||||
success "✓ Docker image built locally"
|
||||
|
||||
# ============================================================
|
||||
# 7. Deploy
|
||||
# ============================================================
|
||||
|
||||
info "[7/8] Deploying BZOD..."
|
||||
|
||||
cd "${COMPOSE_DIR}"
|
||||
|
||||
# Stop/remove the existing container through Compose.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
down \
|
||||
--remove-orphans
|
||||
|
||||
# Start the requested image.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
up -d
|
||||
|
||||
success "✓ BZOD container started"
|
||||
|
||||
# ============================================================
|
||||
# 8. Validation
|
||||
# ============================================================
|
||||
|
||||
info "[8/8] Validating deployment..."
|
||||
|
||||
sleep 5
|
||||
|
||||
if ! docker inspect \
|
||||
--format '{{.State.Running}}' \
|
||||
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
|
||||
|
||||
error "BZOD container failed to start."
|
||||
echo
|
||||
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
logs --tail=100
|
||||
|
||||
error
|
||||
error "Deployment failed. Existing data was not removed."
|
||||
error "Backup: ${BACKUP_DIR}"
|
||||
|
||||
if ! systemctl is-active --quiet bzod; then
|
||||
echo -e "${RED}Service failed to start! Rolling back...${NC}"
|
||||
if [ -f "${INSTALL_PATH}.bak" ]; then
|
||||
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
|
||||
systemctl restart bzod || true
|
||||
fi
|
||||
journalctl -u bzod -n 50 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up backup on success
|
||||
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
success "✓ Container is running"
|
||||
|
||||
# Soft health check
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ HTTP health check passed${NC}"
|
||||
else
|
||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
||||
# ------------------------------------------------------------
|
||||
# Health check
|
||||
# ------------------------------------------------------------
|
||||
|
||||
HEALTH_OK=0
|
||||
|
||||
for _ in {1..12}; do
|
||||
if curl -fsS \
|
||||
"http://127.0.0.1:${BZOD_PORT}/status" \
|
||||
>/dev/null 2>&1; then
|
||||
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "${HEALTH_OK}" -eq 1 ]]; then
|
||||
success "✓ HTTP health check passed"
|
||||
else
|
||||
warning "⚠ HTTP health check did not respond yet"
|
||||
warning "The container is running; inspect logs if necessary:"
|
||||
echo
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
|
||||
fi
|
||||
|
||||
# Final Message
|
||||
IP=$(hostname -I | awk '{print $1}' | head -n1)
|
||||
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
|
||||
echo -e "🌐 Web UI: http://${IP}:8654"
|
||||
echo -e "🔑 Admin: http://${IP}:8654/admin"
|
||||
echo -e "🖥 Architecture: ${ARCH}"
|
||||
echo -e "📦 Version: ${VERSION}"
|
||||
echo -e "\nNext step (first install):"
|
||||
echo -e " sudo -u bzod bzod create-admin"
|
||||
echo -e "\nCommands:"
|
||||
echo -e " journalctl -u bzod -f"
|
||||
echo -e " bzod doctor"
|
||||
echo -e " systemctl status bzod"
|
||||
# ============================================================
|
||||
# Verify image and binary
|
||||
# ============================================================
|
||||
|
||||
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
|
||||
echo
|
||||
info "Installed image:"
|
||||
docker image inspect "${IMAGE}" \
|
||||
--format ' {{.RepoTags}} ({{.Id}})' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Container:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format ' {{.Name}} {{.Config.Image}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Persistent mounts:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# Final status
|
||||
# ============================================================
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo
|
||||
|
||||
echo "Web UI:"
|
||||
echo " http://<server-ip>:${BZOD_PORT}"
|
||||
|
||||
echo
|
||||
echo "Persistent data:"
|
||||
echo " ${DATA_DIR}"
|
||||
|
||||
echo
|
||||
echo "Persistent images:"
|
||||
echo " ${IMAGES_DIR}"
|
||||
|
||||
echo
|
||||
echo "Docker Compose:"
|
||||
echo " ${COMPOSE_FILE}"
|
||||
|
||||
echo
|
||||
echo "Backup:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
echo
|
||||
echo "Useful commands:"
|
||||
echo " docker compose -f ${COMPOSE_FILE} ps"
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
|
||||
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
|
||||
|
||||
echo
|
||||
success "Deployment complete."
|
||||
@@ -1,52 +1,87 @@
|
||||
name: app-bzod
|
||||
|
||||
services:
|
||||
bzod:
|
||||
build:
|
||||
context: /DATA/AppData/bzod
|
||||
context: /DATA/AppData/nx9-url-shortener
|
||||
dockerfile: Dockerfile
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
|
||||
container_name: bzod
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- CONFIG_DIR=/app/config
|
||||
- COOKIE_SECURE=false
|
||||
- DATA_DIR=/app/data
|
||||
- HOST=0.0.0.0
|
||||
- IMAGES_DIR=/app/images
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
- BASE_URL=${BASE_URL}
|
||||
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.4.0
|
||||
|
||||
image: nx9-url-shortener:v0.7.0
|
||||
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
published: "8654"
|
||||
protocol: tcp
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/data
|
||||
source: /DATA/AppData/nx9-url-shortener/data
|
||||
target: /app/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/config
|
||||
source: /DATA/AppData/nx9-url-shortener/config
|
||||
target: /app/config
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/www
|
||||
source: /DATA/AppData/nx9-url-shortener/www
|
||||
target: /app/www
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/nx9-url-shortener/images
|
||||
target: /app/images
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
devices: []
|
||||
|
||||
cap_add: []
|
||||
|
||||
command: []
|
||||
|
||||
networks:
|
||||
- default
|
||||
|
||||
privileged: false
|
||||
|
||||
cpu_shares: 90
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: app_default
|
||||
|
||||
x-casaos:
|
||||
author: self
|
||||
category: self
|
||||
@@ -57,4 +92,4 @@ x-casaos:
|
||||
port_map: "8654"
|
||||
scheme: http
|
||||
title:
|
||||
custom: nx9-url-shortener
|
||||
custom: nx9-url-shortener
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = 'serve' ]; then
|
||||
/usr/local/bin/bzod init-admin
|
||||
exec /usr/local/bin/bzod serve
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/bzod "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -89,7 +89,22 @@ Responsible for:
|
||||
Major modules:
|
||||
|
||||
```text
|
||||
admin.rs
|
||||
admin/ (modular feature directory)
|
||||
auth.rs (authentication and session handling)
|
||||
dashboard.rs (dashboard rendering)
|
||||
urls.rs (URL management handlers)
|
||||
pages.rs (landing page management handlers)
|
||||
analytics.rs (analytics and export handlers)
|
||||
settings.rs (settings and configuration handlers)
|
||||
users.rs (user management handlers)
|
||||
sessions.rs (session administration)
|
||||
quotas.rs (quota management)
|
||||
health.rs (health diagnostics)
|
||||
backups.rs (backup and restore handlers)
|
||||
api_keys.rs (API key management)
|
||||
audit.rs (audit log handlers)
|
||||
moderation.rs (content moderation handlers)
|
||||
mod.rs (module exports and shared helpers)
|
||||
api.rs
|
||||
pages.rs
|
||||
redirect.rs
|
||||
@@ -339,9 +354,11 @@ Locate owner database
|
||||
↓
|
||||
Resolve URL
|
||||
↓
|
||||
Validate destination
|
||||
↓
|
||||
Record analytics
|
||||
↓
|
||||
302 Redirect
|
||||
301 Redirect (with safe Location header construction)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -590,7 +607,7 @@ Coverage includes:
|
||||
* Upgrade validation
|
||||
* Multi-user isolation
|
||||
|
||||
v0.5.0 includes more than 90 automated tests.
|
||||
The project includes comprehensive automated test coverage spanning unit, integration, security, and end-to-end tests.
|
||||
|
||||
---
|
||||
|
||||
@@ -635,7 +652,7 @@ Planned for future releases:
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.5.0 is built around a simple principle:
|
||||
BZOD is built around a simple principle:
|
||||
|
||||
> Keep deployment simple, keep data local, keep users isolated, and keep recovery easy.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
@@ -6,6 +6,122 @@ The format is based on Keep a Changelog and this project follows Semantic Versio
|
||||
|
||||
---
|
||||
|
||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
## Added
|
||||
|
||||
### Responsive UI
|
||||
|
||||
* Responsive layouts for admin and user URL registry panels
|
||||
* Responsive layouts for admin and user landing page registry panels
|
||||
* Desktop, laptop, tablet, and mobile layout support
|
||||
* Table-to-card responsive behavior for registry panels
|
||||
* Resolved horizontal scrolling issues in registry panels
|
||||
|
||||
### Theme Support
|
||||
|
||||
* Dark/light theme toggle
|
||||
* Theme persistence across sessions
|
||||
* Responsive theme behavior across device sizes
|
||||
|
||||
### Build Metadata
|
||||
|
||||
* Introduced `build.rs` build script for compile-time metadata
|
||||
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
|
||||
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
|
||||
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
|
||||
* Graceful fallback to `"unknown"` when Git metadata is unavailable
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
|
||||
* Public/runtime www assets supported by the deployment layout
|
||||
|
||||
## Changed
|
||||
|
||||
* Documentation updated to reflect v0.7.0 current state
|
||||
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
|
||||
|
||||
## Notes
|
||||
|
||||
* No API behavior changes
|
||||
* No database schema changes
|
||||
* No authentication or security behavior changes
|
||||
* Existing redirect, routing, and tenant isolation behavior preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
- **Legacy Backup Restore**: Full backward-compatible restore support for `legacy_flat_backup` archives into the current multi-tenant database architecture
|
||||
- **CLI Version Reporting**: Added `--version` / `-V` flags derived from Cargo package metadata
|
||||
- **Deploy Script**: Removed obsolete `init-db` command; database creation and migration now handled by `bzod serve`
|
||||
- **Version Verification**: Deploy script now verifies installed binary version matches requested version
|
||||
|
||||
# v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||
|
||||
---
|
||||
|
||||
## Changed
|
||||
|
||||
### Architecture
|
||||
|
||||
* Eliminated the monolithic `admin.rs` handler file
|
||||
* Reorganized admin functionality into focused feature modules under `src/web/admin/`
|
||||
* Separated authentication, dashboard, URLs, pages, analytics, settings, users, sessions, quotas, health, backups, API keys, audit, and moderation into dedicated modules
|
||||
* Extracted shared authentication and authorization helpers
|
||||
* Extracted common export and helper functionality
|
||||
|
||||
### Redirect Handling
|
||||
|
||||
* Removed panic-prone `HeaderValue::from_str(...).unwrap()` pattern from the redirect path
|
||||
* Added destination URL validation (scheme validation, control character rejection)
|
||||
* Added safe HTTP Location header construction
|
||||
* Improved database error logging with structured fields
|
||||
* Reduced unnecessary database mutex lock acquisitions on the redirect hot path
|
||||
* Removed synchronous expiration writes from the redirect hot path
|
||||
|
||||
---
|
||||
|
||||
## Improved
|
||||
|
||||
* Database lock scoping across admin handlers
|
||||
* Error handling consistency and observability
|
||||
* Handler decomposition for oversized functions
|
||||
* Reduced duplicated handler logic across admin operations
|
||||
|
||||
---
|
||||
|
||||
## Verified
|
||||
|
||||
* Root landing page (GET /) confirmed as intentional route serving www/index.html
|
||||
* Release binary built successfully
|
||||
* Runtime smoke tests passed (GET /, GET /login, GET /admin/login all return HTTP 200)
|
||||
* SQLite WAL mode and foreign-key enforcement initialized successfully
|
||||
* All existing migrations reported as up to date
|
||||
* Comprehensive automated test suite passed, including:
|
||||
* Authentication and migration tests
|
||||
* Redirect security tests
|
||||
* Root landing page test
|
||||
* Backup and restore tests
|
||||
* Business workflow tests
|
||||
* Security tests
|
||||
* Slug namespace, registry, and transfer tests
|
||||
* User management and isolation tests
|
||||
* WAL recovery tests
|
||||
* HTTP end-to-end tests
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
* This release is an internal architecture and quality improvement
|
||||
* No new user-facing features were introduced
|
||||
* Existing API and route behavior was preserved
|
||||
* Existing redirect security and tenant isolation behavior was preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.5.1 - General Availability (GA)
|
||||
|
||||
Release Date: 2026-06-20
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
BZOD includes a comprehensive command-line interface for server administration, backups, migrations, diagnostics, validation, and multi-user management.
|
||||
|
||||
The current command list for BZOD v0.5.1 is:
|
||||
The current command list for BZOD v0.7.0 is:
|
||||
|
||||
```text
|
||||
$ bzod --help
|
||||
@@ -30,6 +30,8 @@ Commands:
|
||||
list-users List all standard/system users
|
||||
backup-user Backup a standard user's databases to a .tar.zst package
|
||||
restore-user Restore a standard user's databases from a .tar.zst package
|
||||
admin-migrate FUTURE: Migrate legacy admin content to a specific admin tenant database
|
||||
repair Repair registry and database inconsistencies
|
||||
help Print this message or the help of the given subcommand(s)
|
||||
|
||||
Options:
|
||||
@@ -119,6 +121,19 @@ Performs:
|
||||
* Database availability checks
|
||||
* Storage verification
|
||||
* System health diagnostics
|
||||
* Global registry integrity validation
|
||||
|
||||
## Registry Repair
|
||||
|
||||
```bash
|
||||
bzod repair registry --dry-run
|
||||
```
|
||||
|
||||
Provides a transaction-safe repair utility for fixing global slug registry inconsistencies detected by `bzod doctor`.
|
||||
|
||||
* Use `--dry-run` to preview changes safely.
|
||||
* Use `--force` to execute changes and remove orphaned entries.
|
||||
* Use `--slug <slug>` to target a single missing entry.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# BZOD v0.5.1 vs Self-Hosted URL Management Platforms
|
||||
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
|
||||
|
||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# BZOD Database Architecture
|
||||
|
||||
BZOD v0.5.1 uses SQLite exclusively.
|
||||
BZOD v0.7.0 uses SQLite exclusively.
|
||||
|
||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||
|
||||
|
||||
@@ -45,7 +45,21 @@ cd nx9-url-shortener
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Create Administrator
|
||||
## Automated Administrator Bootstrap (First Start Only)
|
||||
|
||||
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD: "<your-secure-password>"
|
||||
```
|
||||
|
||||
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
|
||||
|
||||
## Manual Administrator Creation
|
||||
|
||||
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
|
||||
|
||||
```bash
|
||||
docker exec -it bzod bzod create-admin
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.5.0-linux-amd64.tar.gz
|
||||
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.5.0-linux-amd64.tar.gz
|
||||
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Multi-User Architecture Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,178 @@
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||
|
||||
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||
|
||||
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||
|
||||
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||
|
||||
## User-Visible Changes
|
||||
|
||||
- Admin URL registry panel is responsive across all device sizes
|
||||
- Admin landing page registry panel is responsive across all device sizes
|
||||
- User URL registry panel is responsive across all device sizes
|
||||
- User landing page registry panel is responsive across all device sizes
|
||||
- Dark/light theme toggle available in the UI
|
||||
- Theme preference persists across sessions
|
||||
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||
- Horizontal scrolling eliminated from registry panels
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||
- Version and Git commit now available to system status and admin settings endpoints
|
||||
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Direct upgrade from v0.6.0 with no migration required
|
||||
- No database schema changes
|
||||
- No API changes
|
||||
- No configuration changes
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
Release Date: 2026-08-09
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Legacy Backup Restore Compatibility**: Backups created with the web admin "Download Backup" feature (`legacy_flat_backup` format) can now be correctly restored into the current multi-tenant database architecture. Previously, these restores failed with "no such table: users" because the restore validator ran against the empty legacy `users.db` before layout normalization.
|
||||
|
||||
- **CLI Version Reporting**: `bzod --version` and `bzod -V` now report the application version derived from Cargo.toml package metadata, ensuring the reported version cannot diverge from the build.
|
||||
|
||||
- **Deploy Script Modernization**: Removed the obsolete `init-db` command from the deployment script. Database creation and schema migration are now handled automatically by `bzod serve`. The deploy script now verifies the installed binary version using `--version`.
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
# BZOD v0.5.3 — Architecture Refinement & Redirect Hardening
|
||||
|
||||
BZOD v0.5.3 is an internal quality and maintainability release focused on architectural refinement, redirect handler hardening, and comprehensive verification.
|
||||
|
||||
No new user-facing features are introduced. Existing API contracts, route behavior, authentication, and tenant isolation are fully preserved.
|
||||
|
||||
---
|
||||
|
||||
# Highlights
|
||||
|
||||
## Modular Admin Architecture
|
||||
|
||||
The former monolithic admin handler file was eliminated and replaced with a focused module directory at `src/web/admin/`.
|
||||
|
||||
Feature modules:
|
||||
|
||||
* `auth.rs` — authentication and session handling
|
||||
* `dashboard.rs` — dashboard rendering
|
||||
* `urls.rs` — URL management handlers
|
||||
* `pages.rs` — landing page management handlers
|
||||
* `analytics.rs` — analytics and export handlers
|
||||
* `settings.rs` — settings and configuration handlers
|
||||
* `users.rs` — user management handlers
|
||||
* `sessions.rs` — session administration
|
||||
* `quotas.rs` — quota management
|
||||
* `health.rs` — health diagnostics
|
||||
* `backups.rs` — backup and restore handlers
|
||||
* `api_keys.rs` — API key management
|
||||
* `audit.rs` — audit log handlers
|
||||
* `moderation.rs` — content moderation handlers
|
||||
|
||||
Benefits:
|
||||
|
||||
* Improved code organization and navigability
|
||||
* Reduced coupling between feature areas
|
||||
* Improved database lock scoping
|
||||
* Reduced duplicated handler logic
|
||||
* Better error handling consistency and observability
|
||||
* Simplified future extension
|
||||
|
||||
---
|
||||
|
||||
## Redirect Handler Hardening
|
||||
|
||||
The public redirect path (`GET /:code`) was hardened against invalid HTTP Location header values.
|
||||
|
||||
Changes:
|
||||
|
||||
* Removed the panic-prone `HeaderValue::from_str(...).unwrap()` pattern
|
||||
* Added destination URL validation (scheme enforcement, control character rejection)
|
||||
* Added safe Location header construction that handles malformed values gracefully
|
||||
* Improved database error logging with structured fields
|
||||
* Reduced unnecessary database mutex lock acquisitions
|
||||
* Removed synchronous expiration writes from the redirect hot path
|
||||
|
||||
Existing redirect security and tenant isolation behavior was preserved.
|
||||
|
||||
---
|
||||
|
||||
## Root Landing Page Verification
|
||||
|
||||
* Confirmed `GET /` as an intentional application route serving `www/index.html`
|
||||
* Resolved a runtime path-resolution issue affecting static landing-page resolution
|
||||
* Verified `GET /` returns HTTP 200
|
||||
* Verified `GET /login` returns HTTP 200
|
||||
* Verified `GET /admin/login` returns HTTP 200
|
||||
|
||||
---
|
||||
|
||||
# Testing & Validation
|
||||
|
||||
BZOD v0.5.3 passed:
|
||||
|
||||
* Release build (`cargo build --release`)
|
||||
* Comprehensive automated test suite, including:
|
||||
* Authentication and migration tests
|
||||
* Redirect security tests
|
||||
* Root landing page test
|
||||
* Backup and restore tests
|
||||
* Business workflow tests
|
||||
* Security tests
|
||||
* Slug namespace, registry, and transfer tests
|
||||
* User management and isolation tests
|
||||
* WAL recovery tests
|
||||
* HTTP end-to-end tests
|
||||
* Runtime smoke tests against the release binary
|
||||
* SQLite WAL mode and foreign-key enforcement initialization
|
||||
* Database migration verification (all migrations up to date)
|
||||
|
||||
---
|
||||
|
||||
# Compatibility
|
||||
|
||||
* No breaking changes
|
||||
* No API changes
|
||||
* No route changes
|
||||
* No database schema changes
|
||||
* No configuration changes
|
||||
* Direct upgrade from v0.5.1 with no migration required
|
||||
|
||||
---
|
||||
|
||||
# Repository
|
||||
|
||||
* Clean source tree established
|
||||
* Build artifacts, temporary reports, and IDE metadata removed
|
||||
* Existing BZOD Git history preserved
|
||||
* Refactoring baseline merged with existing history
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.5.1 — Namespace Integrity & Platform Hardening
|
||||
|
||||
**Release Date:** 2026-06-20
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
|
||||
* Disaster recovery
|
||||
* Operational simplicity
|
||||
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.5.0.
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
@@ -432,6 +432,27 @@ for:
|
||||
|
||||
---
|
||||
|
||||
# Redirect Security
|
||||
|
||||
The redirect handler validates destination URLs before constructing HTTP Location headers.
|
||||
|
||||
Protections include:
|
||||
|
||||
* URL scheme validation (only http and https destinations are permitted)
|
||||
* Control character rejection
|
||||
* CRLF injection prevention
|
||||
* Safe Location header construction (no panics on malformed values)
|
||||
|
||||
Invalid redirect destinations return:
|
||||
|
||||
```http
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
with structured server-side logging. Full destination values are not exposed to clients.
|
||||
|
||||
---
|
||||
|
||||
# Audit Logging
|
||||
|
||||
Security-sensitive actions are logged.
|
||||
@@ -599,7 +620,7 @@ If compromise is suspected:
|
||||
|
||||
# Security Testing
|
||||
|
||||
BZOD v0.5.0 includes tests covering:
|
||||
BZOD v0.7.0 includes tests covering:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
@@ -610,6 +631,8 @@ BZOD v0.5.0 includes tests covering:
|
||||
* Upgrade migrations
|
||||
* Backup integrity
|
||||
* Disaster recovery
|
||||
* Redirect destination validation
|
||||
* HTTP Location header safety
|
||||
|
||||
These tests are executed during CI and release validation.
|
||||
|
||||
@@ -642,7 +665,7 @@ These may be addressed in future releases.
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.5.0 provides:
|
||||
BZOD v0.7.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
|
||||
@@ -325,6 +325,38 @@ and:
|
||||
|
||||
for final landing page render.
|
||||
|
||||
Root landing page:
|
||||
|
||||
```text
|
||||
GET /
|
||||
```
|
||||
|
||||
must serve the static landing page.
|
||||
|
||||
Expected:
|
||||
|
||||
```http
|
||||
200 OK
|
||||
Content-Type: text/html
|
||||
```
|
||||
|
||||
Redirect security:
|
||||
|
||||
Redirect destinations are validated against:
|
||||
|
||||
* Invalid URL schemes
|
||||
* CRLF injection attempts
|
||||
* Control character injection
|
||||
* Malformed HTTP Location header values
|
||||
|
||||
Invalid destinations must return:
|
||||
|
||||
```http
|
||||
500 Internal Server Error
|
||||
```
|
||||
|
||||
and must not panic or produce malformed HTTP responses.
|
||||
|
||||
---
|
||||
|
||||
# 12. Backup Validation
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.5.1
|
||||
Version: v0.7.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
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 {
|
||||
pub fn new(db: Db, capacity: usize) -> Self {
|
||||
pub fn new(
|
||||
db: Db,
|
||||
capacity: usize,
|
||||
shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) -> (Self, tokio::task::JoinHandle<()>) {
|
||||
let (sender, receiver) = mpsc::channel(capacity);
|
||||
|
||||
// Spawn background worker to batch-write records
|
||||
tokio::spawn(async move {
|
||||
super::worker::run_worker(db, receiver).await;
|
||||
let handle = tokio::spawn(async move {
|
||||
super::worker::run_worker(db, receiver, shutdown_rx).await;
|
||||
});
|
||||
|
||||
Self { sender }
|
||||
(Self { sender }, handle)
|
||||
}
|
||||
|
||||
// Attempt to queue a visit. Non-blocking.
|
||||
|
||||
@@ -7,7 +7,11 @@ use crate::db::analytics::insert_visits_batch;
|
||||
use crate::db::Db;
|
||||
use crate::models::VisitRecord;
|
||||
|
||||
pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
pub async fn run_worker(
|
||||
db: Db,
|
||||
mut receiver: mpsc::Receiver<VisitRecord>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let mut batch = Vec::new();
|
||||
let batch_size = 50;
|
||||
let flush_interval = Duration::from_secs(2);
|
||||
@@ -37,6 +41,11 @@ pub async fn run_worker(db: Db, mut receiver: mpsc::Receiver<VisitRecord>) {
|
||||
flush_batch(&db, &mut batch);
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Analytics worker flushing pending records");
|
||||
flush_batch(&db, &mut batch);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -101,32 +101,69 @@ pub async fn run(
|
||||
Connection::open(&users_db_path),
|
||||
) {
|
||||
(Ok(sys_conn), Ok(usr_conn)) => {
|
||||
match crate::db::users::verify_global_slug_registry_integrity(
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&sys_conn,
|
||||
&usr_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok((errors, warnings)) => {
|
||||
if errors.is_empty() && warnings.is_empty() {
|
||||
Ok(issues) => {
|
||||
if issues.is_empty() {
|
||||
println!(" Status: HEALTHY (no issues found)");
|
||||
} else {
|
||||
if !errors.is_empty() {
|
||||
println!(" Errors (Action Required):");
|
||||
for err in &errors {
|
||||
println!(" - {}", err);
|
||||
}
|
||||
all_healthy = false;
|
||||
}
|
||||
if !warnings.is_empty() {
|
||||
println!(" Warnings (Attention Needed):");
|
||||
for warn in &warnings {
|
||||
println!(" - {}", warn);
|
||||
println!(" Status: ISSUES DETECTED");
|
||||
all_healthy = false;
|
||||
|
||||
for issue in &issues {
|
||||
println!();
|
||||
println!("ERROR");
|
||||
println!();
|
||||
println!("Slug:");
|
||||
println!(" {}", issue.slug);
|
||||
println!();
|
||||
println!("Type:");
|
||||
println!(
|
||||
" {}",
|
||||
if issue.target_type == "url" {
|
||||
"URL"
|
||||
} else if issue.target_type == "page" {
|
||||
"Landing Page"
|
||||
} else {
|
||||
&issue.target_type
|
||||
}
|
||||
);
|
||||
println!();
|
||||
println!("Owner:");
|
||||
println!(" User ID {}", issue.owner_user_id);
|
||||
println!();
|
||||
println!("Database:");
|
||||
println!(" {}", issue.database_path.display());
|
||||
println!();
|
||||
println!("Target UUID:");
|
||||
println!(" {}", issue.target_id);
|
||||
println!();
|
||||
println!("Issue:");
|
||||
println!(" {:?}", issue.issue_type);
|
||||
println!();
|
||||
println!("Description:");
|
||||
println!(" {}", issue.description);
|
||||
println!();
|
||||
println!("Suggested Repair:");
|
||||
println!();
|
||||
if issue.slug != "*" {
|
||||
println!(
|
||||
" bzod repair registry --slug {} --dry-run",
|
||||
issue.slug
|
||||
);
|
||||
} else {
|
||||
println!(" bzod repair registry --dry-run");
|
||||
}
|
||||
println!("--------------------");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(" Status: ERROR running integrity check: {}", e);
|
||||
println!(" Status: ERROR running registry scan: {}", e);
|
||||
all_healthy = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
use crate::auth::hash_password;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
|
||||
let admin_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
|
||||
if admin_count > 0 {
|
||||
info!("Administrator already exists; initialization skipped.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let username = match env::var("ADMIN_USERNAME") {
|
||||
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let password = match env::var("ADMIN_PASSWORD") {
|
||||
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let u = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!("Administrator initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,28 +1,31 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod admin_migrate;
|
||||
pub mod audit_destinations;
|
||||
pub mod backup;
|
||||
pub mod backup_user;
|
||||
pub mod create_admin;
|
||||
pub mod create_user;
|
||||
pub mod delete_user;
|
||||
pub mod disable_user;
|
||||
pub mod doctor;
|
||||
pub mod enable_user;
|
||||
pub mod expand;
|
||||
pub mod init_admin;
|
||||
pub mod list_users;
|
||||
pub mod migrate;
|
||||
pub mod repair;
|
||||
pub mod reset_password;
|
||||
pub mod restore;
|
||||
pub mod restore_user;
|
||||
pub mod serve;
|
||||
pub mod shorten;
|
||||
pub mod stats;
|
||||
pub mod validate;
|
||||
|
||||
pub mod 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)]
|
||||
#[command(name = "bzod")]
|
||||
#[command(version)]
|
||||
#[command(about = "BZOD - Personal Redirector & Landing Page Platform")]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
@@ -72,6 +75,11 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Read-only audit of stored redirect destinations (schemes, control chars, malformed)
|
||||
AuditDestinations {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Create a new administrator user in the database
|
||||
CreateAdmin {
|
||||
#[arg(long)]
|
||||
@@ -79,6 +87,11 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Initialize the first administrator for automated/container deployments
|
||||
InitAdmin {
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
/// Run database diagnostics and health checks
|
||||
Doctor {
|
||||
#[arg(long)]
|
||||
@@ -179,4 +192,24 @@ pub enum Commands {
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
},
|
||||
/// Repair registry and database inconsistencies
|
||||
Repair {
|
||||
#[command(subcommand)]
|
||||
command: RepairCommands,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
pub enum RepairCommands {
|
||||
/// Repair Global Slug Registry inconsistencies
|
||||
Registry {
|
||||
#[arg(long)]
|
||||
dry_run: bool,
|
||||
#[arg(long)]
|
||||
force: bool,
|
||||
#[arg(long)]
|
||||
slug: Option<String>,
|
||||
#[arg(long)]
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
use crate::cli::RepairCommands;
|
||||
use crate::config::Config;
|
||||
use crate::services::registry_validator::{RegistryIssueType, RegistryValidator};
|
||||
use rusqlite::Connection;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
|
||||
pub async fn run(
|
||||
command: RepairCommands,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match command {
|
||||
RepairCommands::Registry {
|
||||
dry_run,
|
||||
force,
|
||||
slug,
|
||||
data_dir,
|
||||
} => {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
|
||||
if !dry_run && !force {
|
||||
println!("Error: You must specify either --dry-run or --force");
|
||||
return Ok(());
|
||||
}
|
||||
if dry_run && force {
|
||||
println!("Error: Cannot specify both --dry-run and --force");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let system_db_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
if !system_db_path.exists() || !users_db_path.exists() {
|
||||
println!("Error: system.db or users.db not found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sys_conn = Connection::open(&system_db_path)?;
|
||||
let usr_conn = Connection::open(&users_db_path)?;
|
||||
|
||||
let slug_filter = slug.as_deref();
|
||||
|
||||
if dry_run {
|
||||
println!("BZOD Registry Repair\n");
|
||||
println!("Scanning Global Slug Registry...");
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||
|
||||
println!("\nDetected:");
|
||||
println!("\nPages:\n {} orphaned", orphaned_pages);
|
||||
println!("\nURLs:\n {} orphaned", orphaned_urls);
|
||||
|
||||
if !orphaned.is_empty() {
|
||||
println!("\nThe following entries would be removed:");
|
||||
for issue in &orphaned {
|
||||
println!("\n{}\n {}", issue.target_type.to_uppercase(), issue.slug);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made.");
|
||||
println!(
|
||||
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||
if let Some(s) = slug_filter {
|
||||
format!(" --slug {}", s)
|
||||
} else {
|
||||
"".to_string()
|
||||
}
|
||||
);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, start_time.elapsed()
|
||||
);
|
||||
} else if force {
|
||||
let tx = sys_conn.transaction()?;
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&tx, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let orphaned_pages = orphaned.iter().filter(|i| i.target_type == "page").count();
|
||||
let orphaned_urls = orphaned.iter().filter(|i| i.target_type == "url").count();
|
||||
|
||||
if orphaned.is_empty() {
|
||||
println!("No repairs required.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(s) = slug_filter {
|
||||
println!("Checking slug:\n\n{}\n", s);
|
||||
if let Some(issue) = orphaned.first() {
|
||||
println!("Owner:\n\n{}\n", issue.owner_user_id);
|
||||
println!("Status:\n\nOrphaned\n");
|
||||
}
|
||||
}
|
||||
|
||||
let mut removed_count = 0;
|
||||
for issue in &orphaned {
|
||||
let rows = tx.execute(
|
||||
"DELETE FROM global_slugs WHERE slug = ?1",
|
||||
rusqlite::params![issue.slug],
|
||||
)?;
|
||||
removed_count += rows;
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
|
||||
if slug_filter.is_some() {
|
||||
println!("Removed:\n\nSUCCESS");
|
||||
} else {
|
||||
println!("Repair Complete\n");
|
||||
println!("Removed:\n");
|
||||
println!("Pages:\n {}\n", orphaned_pages);
|
||||
println!("URLs:\n {}\n", orphaned_urls);
|
||||
|
||||
let remaining: i64 =
|
||||
sys_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
println!("Remaining Registry Entries:\n {}\n", remaining);
|
||||
|
||||
let post_issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
|
||||
let post_orphaned = post_issues
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
println!(
|
||||
"Integrity:\n {}",
|
||||
if post_orphaned == 0 { "PASS" } else { "FAIL" }
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,21 +1,250 @@
|
||||
use crate::config::Config;
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
use flate2::read::GzDecoder;
|
||||
use std::fs::File;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tar::Archive;
|
||||
use tracing::{error, info};
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Read backup_manifest.json and return true if this is a legacy_flat_backup.
|
||||
fn is_legacy_flat_backup(temp_dir: &Path) -> bool {
|
||||
let manifest_path = temp_dir.join("backup_manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
return false;
|
||||
}
|
||||
match std::fs::read_to_string(&manifest_path) {
|
||||
Ok(contents) => match serde_json::from_str::<serde_json::Value>(&contents) {
|
||||
Ok(val) => val.get("type").and_then(|t| t.as_str()) == Some("legacy_flat_backup"),
|
||||
Err(_) => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect if the unpacked archive is in flat layout (files at root, not in admin/ subdirectory).
|
||||
fn is_flat_layout(temp_dir: &Path) -> bool {
|
||||
temp_dir.join("admin.db").exists() && !temp_dir.join("admin").join("admin.db").exists()
|
||||
}
|
||||
|
||||
/// Bootstrap users.db for a legacy backup where users.db is empty/unmigrated.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Runs USERS_MIGRATIONS on users.db to create the required schema.
|
||||
/// 2. Reads the actual administrator identity from admin.db (preserving
|
||||
/// the original username and argon2id password hash — no manufacturing).
|
||||
/// 3. Creates a legacy_admin system placeholder (id=1) for tenant ownership.
|
||||
/// 4. Creates an admin account with the original credentials.
|
||||
/// 5. Scans global_slugs for owner_user_ids and creates disabled placeholder
|
||||
/// accounts for any missing tenants.
|
||||
fn bootstrap_legacy_users_db(temp_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use crate::db::migrations::{run_migrations, USERS_MIGRATIONS};
|
||||
|
||||
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||
let admin_db_path = temp_dir.join("admin").join("admin.db");
|
||||
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||
|
||||
// Check if users.db already has the users table (i.e., not a legacy backup)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
let has_users_table: bool = conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='users');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if has_users_table {
|
||||
info!("users.db already has users table; skipping legacy bootstrap");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
info!("Legacy users.db detected (empty/unmigrated). Bootstrapping current schema...");
|
||||
|
||||
// Step 1: Run migrations to create the users.db schema
|
||||
let mut users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
crate::db::sqlite::enable_wal(&users_conn, "users")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&users_conn, "users")?;
|
||||
run_migrations(&mut users_conn, "users", USERS_MIGRATIONS, None)?;
|
||||
|
||||
// Step 2: Read the actual administrator identity from admin.db
|
||||
let (admin_username, admin_password_hash) = {
|
||||
let admin_conn = rusqlite::Connection::open(&admin_db_path)?;
|
||||
|
||||
// The legacy admin.db users table has schema:
|
||||
// id TEXT PRIMARY KEY (UUID), username TEXT, password_hash TEXT, created_at TEXT
|
||||
// Read the actual admin — typically the first (and often only) user.
|
||||
let result: Result<(String, String), _> = admin_conn.query_row(
|
||||
"SELECT username, password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
);
|
||||
|
||||
match result {
|
||||
Ok((username, hash)) => {
|
||||
info!(
|
||||
"Preserved administrator identity from legacy admin.db: username='{}'",
|
||||
username
|
||||
);
|
||||
(username, hash)
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!(
|
||||
"Failed to read administrator credentials from legacy admin.db: {}",
|
||||
e
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Step 3: Create legacy_admin system placeholder (id=1) for tenant content ownership
|
||||
// This account owns the content.db/analytics.db from the flat backup (users/1/).
|
||||
// It uses the original admin's password hash so no synthetic credentials are introduced.
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
1i64,
|
||||
"legacy_admin",
|
||||
&admin_password_hash,
|
||||
"disabled",
|
||||
&now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [1i64])?;
|
||||
info!("Created legacy_admin system account (id=1) for tenant content ownership");
|
||||
|
||||
// Step 4: Create the actual admin account with original credentials
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![
|
||||
&admin_username,
|
||||
&admin_password_hash,
|
||||
"active",
|
||||
&now,
|
||||
"admin"
|
||||
],
|
||||
)?;
|
||||
let admin_id = users_conn.last_insert_rowid();
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [admin_id])?;
|
||||
info!(
|
||||
"Created admin account '{}' (id={}) with original credentials",
|
||||
admin_username, admin_id
|
||||
);
|
||||
|
||||
// Step 5: Scan global_slugs for owner_user_ids and create placeholders for missing tenants
|
||||
if system_db_path.exists() {
|
||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||
let has_global_slugs: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_global_slugs {
|
||||
let mut stmt =
|
||||
system_conn.prepare("SELECT DISTINCT owner_user_id FROM global_slugs;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let owner_id: i64 = row.get(0)?;
|
||||
// Skip user 1 (legacy_admin) and the admin we just created
|
||||
if owner_id == 1 || owner_id == admin_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this user already exists in users.db
|
||||
let exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !exists {
|
||||
// Create a disabled placeholder so RegistryValidator can resolve ownership.
|
||||
// The tenant's actual databases were not included in the flat backup.
|
||||
let placeholder_name = format!("restored_user_{}", owner_id);
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
owner_id,
|
||||
&placeholder_name,
|
||||
&admin_password_hash,
|
||||
"disabled",
|
||||
&now,
|
||||
"standard",
|
||||
"Placeholder created during legacy_flat_backup restore. Original tenant databases were not included in the flat backup."
|
||||
],
|
||||
)?;
|
||||
users_conn.execute("INSERT INTO quotas (user_id) VALUES (?1);", [owner_id])?;
|
||||
warn!(
|
||||
"Created placeholder account for user_id={} (referenced in global_slugs but tenant databases not in backup)",
|
||||
owner_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Classify registry issues into hard errors vs warnings for legacy restore.
|
||||
///
|
||||
/// Hard errors: DuplicateSlug, InvalidTargetType, InvalidStatus
|
||||
/// Warnings: MissingDatabase, MissingTarget, MissingOwner, StaleReservation,
|
||||
/// TenantAdminHasIsolatedContent
|
||||
fn classify_registry_issues(
|
||||
issues: &[crate::services::registry_validator::RegistryIssue],
|
||||
is_legacy: bool,
|
||||
) -> (
|
||||
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||
Vec<&crate::services::registry_validator::RegistryIssue>,
|
||||
) {
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for issue in issues {
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::DuplicateSlug
|
||||
| RegistryIssueType::InvalidTargetType
|
||||
| RegistryIssueType::InvalidStatus => {
|
||||
errors.push(issue);
|
||||
}
|
||||
RegistryIssueType::MissingOwner if !is_legacy => {
|
||||
errors.push(issue);
|
||||
}
|
||||
_ => {
|
||||
// For legacy restores: MissingDatabase, MissingTarget, MissingOwner,
|
||||
// StaleReservation, TenantAdminHasIsolatedContent are warnings.
|
||||
// These represent pre-existing inconsistencies in the backup data,
|
||||
// not restore corruption.
|
||||
warnings.push(issue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(errors, warnings)
|
||||
}
|
||||
|
||||
pub fn perform_restore(
|
||||
file_path: &std::path::Path,
|
||||
data_dir: &std::path::Path,
|
||||
file_path: &Path,
|
||||
data_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 1. Open the archive
|
||||
// 1. Open and unpack the archive to a temporary directory
|
||||
let f = File::open(file_path)?;
|
||||
let tar_gz = GzDecoder::new(f);
|
||||
let mut archive = Archive::new(tar_gz);
|
||||
|
||||
// 2. Unpack to temporary directory first
|
||||
let temp_dir =
|
||||
std::env::temp_dir().join(format!("bzod_system_restore_{}", uuid::Uuid::new_v4()));
|
||||
std::fs::create_dir_all(&temp_dir)?;
|
||||
@@ -25,7 +254,33 @@ pub fn perform_restore(
|
||||
return Err(e.into());
|
||||
}
|
||||
|
||||
// 3. Run validation on temp_dir
|
||||
// 2. Detect backup format
|
||||
let is_legacy = is_legacy_flat_backup(&temp_dir);
|
||||
let needs_normalization = is_flat_layout(&temp_dir);
|
||||
|
||||
if is_legacy {
|
||||
info!("Detected legacy_flat_backup format — using legacy-aware restore path");
|
||||
}
|
||||
|
||||
// 3. Normalize flat layout into multi-tenant structure BEFORE any validation
|
||||
if needs_normalization {
|
||||
info!("Normalizing flat database layout into multi-tenant structure...");
|
||||
if let Err(e) = crate::services::backup_layout::normalize_restored_layout(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to normalize legacy layout: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// 4. For legacy backups: bootstrap the empty users.db with the current schema
|
||||
// and populate it from admin.db credentials
|
||||
if is_legacy {
|
||||
if let Err(e) = bootstrap_legacy_users_db(&temp_dir) {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Failed to bootstrap legacy users database: {}", e).into());
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Run validation on the normalized temp_dir
|
||||
let mut temp_config = Config::load();
|
||||
temp_config.data_dir = temp_dir.clone();
|
||||
|
||||
@@ -46,29 +301,51 @@ pub fn perform_restore(
|
||||
}
|
||||
|
||||
// Registry integrity check
|
||||
let system_db_path = if temp_dir.join("admin/system.db").exists() {
|
||||
temp_dir.join("admin/system.db")
|
||||
} else {
|
||||
temp_dir.join("system.db")
|
||||
};
|
||||
let users_db_path = if temp_dir.join("admin/users.db").exists() {
|
||||
temp_dir.join("admin/users.db")
|
||||
} else {
|
||||
temp_dir.join("users.db")
|
||||
};
|
||||
let system_db_path = temp_dir.join("admin").join("system.db");
|
||||
let users_db_path = temp_dir.join("admin").join("users.db");
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
let system_conn = rusqlite::Connection::open(&system_db_path)?;
|
||||
let users_conn = rusqlite::Connection::open(&users_db_path)?;
|
||||
match crate::db::users::verify_global_slug_registry_integrity(
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&temp_dir,
|
||||
None,
|
||||
) {
|
||||
Ok((errors, _warnings)) => {
|
||||
if !errors.is_empty() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!("Registry integrity errors in backup: {:?}", errors).into());
|
||||
Ok(issues) => {
|
||||
if !issues.is_empty() {
|
||||
let (hard_errors, warnings) = classify_registry_issues(&issues, is_legacy);
|
||||
|
||||
// Log all warnings
|
||||
for w in &warnings {
|
||||
warn!(
|
||||
"Legacy restore warning: {:?} — {}",
|
||||
w.issue_type, w.description
|
||||
);
|
||||
}
|
||||
|
||||
// Abort only on hard errors
|
||||
if !hard_errors.is_empty() {
|
||||
let descriptions: Vec<String> = hard_errors
|
||||
.iter()
|
||||
.map(|e| format!("{:?}: {}", e.issue_type, e.description))
|
||||
.collect();
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
return Err(format!(
|
||||
"Registry integrity errors in backup ({} critical): {}",
|
||||
hard_errors.len(),
|
||||
descriptions.join("; ")
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
if !warnings.is_empty() {
|
||||
info!(
|
||||
"Registry validation completed with {} warnings (pre-existing backup inconsistencies)",
|
||||
warnings.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -78,13 +355,13 @@ pub fn perform_restore(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. If validation succeeds, copy temp_dir contents to data_dir
|
||||
// 6. If validation succeeds, atomically replace data_dir contents
|
||||
if data_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(data_dir);
|
||||
}
|
||||
std::fs::create_dir_all(data_dir)?;
|
||||
|
||||
fn copy_dir_all(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
|
||||
fn copy_dir_all(src: &Path, dst: &Path) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(dst)?;
|
||||
for entry in std::fs::read_dir(src)? {
|
||||
let entry = entry?;
|
||||
|
||||
@@ -7,6 +7,30 @@ use std::path::PathBuf;
|
||||
use std::time::Instant;
|
||||
use tracing::info;
|
||||
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
tokio::signal::ctrl_c()
|
||||
.await
|
||||
.expect("failed to install Ctrl+C handler");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
|
||||
.expect("failed to install signal handler")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {},
|
||||
_ = terminate => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
host: Option<String>,
|
||||
port: Option<u16>,
|
||||
@@ -29,39 +53,63 @@ pub async fn run(
|
||||
// Init DBs
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);
|
||||
let mut join_handles = Vec::new();
|
||||
|
||||
// Init Queue
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (queue, analytics_handle) = AnalyticsQueue::new(db.clone(), 1000, shutdown_rx.clone());
|
||||
join_handles.push(("analytics_worker", analytics_handle));
|
||||
|
||||
// Spawn background tasks
|
||||
let link_checker_db = db.clone();
|
||||
let link_checker_interval = config.link_check_interval_mins;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_link_checker(link_checker_db, link_checker_interval).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"link_checker",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_link_checker(link_checker_db, link_checker_interval, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let aggregator_db = db.clone();
|
||||
let aggregator_interval = config.aggregation_interval_mins;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_aggregator(aggregator_db, aggregator_interval).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"aggregator",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_aggregator(aggregator_db, aggregator_interval, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let retention_db = db.clone();
|
||||
let retention_days = config.data_retention_days;
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_retention_cleaner(retention_db, retention_days).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"retention_cleaner",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_retention_cleaner(retention_db, retention_days, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
// Spawn optional backup scheduler
|
||||
let backup_db = db.clone();
|
||||
let backup_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"backup_scheduler",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::backup::run_backup_scheduler(backup_db, backup_config, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let expiry_db = db.clone();
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"expiry_checker",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_expiry_checker(expiry_db, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let reconcile_db = db.clone();
|
||||
let reconcile_interval_hours = {
|
||||
@@ -75,9 +123,13 @@ pub async fn run(
|
||||
.and_then(|val| val.parse::<u64>().ok())
|
||||
.unwrap_or(24)
|
||||
};
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours).await;
|
||||
});
|
||||
let rx = shutdown_rx.clone();
|
||||
join_handles.push((
|
||||
"quota_reconciliation",
|
||||
tokio::spawn(async move {
|
||||
crate::jobs::run_quota_reconciliation(reconcile_db, reconcile_interval_hours, rx).await;
|
||||
}),
|
||||
));
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
@@ -98,7 +150,31 @@ pub async fn run(
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
info!("Listening for requests on http://{}", addr);
|
||||
axum::serve(listener, router).await?;
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async move {
|
||||
shutdown_signal().await;
|
||||
info!("Shutdown signal received");
|
||||
info!("Stopping HTTP server...");
|
||||
let _ = shutdown_tx.send(true);
|
||||
})
|
||||
.await?;
|
||||
|
||||
info!("Stopping background workers...");
|
||||
|
||||
let timeout_duration = std::time::Duration::from_secs(10);
|
||||
let deadline = tokio::time::Instant::now() + timeout_duration;
|
||||
|
||||
for (name, handle) in join_handles {
|
||||
match tokio::time::timeout_at(deadline, handle).await {
|
||||
Ok(Ok(_)) => {}
|
||||
Ok(Err(e)) => tracing::error!("Background task '{}' panicked: {:?}", name, e),
|
||||
Err(_) => tracing::warn!("Background task did not terminate: {}", name),
|
||||
}
|
||||
}
|
||||
|
||||
info!("Background workers stopped");
|
||||
info!("BZOD shutdown complete");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -356,17 +356,19 @@ impl Db {
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
match crate::db::users::verify_global_slug_registry_integrity(
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok((errors, warnings)) => {
|
||||
for err in errors {
|
||||
tracing::error!("Global registry integrity error: {}", err);
|
||||
}
|
||||
for warn in warnings {
|
||||
tracing::warn!("Global registry integrity warning: {}", warn);
|
||||
Ok(issues) => {
|
||||
for issue in issues {
|
||||
tracing::error!(
|
||||
"Global registry integrity issue: {:?} for slug {}",
|
||||
issue.issue_type,
|
||||
issue.slug
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -722,216 +722,6 @@ pub fn cleanup_stale_reservations(
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
pub fn verify_global_slug_registry_integrity(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
data_dir: &std::path::Path,
|
||||
) -> Result<(Vec<String>, Vec<String>), Box<dyn std::error::Error>> {
|
||||
use chrono::{DateTime, Utc};
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Check duplicate slugs
|
||||
let total_count: i64 =
|
||||
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
let distinct_count: i64 =
|
||||
system_conn.query_row("SELECT COUNT(DISTINCT slug) FROM global_slugs;", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
if total_count != distinct_count {
|
||||
errors.push(format!(
|
||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||
total_count, distinct_count
|
||||
));
|
||||
}
|
||||
|
||||
// 2. Scan all global slugs
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
)?;
|
||||
let mut rows = stmt.query([])?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_user_id: i64 = row.get(1)?;
|
||||
let target_type: String = row.get(2)?;
|
||||
let target_id: String = row.get(3)?;
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
errors.push(format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
));
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
errors.push(format!("Slug '{}' has invalid status '{}'", slug, status));
|
||||
}
|
||||
|
||||
// Check owner
|
||||
let owner_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_user_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !owner_exists {
|
||||
errors.push(format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
if status == "reserving" {
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::minutes(15) {
|
||||
warnings.push(format!(
|
||||
"Reserving slug '{}' has been stale for over 15 minutes",
|
||||
slug
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
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(
|
||||
system_conn: &Connection,
|
||||
target_user_id: i64,
|
||||
|
||||
@@ -5,9 +5,19 @@ use super::{log_job_end, log_job_start};
|
||||
use crate::analytics::aggregate_day;
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_aggregator(db: Db, interval_mins: u64) {
|
||||
pub async fn run_aggregator(
|
||||
db: Db,
|
||||
interval_mins: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Analytics aggregator shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background analytics aggregator...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
@@ -4,7 +4,11 @@ use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
pub async fn run_backup_scheduler(
|
||||
db: Db,
|
||||
config: Config,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
if !config.backup_enabled {
|
||||
info!("Background backup scheduler is disabled.");
|
||||
return;
|
||||
@@ -16,7 +20,13 @@ pub async fn run_backup_scheduler(db: Db, config: Config) {
|
||||
);
|
||||
loop {
|
||||
// Run backup every configured interval
|
||||
tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(config.backup_interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Backup scheduler shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background database backup...");
|
||||
|
||||
let job_id = log_job_start(&db.system, "database_backup");
|
||||
|
||||
@@ -1,33 +1,73 @@
|
||||
use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::info;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Background job that marks expired URLs.
|
||||
///
|
||||
/// Runs every 60 seconds. Any URL with `expires_at < NOW()` and `expired = 0`
|
||||
/// gets flipped to `expired = 1`.
|
||||
pub async fn run_expiry_checker(db: Db) {
|
||||
///
|
||||
/// Correctness note: the redirect handler treats wall-clock `expires_at` as
|
||||
/// authoritative and returns 410 without depending on this sweeper. The sweeper
|
||||
/// is maintenance (persist `expired=1`) and must remain idempotent.
|
||||
pub async fn run_expiry_checker(db: Db, mut shutdown_rx: tokio::sync::watch::Receiver<bool>) {
|
||||
loop {
|
||||
tokio::time::sleep(Duration::from_secs(60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Expiry checker shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
let conn = match db.users.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: users_db mutex poisoned");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let mut stmt = match conn.prepare("SELECT id FROM users;") {
|
||||
Ok(s) => s,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: failed to list users");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let rows = match stmt.query_map([], |row| row.get(0)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => continue,
|
||||
Err(e) => {
|
||||
error!(error = %e, "expiry job: failed to map user ids");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let mut total_expired = 0;
|
||||
for user_id in user_ids {
|
||||
if let Ok(conn) = super::open_user_content_conn(&db, user_id) {
|
||||
let count = crate::db::content::expire_urls(&conn).unwrap_or(0);
|
||||
total_expired += count;
|
||||
match super::open_user_content_conn(&db, user_id) {
|
||||
Ok(conn) => match crate::db::content::expire_urls(&conn) {
|
||||
Ok(count) => total_expired += count,
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"expiry job: expire_urls failed"
|
||||
);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
// Missing content.db for a user is common; only log open errors that are unexpected.
|
||||
if !matches!(e, rusqlite::Error::SqliteFailure(_, _)) {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"expiry job: could not open content.db"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,11 @@ use uuid::Uuid;
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
pub async fn run_link_checker(
|
||||
db: Db,
|
||||
interval_mins: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let client = Client::builder()
|
||||
.timeout(Duration::from_secs(10))
|
||||
.user_agent("bzod-link-checker/0.1")
|
||||
@@ -18,7 +22,14 @@ pub async fn run_link_checker(db: Db, interval_mins: u64) {
|
||||
|
||||
loop {
|
||||
// Sleep first to give server time to start up
|
||||
tokio::time::sleep(Duration::from_secs(interval_mins * 60)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_mins * 60)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Link checker shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
info!("Running background link health check...");
|
||||
|
||||
let job_id = log_job_start(&db.system, "link_checker");
|
||||
|
||||
@@ -2,10 +2,20 @@ use crate::db::Db;
|
||||
use std::time::Duration;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run_quota_reconciliation(db: Db, interval_hours: u64) {
|
||||
pub async fn run_quota_reconciliation(
|
||||
db: Db,
|
||||
interval_hours: u64,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
loop {
|
||||
// Sleep first
|
||||
tokio::time::sleep(Duration::from_secs(interval_hours * 3600)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(interval_hours * 3600)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Quota reconciliation shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background quota reconciliation...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
@@ -4,7 +4,11 @@ use tracing::{error, info};
|
||||
use super::{log_job_end, log_job_start};
|
||||
use crate::db::Db;
|
||||
|
||||
pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
pub async fn run_retention_cleaner(
|
||||
db: Db,
|
||||
retention_days_opt: Option<i64>,
|
||||
mut shutdown_rx: tokio::sync::watch::Receiver<bool>,
|
||||
) {
|
||||
let retention_days = match retention_days_opt {
|
||||
Some(days) => days,
|
||||
None => return,
|
||||
@@ -12,7 +16,13 @@ pub async fn run_retention_cleaner(db: Db, retention_days_opt: Option<i64>) {
|
||||
|
||||
loop {
|
||||
// Check once every 24 hours
|
||||
tokio::time::sleep(Duration::from_secs(24 * 3600)).await;
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(24 * 3600)) => {}
|
||||
_ = shutdown_rx.changed() => {
|
||||
info!("Retention cleaner shutting down...");
|
||||
break;
|
||||
}
|
||||
}
|
||||
info!("Running background data retention cleanup...");
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod build_info;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
|
||||
@@ -38,9 +38,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Commands::Validate { data_dir } => {
|
||||
bzod::cli::validate::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::AuditDestinations { data_dir } => {
|
||||
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateAdmin { username, data_dir } => {
|
||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||
}
|
||||
Commands::InitAdmin { data_dir } => {
|
||||
bzod::cli::init_admin::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::Doctor { data_dir } => {
|
||||
bzod::cli::doctor::run(data_dir, config).await?;
|
||||
}
|
||||
@@ -103,6 +109,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
bzod::cli::admin_migrate::run(target_admin_id, data_dir, dry_run, force, config)
|
||||
.await?;
|
||||
}
|
||||
Commands::Repair { command } => {
|
||||
bzod::cli::repair::run(command, config).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
//! Post-restore filesystem layout normalization for multi-tenant BZOD data dirs.
|
||||
//!
|
||||
//! Extracted from admin restore handlers so path moves are testable without HTTP.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
|
||||
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
|
||||
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
|
||||
let admin_dir = data_dir.join("admin");
|
||||
let users_1_dir = data_dir.join("users").join("1");
|
||||
std::fs::create_dir_all(&admin_dir)?;
|
||||
std::fs::create_dir_all(&users_1_dir)?;
|
||||
|
||||
let admin_files = [
|
||||
"admin.db",
|
||||
"admin.db-wal",
|
||||
"admin.db-shm",
|
||||
"system.db",
|
||||
"system.db-wal",
|
||||
"system.db-shm",
|
||||
"users.db",
|
||||
"users.db-wal",
|
||||
"users.db-shm",
|
||||
];
|
||||
for f in admin_files {
|
||||
let src = data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = admin_dir.join(f);
|
||||
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||
warn!(
|
||||
file = f,
|
||||
error = %e,
|
||||
"failed to move restored admin file into admin/"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let content_files = [
|
||||
"content.db",
|
||||
"content.db-wal",
|
||||
"content.db-shm",
|
||||
"analytics.db",
|
||||
"analytics.db-wal",
|
||||
"analytics.db-shm",
|
||||
];
|
||||
for f in content_files {
|
||||
let src = data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = users_1_dir.join(f);
|
||||
if let Err(e) = std::fs::rename(&src, &dst) {
|
||||
warn!(
|
||||
file = f,
|
||||
error = %e,
|
||||
"failed to move restored content file into users/1/"
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Paths used when reopening connections after restore.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RestoredDbPaths {
|
||||
pub admin: PathBuf,
|
||||
pub system: PathBuf,
|
||||
pub users: PathBuf,
|
||||
pub content: PathBuf,
|
||||
pub analytics: PathBuf,
|
||||
}
|
||||
|
||||
impl RestoredDbPaths {
|
||||
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||
Self {
|
||||
admin: data_dir.join("admin/admin.db"),
|
||||
system: data_dir.join("admin/system.db"),
|
||||
users: data_dir.join("admin/users.db"),
|
||||
content: data_dir.join("users/1/content.db"),
|
||||
analytics: data_dir.join("users/1/analytics.db"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn moves_flat_files_into_tenant_layout() {
|
||||
let dir = std::env::temp_dir().join(format!("bzod_layout_{}", uuid::Uuid::new_v4()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
fs::write(dir.join("admin.db"), b"a").unwrap();
|
||||
fs::write(dir.join("system.db"), b"s").unwrap();
|
||||
fs::write(dir.join("users.db"), b"u").unwrap();
|
||||
fs::write(dir.join("content.db"), b"c").unwrap();
|
||||
fs::write(dir.join("analytics.db"), b"an").unwrap();
|
||||
|
||||
normalize_restored_layout(&dir).unwrap();
|
||||
|
||||
assert!(dir.join("admin/admin.db").exists());
|
||||
assert!(dir.join("admin/system.db").exists());
|
||||
assert!(dir.join("admin/users.db").exists());
|
||||
assert!(dir.join("users/1/content.db").exists());
|
||||
assert!(dir.join("users/1/analytics.db").exists());
|
||||
assert!(!dir.join("admin.db").exists());
|
||||
assert!(!dir.join("content.db").exists());
|
||||
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
//! Bulk URL creation business logic (transaction + slug reservation).
|
||||
//!
|
||||
//! Handlers own auth/HTTP; this module owns validation, reservation, and inserts.
|
||||
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::models::Url;
|
||||
use crate::utils::validation::validate_redirect_destination;
|
||||
use rusqlite::{Connection, Transaction};
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// One item in a bulk URL create request (mirrors the HTTP payload shape).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BulkUrlCreateItem {
|
||||
pub destination: String,
|
||||
pub code: Option<String>,
|
||||
pub title: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub expires_at: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub max_access_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BulkUrlError {
|
||||
BadRequest(String),
|
||||
Conflict(String),
|
||||
Forbidden(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl BulkUrlError {
|
||||
pub fn message(&self) -> &str {
|
||||
match self {
|
||||
Self::BadRequest(m) | Self::Conflict(m) | Self::Forbidden(m) | Self::Internal(m) => m,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
|
||||
for slug in slugs {
|
||||
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Check that the tenant can accept `additional` new URLs.
|
||||
pub fn ensure_url_quota(
|
||||
users_db: &Mutex<Connection>,
|
||||
user_id: i64,
|
||||
additional: i64,
|
||||
) -> Result<(), BulkUrlError> {
|
||||
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
match crate::db::users::get_user_quotas(&users_conn, user_id) {
|
||||
Ok(Some(quotas)) => {
|
||||
if quotas.current_urls + additional > quotas.max_urls {
|
||||
Err(BulkUrlError::Forbidden("Quota limit exceeded".into()))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
Ok(None) => Err(BulkUrlError::Forbidden("User quota not found".into())),
|
||||
Err(e) => Err(BulkUrlError::Internal(format!("quota lookup failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create many URLs inside a single content transaction with global slug reservation.
|
||||
pub fn create_urls_bulk(
|
||||
content_db: &Mutex<Connection>,
|
||||
system_db: &Mutex<Connection>,
|
||||
users_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
items: Vec<BulkUrlCreateItem>,
|
||||
) -> Result<Vec<Url>, BulkUrlError> {
|
||||
let mut conn = crate::utils::lock_db(content_db, "content_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let tx = conn.transaction().map_err(|e| {
|
||||
BulkUrlError::Internal(format!("Failed to start database transaction: {e}"))
|
||||
})?;
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
match create_one_in_tx(&tx, system_db, owner_user_id, item, &mut reserved_slugs) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
}
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to commit transaction: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for url in &created_urls {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increment quota counters
|
||||
{
|
||||
let users_conn = crate::utils::lock_db(users_db, "users_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for _ in 0..created_urls.len() {
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, owner_user_id, "urls");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(created_urls)
|
||||
}
|
||||
|
||||
fn create_one_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
system_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
item: BulkUrlCreateItem,
|
||||
reserved_slugs: &mut Vec<String>,
|
||||
) -> Result<Url, BulkUrlError> {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Short code '{code}' must be 6 hex characters"
|
||||
)));
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
if !available {
|
||||
return Err(BulkUrlError::Conflict(format!(
|
||||
"Short code '{code}' already exists"
|
||||
)));
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
owner_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to reserve slug '{code}': {e}"
|
||||
)));
|
||||
}
|
||||
reserved_slugs.push(code.clone());
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Password hashing error: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if !validate_redirect_destination(&item.destination) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Invalid destination for item '{code}': must be a valid http(s) URL without control characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
crate::db::content::create_url_extended(
|
||||
tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
)
|
||||
.map_err(|e| BulkUrlError::Internal(format!("Database insert error: {e}")))
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
//! Read-only audit of stored redirect destinations.
|
||||
//!
|
||||
//! Scans tenant content databases and classifies each `urls.destination` using
|
||||
//! the same rules as write-path validation. Never rewrites or deletes data.
|
||||
|
||||
use crate::db::Db;
|
||||
use crate::utils::validation::{classify_redirect_destination, DestinationClass};
|
||||
use rusqlite::Connection;
|
||||
use std::path::Path;
|
||||
use tracing::{error, info, warn};
|
||||
|
||||
/// Summary counters for a destination audit run.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct DestinationAuditReport {
|
||||
pub scanned_users: usize,
|
||||
pub total_urls: usize,
|
||||
pub valid_http: usize,
|
||||
pub valid_https: usize,
|
||||
pub invalid: usize,
|
||||
pub control_characters: usize,
|
||||
pub unsupported_scheme: usize,
|
||||
pub malformed: usize,
|
||||
pub empty: usize,
|
||||
pub too_long: usize,
|
||||
pub non_ascii: usize,
|
||||
/// Safe sample of invalid records: (owner_user_id, code, class_label).
|
||||
/// Destination bodies are never included (may contain control chars / secrets).
|
||||
pub invalid_samples: Vec<InvalidDestinationSample>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InvalidDestinationSample {
|
||||
pub owner_user_id: i64,
|
||||
pub code: String,
|
||||
pub url_id: String,
|
||||
pub class: &'static str,
|
||||
pub destination_len: usize,
|
||||
}
|
||||
|
||||
const MAX_SAMPLES: usize = 50;
|
||||
|
||||
fn class_label(c: DestinationClass) -> &'static str {
|
||||
match c {
|
||||
DestinationClass::ValidHttp => "valid_http",
|
||||
DestinationClass::ValidHttps => "valid_https",
|
||||
DestinationClass::Empty => "empty",
|
||||
DestinationClass::TooLong => "too_long",
|
||||
DestinationClass::ControlCharacters => "control_characters",
|
||||
DestinationClass::NonAscii => "non_ascii",
|
||||
DestinationClass::UnsupportedScheme => "unsupported_scheme",
|
||||
DestinationClass::Malformed => "malformed",
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a single destination and update report counters.
|
||||
pub fn record_destination(
|
||||
report: &mut DestinationAuditReport,
|
||||
owner_user_id: i64,
|
||||
code: &str,
|
||||
url_id: &str,
|
||||
destination: &str,
|
||||
) {
|
||||
report.total_urls += 1;
|
||||
let class = classify_redirect_destination(destination);
|
||||
match class {
|
||||
DestinationClass::ValidHttp => report.valid_http += 1,
|
||||
DestinationClass::ValidHttps => report.valid_https += 1,
|
||||
DestinationClass::Empty => {
|
||||
report.empty += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::TooLong => {
|
||||
report.too_long += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::ControlCharacters => {
|
||||
report.control_characters += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::NonAscii => {
|
||||
report.non_ascii += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::UnsupportedScheme => {
|
||||
report.unsupported_scheme += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
DestinationClass::Malformed => {
|
||||
report.malformed += 1;
|
||||
report.invalid += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if !class.is_valid() && report.invalid_samples.len() < MAX_SAMPLES {
|
||||
report.invalid_samples.push(InvalidDestinationSample {
|
||||
owner_user_id,
|
||||
code: code.to_string(),
|
||||
url_id: url_id.to_string(),
|
||||
class: class_label(class),
|
||||
destination_len: destination.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan one content database connection for URL destinations.
|
||||
pub fn audit_content_conn(
|
||||
conn: &Connection,
|
||||
owner_user_id: i64,
|
||||
report: &mut DestinationAuditReport,
|
||||
) -> rusqlite::Result<()> {
|
||||
let mut stmt = conn.prepare("SELECT id, code, destination FROM urls;")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (id, code, destination) = row?;
|
||||
record_destination(report, owner_user_id, &code, &id, &destination);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn open_user_content(data_dir: &Path, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||
let path = data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Audit all tenant content databases found under the configured data directory.
|
||||
///
|
||||
/// Read-only: does not modify any records.
|
||||
pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String> {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
|
||||
let user_ids: Vec<i64> = {
|
||||
let users = db
|
||||
.users
|
||||
.lock()
|
||||
.map_err(|e| format!("users_db lock poisoned: {}", e))?;
|
||||
let mut stmt = users
|
||||
.prepare("SELECT id FROM users;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for user_id in user_ids {
|
||||
match open_user_content(&db.data_dir, user_id) {
|
||||
Ok(conn) => {
|
||||
report.scanned_users += 1;
|
||||
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
|
||||
error!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"destination audit failed for user content.db"
|
||||
);
|
||||
return Err(format!("audit user {} content.db: {}", user_id, e));
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::InvalidPath(_)) => {
|
||||
// User has no content DB yet — skip.
|
||||
}
|
||||
Err(e) => {
|
||||
warn!(
|
||||
owner_user_id = user_id,
|
||||
error = %e,
|
||||
"could not open user content.db for destination audit"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
info!(
|
||||
total_urls = report.total_urls,
|
||||
valid = report.valid_http + report.valid_https,
|
||||
invalid = report.invalid,
|
||||
"destination audit complete"
|
||||
);
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Format a human-readable report for CLI output.
|
||||
pub fn format_report(report: &DestinationAuditReport) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("BZOD Redirect Destination Audit (read-only)\n");
|
||||
out.push_str("===========================================\n");
|
||||
out.push_str(&format!("Users scanned: {}\n", report.scanned_users));
|
||||
out.push_str(&format!("Total URLs: {}\n", report.total_urls));
|
||||
out.push_str(&format!("Valid HTTP: {}\n", report.valid_http));
|
||||
out.push_str(&format!("Valid HTTPS: {}\n", report.valid_https));
|
||||
out.push_str(&format!("Invalid (total): {}\n", report.invalid));
|
||||
out.push_str(&format!(
|
||||
" control characters: {}\n",
|
||||
report.control_characters
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" unsupported scheme: {}\n",
|
||||
report.unsupported_scheme
|
||||
));
|
||||
out.push_str(&format!(" malformed: {}\n", report.malformed));
|
||||
out.push_str(&format!(" empty: {}\n", report.empty));
|
||||
out.push_str(&format!(" too long: {}\n", report.too_long));
|
||||
out.push_str(&format!(" non-ascii: {}\n", report.non_ascii));
|
||||
|
||||
if !report.invalid_samples.is_empty() {
|
||||
out.push_str("\nInvalid samples (id/code only; destinations not printed):\n");
|
||||
for s in &report.invalid_samples {
|
||||
out.push_str(&format!(
|
||||
" user={} code={} id={} class={} dest_len={}\n",
|
||||
s.owner_user_id, s.code, s.url_id, s.class, s.destination_len
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn records_control_character_destination() {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
record_destination(
|
||||
&mut report,
|
||||
1,
|
||||
"ab12cd",
|
||||
"id-1",
|
||||
"https://evil.example/\r\nX:1",
|
||||
);
|
||||
assert_eq!(report.total_urls, 1);
|
||||
assert_eq!(report.invalid, 1);
|
||||
assert_eq!(report.control_characters, 1);
|
||||
assert_eq!(report.invalid_samples.len(), 1);
|
||||
assert_eq!(report.invalid_samples[0].class, "control_characters");
|
||||
// Ensure we never store the destination body in the sample.
|
||||
assert!(!format!("{:?}", report.invalid_samples[0]).contains("evil"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_valid_https() {
|
||||
let mut report = DestinationAuditReport::default();
|
||||
record_destination(&mut report, 1, "ab12cd", "id-1", "https://example.com/ok");
|
||||
assert_eq!(report.valid_https, 1);
|
||||
assert_eq!(report.invalid, 0);
|
||||
assert!(report.invalid_samples.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,12 @@
|
||||
pub mod api_keys;
|
||||
pub mod audit;
|
||||
pub mod backup_layout;
|
||||
pub mod bulk;
|
||||
pub mod bulk_urls;
|
||||
pub mod destination_audit;
|
||||
pub mod landing_pages;
|
||||
pub mod qr;
|
||||
pub mod registry_validator;
|
||||
pub mod shortener;
|
||||
pub mod slug_transfer;
|
||||
pub mod urls;
|
||||
@@ -0,0 +1,285 @@
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum RegistryIssueType {
|
||||
DuplicateSlug,
|
||||
InvalidTargetType,
|
||||
InvalidStatus,
|
||||
MissingOwner,
|
||||
MissingDatabase,
|
||||
MissingTarget,
|
||||
StaleReservation,
|
||||
TenantAdminHasIsolatedContent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryIssue {
|
||||
pub slug: String,
|
||||
pub target_type: String,
|
||||
pub owner_user_id: i64,
|
||||
pub database_path: PathBuf,
|
||||
pub target_id: String,
|
||||
pub issue_type: RegistryIssueType,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub struct RegistryValidator;
|
||||
|
||||
impl RegistryValidator {
|
||||
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||
pub fn scan(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
data_dir: &Path,
|
||||
slug_filter: Option<&str>,
|
||||
) -> Result<Vec<RegistryIssue>, Box<dyn std::error::Error>> {
|
||||
use chrono::{DateTime, Utc};
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. Check duplicate slugs (only if not filtering by single slug)
|
||||
if slug_filter.is_none() {
|
||||
let total_count: i64 =
|
||||
system_conn.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
let distinct_count: i64 = system_conn.query_row(
|
||||
"SELECT COUNT(DISTINCT slug) FROM global_slugs;",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if total_count != distinct_count {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: 0,
|
||||
database_path: data_dir.join("admin/system.db"),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::DuplicateSlug,
|
||||
description: format!(
|
||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||
total_count, distinct_count
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Scan global slugs
|
||||
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||
vec![slug.to_string()],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = system_conn.prepare(query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_user_id: i64 = row.get(1)?;
|
||||
let target_type: String = row.get(2)?;
|
||||
let target_id: String = row.get(3)?;
|
||||
let created_at_str: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
data_dir.join("users").join("1").join("content.db")
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
};
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidTargetType,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidStatus,
|
||||
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
||||
});
|
||||
}
|
||||
|
||||
// Check owner
|
||||
let owner_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[owner_user_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !owner_exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingOwner,
|
||||
description: format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
if status == "reserving" {
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::StaleReservation,
|
||||
description: format!(
|
||||
"Reserving slug '{}' has been stale for over 15 minutes",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
if !content_db_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database does not exist at {:?}",
|
||||
slug, content_db_path
|
||||
),
|
||||
});
|
||||
} else {
|
||||
match Connection::open(&content_db_path) {
|
||||
Ok(conn) => {
|
||||
let exists = if target_type == "url" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else if target_type == "page" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if !exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTarget,
|
||||
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database could not be opened: {}",
|
||||
slug, e
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Admin Content Reverse Consistency Check (Legacy DB)
|
||||
// Check if tenant databases contain content for admin users incorrectly (isolated admin content)
|
||||
if slug_filter.is_none() {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username FROM users WHERE account_type = 'admin' AND id != 1;",
|
||||
)?;
|
||||
let mut admin_rows = stmt.query([])?;
|
||||
while let Some(row) = admin_rows.next()? {
|
||||
let id: i64 = row.get(0)?;
|
||||
let username: String = row.get(1)?;
|
||||
let tenant_db_path = data_dir
|
||||
.join("users")
|
||||
.join(id.to_string())
|
||||
.join("content.db");
|
||||
|
||||
if tenant_db_path.exists() {
|
||||
if let Ok(tenant_conn) = Connection::open(&tenant_db_path) {
|
||||
let url_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM urls;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
let page_count: i64 = tenant_conn
|
||||
.query_row("SELECT COUNT(*) FROM landing_pages;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if url_count > 0 || page_count > 0 {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: id,
|
||||
database_path: tenant_db_path.clone(),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::TenantAdminHasIsolatedContent,
|
||||
description: format!("Admin user '{}' (ID {}) has content in isolated tenant DB ({} URLs, {} pages). Admin content should be in legacy DB 1.", username, id, url_count, page_count),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,24 @@ pub fn create_url(
|
||||
description: Option<&str>,
|
||||
tags: &[String],
|
||||
) -> Result<Url, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Destination must be a valid http(s) URL without control characters".into(),
|
||||
));
|
||||
}
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::create_url(&conn, code, destination, title, description, tags)?;
|
||||
Ok(url)
|
||||
}
|
||||
|
||||
pub fn get_url_by_code(db: &Db, code: &str) -> Result<Option<Url>, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let conn = db
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
||||
Ok(url)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
//! Cross-tenant slug transfer business logic.
|
||||
//!
|
||||
//! Copies URL/page content between tenant content DBs, then updates global_slugs
|
||||
//! ownership. Handlers own admin auth and HTTP mapping.
|
||||
|
||||
use crate::state::{AppState, UserDbs};
|
||||
use crate::utils::lock_db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransferError {
|
||||
NotFound(&'static str),
|
||||
BadRequest(String),
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl TransferError {
|
||||
pub fn message(&self) -> String {
|
||||
match self {
|
||||
Self::NotFound(m) => (*m).to_string(),
|
||||
Self::BadRequest(m) | Self::Internal(m) => m.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlugTransferRequest {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct SlugTransferResult {
|
||||
pub old_owner_user_id: i64,
|
||||
pub new_owner_user_id: i64,
|
||||
pub target_type: String,
|
||||
pub new_target_id: String,
|
||||
}
|
||||
|
||||
/// Look up slug ownership in `global_slugs`.
|
||||
pub fn lookup_slug(state: &AppState, slug: &str) -> Result<(i64, String, String), TransferError> {
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let mut stmt = system_conn
|
||||
.prepare("SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let row_opt = stmt
|
||||
.query_row([slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
match row_opt {
|
||||
Some(r) => Ok(r),
|
||||
None => Err(TransferError::NotFound("Slug not found")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy content row between tenants and return the new target id.
|
||||
fn copy_content(
|
||||
state: &AppState,
|
||||
old_dbs: &UserDbs,
|
||||
new_dbs: &UserDbs,
|
||||
slug: &str,
|
||||
target_type: &str,
|
||||
new_owner_user_id: i64,
|
||||
) -> Result<String, TransferError> {
|
||||
let old_conn = lock_db(&old_dbs.content, "old_content_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let new_conn = lock_db(&new_dbs.content, "new_content_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
if target_type == "url" {
|
||||
let url = match crate::db::content::get_url_by_code(&old_conn, slug) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => {
|
||||
return Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||
};
|
||||
|
||||
{
|
||||
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
if let Ok(Some(quota)) =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||
{
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner has exceeded URL quota limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_url = crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to copy URL to new owner: {e}")))?;
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
Ok(new_url.id)
|
||||
} else if target_type == "page" {
|
||||
let page = match crate::db::content::get_landing_page_by_code(&old_conn, slug) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => {
|
||||
return Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
Err(e) => return Err(TransferError::Internal(e.to_string())),
|
||||
};
|
||||
|
||||
{
|
||||
let new_users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
if let Ok(Some(quota)) =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, new_owner_user_id)
|
||||
{
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner has exceeded landing page quota limit".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let new_page = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to copy Page to new owner: {e}")))?;
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
Ok(new_page.id)
|
||||
} else {
|
||||
Err(TransferError::NotFound(
|
||||
"Content not found in owner database",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a full slug transfer (content + registry + quotas + history).
|
||||
pub fn transfer_slug(
|
||||
state: &AppState,
|
||||
req: &SlugTransferRequest,
|
||||
admin_username: &str,
|
||||
) -> Result<SlugTransferResult, TransferError> {
|
||||
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
|
||||
|
||||
if old_owner_user_id == req.new_owner_user_id {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner must be different from the current owner".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let old_dbs = state
|
||||
.get_user_dbs(old_owner_user_id)
|
||||
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
|
||||
let new_dbs = state
|
||||
.get_user_dbs(req.new_owner_user_id)
|
||||
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
|
||||
|
||||
let new_target_id = copy_content(
|
||||
state,
|
||||
&old_dbs,
|
||||
&new_dbs,
|
||||
&req.slug,
|
||||
&target_type,
|
||||
req.new_owner_user_id,
|
||||
)?;
|
||||
|
||||
{
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![req.new_owner_user_id, new_target_id, now, req.slug],
|
||||
);
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![
|
||||
req.slug,
|
||||
old_owner_user_id,
|
||||
req.new_owner_user_id,
|
||||
now,
|
||||
admin_username
|
||||
],
|
||||
);
|
||||
|
||||
let users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let field = if target_type == "url" {
|
||||
"urls"
|
||||
} else {
|
||||
"landings"
|
||||
};
|
||||
let _ = crate::db::users::decrement_quota_counter(&users_conn, old_owner_user_id, field);
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, req.new_owner_user_id, field);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
admin_username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&req.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, req.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SlugTransferResult {
|
||||
old_owner_user_id,
|
||||
new_owner_user_id: req.new_owner_user_id,
|
||||
target_type,
|
||||
new_target_id,
|
||||
})
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ pub struct AppState {
|
||||
|
||||
impl AppState {
|
||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||
let mut pool = self.user_dbs.lock().unwrap();
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
if let Some(dbs) = pool.get(&user_id) {
|
||||
return Ok(dbs.clone());
|
||||
}
|
||||
@@ -87,12 +87,12 @@ impl AppState {
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
pub fn db_compact(&self) -> Result<(), rusqlite::Error> {
|
||||
self.admin_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.content_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.analytics_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.system_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
self.users_db.lock().unwrap().execute("VACUUM;", [])?;
|
||||
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
|
||||
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.content_db, "content_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.analytics_db, "analytics_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.system_db, "system_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.users_db, "users_db")?.execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -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 network;
|
||||
pub mod random;
|
||||
@@ -5,8 +6,9 @@ pub mod system;
|
||||
pub mod time;
|
||||
pub mod validation;
|
||||
|
||||
pub use db_lock::{lock_db, lock_db_str};
|
||||
pub use hashing::sha256_hash;
|
||||
pub use network::get_client_ip;
|
||||
pub use network::{get_client_ip, resolve_cookie_secure};
|
||||
pub use random::generate_token;
|
||||
pub use system::{get_db_file_info, get_memory_usage};
|
||||
pub use time::format_duration;
|
||||
@@ -22,3 +22,119 @@ pub fn get_client_ip(headers: &HeaderMap, connect_info: Option<ConnectInfo<Socke
|
||||
}
|
||||
"127.0.0.1".to_string()
|
||||
}
|
||||
|
||||
// Helper to safely extract hostname from a Host header, handling IPv6 and ports.
|
||||
pub(crate) fn extract_hostname(host_header: &str) -> &str {
|
||||
if host_header.starts_with('[') {
|
||||
if let Some(end_idx) = host_header.find(']') {
|
||||
return &host_header[1..end_idx];
|
||||
}
|
||||
}
|
||||
host_header.split(':').next().unwrap_or(host_header)
|
||||
}
|
||||
|
||||
/// Determines whether to set the `Secure` flag on a cookie based on deployment context.
|
||||
///
|
||||
/// Policy:
|
||||
/// 1. If X-Forwarded-Proto is explicitly "https", always enforce Secure=true.
|
||||
/// (We assume X-Forwarded-Proto is from a trusted proxy. Forged "https" only makes
|
||||
/// the cookie safer. We never weaken based on X-Forwarded-Proto=http).
|
||||
/// 2. If the exact Host is a local loopback (localhost, 127.0.0.1, ::1) and we are not
|
||||
/// explicitly proxied via HTTPS, disable Secure. This prevents browsers from dropping
|
||||
/// the cookie during local development over cleartext HTTP.
|
||||
/// 3. Otherwise, fall back to the global `cookie_secure` config (which defaults to true
|
||||
/// to keep production secure-by-default even if the proxy strips X-Forwarded-Proto).
|
||||
pub fn resolve_cookie_secure(config_secure: bool, headers: &HeaderMap) -> bool {
|
||||
// 1. Explicit HTTPS via reverse proxy
|
||||
if let Some(proto) = headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if proto.eq_ignore_ascii_case("https") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Exact loopback development (prevent cookie drop)
|
||||
if let Some(host_hdr) = headers.get("host").and_then(|h| h.to_str().ok()) {
|
||||
let hostname = extract_hostname(host_hdr);
|
||||
if matches!(hostname, "localhost" | "127.0.0.1" | "::1") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Global config (normally true)
|
||||
config_secure
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::HeaderValue;
|
||||
|
||||
#[test]
|
||||
fn test_extract_hostname() {
|
||||
assert_eq!(extract_hostname("localhost"), "localhost");
|
||||
assert_eq!(extract_hostname("localhost:8080"), "localhost");
|
||||
assert_eq!(extract_hostname("127.0.0.1"), "127.0.0.1");
|
||||
assert_eq!(extract_hostname("127.0.0.1:8080"), "127.0.0.1");
|
||||
assert_eq!(extract_hostname("[::1]"), "::1");
|
||||
assert_eq!(extract_hostname("[::1]:8080"), "::1");
|
||||
assert_eq!(extract_hostname("example.com"), "example.com");
|
||||
assert_eq!(extract_hostname("example.com:443"), "example.com");
|
||||
assert_eq!(
|
||||
extract_hostname("localhost.example.com"),
|
||||
"localhost.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_cookie_secure() {
|
||||
let mut headers = HeaderMap::new();
|
||||
|
||||
// No headers, global config is true -> Secure=true
|
||||
assert!(resolve_cookie_secure(true, &headers));
|
||||
// No headers, global config is false -> Secure=false
|
||||
assert!(!resolve_cookie_secure(false, &headers));
|
||||
|
||||
// Exact loopback matches -> Secure=false
|
||||
let loopback_hosts = [
|
||||
"localhost",
|
||||
"localhost:8080",
|
||||
"127.0.0.1",
|
||||
"127.0.0.1:8080",
|
||||
"[::1]",
|
||||
"[::1]:8080",
|
||||
];
|
||||
for host in loopback_hosts {
|
||||
headers.insert("host", HeaderValue::from_static(host));
|
||||
assert!(
|
||||
!resolve_cookie_secure(true, &headers),
|
||||
"Failed for host: {}",
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
// Non-loopback localhost subdomains -> Secure=true (relying on config)
|
||||
let public_hosts = ["localhost.example.com", "127.0.0.2", "example.com"];
|
||||
for host in public_hosts {
|
||||
headers.insert("host", HeaderValue::from_static(host));
|
||||
assert!(
|
||||
resolve_cookie_secure(true, &headers),
|
||||
"Failed for host: {}",
|
||||
host
|
||||
);
|
||||
}
|
||||
|
||||
// X-Forwarded-Proto = https overrides loopback
|
||||
headers.insert("host", HeaderValue::from_static("localhost"));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("https"));
|
||||
assert!(resolve_cookie_secure(true, &headers));
|
||||
assert!(resolve_cookie_secure(false, &headers)); // Overrides false config too
|
||||
|
||||
// X-Forwarded-Proto = http DOES NOT override global config
|
||||
headers.insert("host", HeaderValue::from_static("example.com"));
|
||||
headers.insert("x-forwarded-proto", HeaderValue::from_static("http"));
|
||||
assert!(resolve_cookie_secure(true, &headers)); // Still true because of config
|
||||
}
|
||||
}
|
||||
@@ -17,3 +17,144 @@ pub fn validate_redirect_code(code: &str) -> bool {
|
||||
pub fn validate_page_code(code: &str) -> bool {
|
||||
(code.len() == 4 && code.chars().all(|c| c.is_ascii_hexdigit())) || validate_custom_slug(code)
|
||||
}
|
||||
|
||||
/// Classification of a stored or proposed redirect destination.
|
||||
///
|
||||
/// Used by write-path validation and read-only legacy data audits. Order of checks
|
||||
/// matches `validate_redirect_destination` so both share the same rules.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DestinationClass {
|
||||
ValidHttp,
|
||||
ValidHttps,
|
||||
Empty,
|
||||
TooLong,
|
||||
ControlCharacters,
|
||||
NonAscii,
|
||||
UnsupportedScheme,
|
||||
Malformed,
|
||||
}
|
||||
|
||||
impl DestinationClass {
|
||||
pub fn is_valid(self) -> bool {
|
||||
matches!(self, Self::ValidHttp | Self::ValidHttps)
|
||||
}
|
||||
}
|
||||
|
||||
fn scheme_prefix(dest: &str) -> Option<&str> {
|
||||
let end = dest.find(':')?;
|
||||
let scheme = &dest[..end];
|
||||
if scheme.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if scheme
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
|
||||
{
|
||||
Some(scheme)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a destination using the same rules as write-path validation.
|
||||
pub fn classify_redirect_destination(destination: &str) -> DestinationClass {
|
||||
let dest = destination.trim();
|
||||
if dest.is_empty() {
|
||||
return DestinationClass::Empty;
|
||||
}
|
||||
if dest.len() > 2048 {
|
||||
return DestinationClass::TooLong;
|
||||
}
|
||||
// HTTP header / response-splitting: no CR, LF, NUL, or other ASCII controls.
|
||||
if dest.bytes().any(|b| b < 0x20 || b == 0x7f) {
|
||||
return DestinationClass::ControlCharacters;
|
||||
}
|
||||
// HeaderValue also rejects non-visible ASCII in some cases; require pure ASCII.
|
||||
if !dest.is_ascii() {
|
||||
return DestinationClass::NonAscii;
|
||||
}
|
||||
// Reject non-http(s) schemes even when Url::parse fails (e.g. javascript:).
|
||||
if let Some(scheme) = scheme_prefix(dest) {
|
||||
let scheme_l = scheme.to_ascii_lowercase();
|
||||
if scheme_l != "http" && scheme_l != "https" {
|
||||
return DestinationClass::UnsupportedScheme;
|
||||
}
|
||||
}
|
||||
match reqwest::Url::parse(dest) {
|
||||
Ok(url) => {
|
||||
if url.host_str().is_none() {
|
||||
return DestinationClass::Malformed;
|
||||
}
|
||||
match url.scheme() {
|
||||
"http" => DestinationClass::ValidHttp,
|
||||
"https" => DestinationClass::ValidHttps,
|
||||
_ => DestinationClass::UnsupportedScheme,
|
||||
}
|
||||
}
|
||||
Err(_) => DestinationClass::Malformed,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `destination` is safe to store and emit as an HTTP Location value.
|
||||
///
|
||||
/// Rejects CR/LF and other ASCII control characters (response-splitting), empty values,
|
||||
/// and non-http(s) schemes. The redirect handler remains defensive even if invalid
|
||||
/// values already exist in older data.
|
||||
pub fn validate_redirect_destination(destination: &str) -> bool {
|
||||
classify_redirect_destination(destination).is_valid()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_crlf_destination() {
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\r\nX-Injected: 1"
|
||||
));
|
||||
assert!(!validate_redirect_destination(
|
||||
"https://example.com/\nX-Injected: 1"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_http_schemes() {
|
||||
assert!(!validate_redirect_destination("javascript:alert(1)"));
|
||||
assert!(!validate_redirect_destination("data:text/html,hi"));
|
||||
assert!(!validate_redirect_destination("/relative/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_normal_https() {
|
||||
assert!(validate_redirect_destination(
|
||||
"https://example.com/path?q=1#frag"
|
||||
));
|
||||
assert!(validate_redirect_destination("http://localhost:8080/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_destination_categories() {
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://ok.example/"),
|
||||
DestinationClass::ValidHttps
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("http://ok.example/"),
|
||||
DestinationClass::ValidHttp
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("javascript:alert(1)"),
|
||||
DestinationClass::UnsupportedScheme
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("https://x/\r\nX:1"),
|
||||
DestinationClass::ControlCharacters
|
||||
);
|
||||
assert_eq!(
|
||||
classify_redirect_destination("not a url"),
|
||||
DestinationClass::Malformed
|
||||
);
|
||||
assert_eq!(classify_redirect_destination(""), DestinationClass::Empty);
|
||||
}
|
||||
}
|
||||
@@ -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,517 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin
|
||||
pub async fn admin_index(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
match require_auth(&state, &jar).await {
|
||||
Ok(_) => Redirect::to("/admin/dashboard").into_response(),
|
||||
Err(redir) => redir.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/login
|
||||
pub async fn login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
.path("/admin/login")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::minutes(10))
|
||||
.build();
|
||||
|
||||
let new_jar = jar.add(cookie);
|
||||
let template = crate::templates::LoginTemplate {
|
||||
error,
|
||||
csrf_token,
|
||||
action: "/admin/login".to_string(),
|
||||
title: "Admin Login".to_string(),
|
||||
subtitle: "Administrative Access".to_string(),
|
||||
button_text: "Sign In".to_string(),
|
||||
};
|
||||
(new_jar, template).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct LoginForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
/// Bootstrap is allowed only when the system has no real admin yet.
|
||||
pub(crate) fn is_bootstrap_allowed(
|
||||
user_count: i64,
|
||||
admin_count: i64,
|
||||
active_session_count: i64,
|
||||
) -> bool {
|
||||
user_count <= 1 && admin_count == 0 && active_session_count == 0
|
||||
}
|
||||
|
||||
fn count_login_bootstrap_state(
|
||||
conn: &rusqlite::Connection,
|
||||
) -> Result<(i64, i64, i64), rusqlite::Error> {
|
||||
let u_count: i64 = conn.query_row("SELECT COUNT(*) FROM users;", [], |r| r.get(0))?;
|
||||
let a_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let s_count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM sessions WHERE expires_at > ?1;",
|
||||
[now],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok((u_count, a_count, s_count))
|
||||
}
|
||||
|
||||
/// Verify an existing admin tenant user may log into the admin UI.
|
||||
///
|
||||
/// Does not log the password. Rejection reasons are structured for observability.
|
||||
pub(crate) fn verify_admin_credentials(
|
||||
user: &crate::models::TenantUser,
|
||||
password: &str,
|
||||
) -> Result<(), &'static str> {
|
||||
if user.status != "active" {
|
||||
return Err("account_disabled");
|
||||
}
|
||||
if user.account_type != "admin" {
|
||||
return Err("insufficient_privileges");
|
||||
}
|
||||
if !verify_password(password, &user.password_hash) {
|
||||
return Err("invalid_credentials");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_tenant_user_by_username(
|
||||
conn: &rusqlite::Connection,
|
||||
username: &str,
|
||||
) -> Result<Option<crate::models::TenantUser>, rusqlite::Error> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
[username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||
User {
|
||||
id: u.id.to_string(),
|
||||
username: u.username,
|
||||
password_hash: u.password_hash,
|
||||
created_at: u.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
|
||||
format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
)
|
||||
}
|
||||
|
||||
// POST /admin/login
|
||||
pub async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar
|
||||
.get("bzod_temp_csrf")
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/admin/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let bootstrap_allowed = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match count_login_bootstrap_state(&conn) {
|
||||
Ok((u, a, s)) => is_bootstrap_allowed(u, a, s),
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "login bootstrap state query failed");
|
||||
false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let user_opt = if bootstrap_allowed
|
||||
&& form.username == state.config.admin_username
|
||||
&& verify_sha256(&form.password, &state.config.bootstrap_password_sha256)
|
||||
{
|
||||
// Bootstrap Phase using BOOTSTRAP_PASSWORD_SHA256
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal hashing error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match crate::db::users::create_admin_user(&conn, &form.username, &hash) {
|
||||
Ok(u) => {
|
||||
if let Err(e) = state.db.init_user_databases(u.id) {
|
||||
tracing::error!(
|
||||
"Failed to init user databases during admin bootstrap: {:?}",
|
||||
e
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&u.username,
|
||||
"BOOTSTRAP_USER_PROVISIONED",
|
||||
"user",
|
||||
&u.id.to_string(),
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
|
||||
Some(User {
|
||||
id: u.id.to_string(),
|
||||
username: u.username,
|
||||
password_hash: u.password_hash,
|
||||
created_at: u.created_at,
|
||||
})
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to create admin user during bootstrap: {:?}", e);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
match load_tenant_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => match verify_admin_credentials(&u, &form.password) {
|
||||
Ok(()) => Some(tenant_user_to_admin_user(u)),
|
||||
Err(reason) => {
|
||||
tracing::warn!(username = form.username, reason, "login rejected");
|
||||
None
|
||||
}
|
||||
},
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
username = form.username,
|
||||
reason = "user_not_found",
|
||||
"login rejected"
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "login user lookup failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match user_opt {
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
{
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
let user_id_i64 = user.id.parse::<i64>().unwrap_or(0);
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Err(e) = conn.execute(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?1, ?2, ?3, ?4);",
|
||||
rusqlite::params![session_token, user_id_i64, expires, now],
|
||||
) {
|
||||
tracing::error!(error = %e, "failed to insert admin session");
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
"session",
|
||||
&session_token,
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/admin/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
}
|
||||
None => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
"login",
|
||||
"",
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /logout
|
||||
pub async fn public_logout(State(_state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let cookie = Cookie::build("bzod_user_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
|
||||
(response_jar, Redirect::to("/login")).into_response()
|
||||
}
|
||||
|
||||
// GET /login
|
||||
pub async fn public_login_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let error = params.get("error").cloned();
|
||||
let csrf_token = generate_token(16);
|
||||
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_temp_csrf", csrf_token.clone()))
|
||||
.path("/login")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::minutes(10))
|
||||
.build();
|
||||
|
||||
let new_jar = jar.add(cookie);
|
||||
let template = crate::templates::LoginTemplate {
|
||||
error,
|
||||
csrf_token,
|
||||
action: "/login".to_string(),
|
||||
title: "User Login".to_string(),
|
||||
subtitle: "Standard account access".to_string(),
|
||||
button_text: "Sign In".to_string(),
|
||||
};
|
||||
(new_jar, template).into_response()
|
||||
}
|
||||
|
||||
// POST /login
|
||||
pub async fn public_login_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<LoginForm>,
|
||||
) -> Response {
|
||||
let temp_csrf = jar
|
||||
.get("bzod_temp_csrf")
|
||||
.map(|c| c.value().to_string())
|
||||
.unwrap_or_default();
|
||||
if temp_csrf.is_empty() || temp_csrf != form.csrf_token {
|
||||
return Redirect::to("/login?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let user_opt: Option<crate::models::TenantUser> = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let user_res: Result<Option<crate::models::TenantUser>, rusqlite::Error> = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE username = ?1;",
|
||||
[&form.username],
|
||||
|row| {
|
||||
Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
}
|
||||
).optional();
|
||||
|
||||
match user_res {
|
||||
Ok(Some(u)) => {
|
||||
if u.status != "active" {
|
||||
None
|
||||
} else if verify_password(&form.password, &u.password_hash) {
|
||||
Some(u)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
match user_opt {
|
||||
Some(user) => {
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::create_user_session(&conn, &session_token, user.id, &expires);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"USER_LOGIN",
|
||||
"session",
|
||||
&session_token,
|
||||
Some(&metadata),
|
||||
);
|
||||
}
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||
}
|
||||
None => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!(
|
||||
"IP: {:?}, UA: {:?}",
|
||||
ip,
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok())
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"anonymous",
|
||||
"LOGIN_FAILED",
|
||||
"login",
|
||||
"",
|
||||
Some(&metadata),
|
||||
);
|
||||
Redirect::to("/login?error=Invalid username or password").into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/logout
|
||||
pub async fn logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
if let Ok((_, session_id)) = require_auth(&state, &jar).await {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&session_id]);
|
||||
}
|
||||
|
||||
let cookie = Cookie::build("bzod_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
|
||||
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod login_helpers_tests {
|
||||
use super::is_bootstrap_allowed;
|
||||
|
||||
#[test]
|
||||
fn bootstrap_only_when_no_admin() {
|
||||
assert!(is_bootstrap_allowed(0, 0, 0));
|
||||
assert!(is_bootstrap_allowed(1, 0, 0));
|
||||
assert!(!is_bootstrap_allowed(2, 0, 0));
|
||||
assert!(!is_bootstrap_allowed(1, 1, 0));
|
||||
assert!(!is_bootstrap_allowed(1, 0, 1));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
use super::*;
|
||||
|
||||
// GET /user/dashboard
|
||||
pub async fn user_dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
for i in (0..30).rev() {
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
|
||||
let template = crate::templates::UserDashboardTemplate {
|
||||
admin_username: user.username,
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
active_links,
|
||||
dead_links,
|
||||
traffic_chart,
|
||||
countries_chart,
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/dashboard
|
||||
pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
let (user, _) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let total_clicks = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_total_clicks(&conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let clicks_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_clicks_trend(&conn, "url", "all", 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", "all", 30))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
for i in (0..30).rev() {
|
||||
let date_str = (Utc::now() - chrono::Duration::days(i))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
trend_map.insert(date_str, 0i64);
|
||||
}
|
||||
for (d, c) in clicks_data {
|
||||
trend_map.insert(d, c);
|
||||
}
|
||||
let formatted_trend: Vec<(String, i64)> = trend_map.into_iter().collect();
|
||||
let traffic_chart = generate_line_chart(&formatted_trend);
|
||||
|
||||
let countries_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "country", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "country", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let countries_chart = generate_bar_chart(&countries_data);
|
||||
|
||||
let referrers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "referrer", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "referrer", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let referrers_chart = generate_bar_chart(&referrers_data);
|
||||
|
||||
let browsers_data = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_metric_rankings(&conn, "url", "all", "browser", 5)
|
||||
.or_else(|_| get_metric_rankings_raw(&conn, "url", "all", "browser", 5))
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let browsers_chart = generate_bar_chart(&browsers_data);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
total_urls,
|
||||
total_pages,
|
||||
total_clicks,
|
||||
active_links,
|
||||
dead_links,
|
||||
traffic_chart,
|
||||
countries_chart,
|
||||
browsers_chart,
|
||||
referrers_chart,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct JobHistoryRow {
|
||||
pub id: String,
|
||||
pub job_name: String,
|
||||
pub status: String,
|
||||
pub started_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct HealthCheckRow {
|
||||
pub id: String,
|
||||
pub object_type: String,
|
||||
pub object_id: String,
|
||||
pub checked_at: String,
|
||||
pub status_code: Option<i64>,
|
||||
pub error_message: Option<String>,
|
||||
pub is_healthy: i64,
|
||||
}
|
||||
|
||||
// GET /admin/health
|
||||
pub async fn health_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let mut db_reports = vec![];
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.admin_db.lock().unwrap(), "admin")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.system_db.lock().unwrap(), "system")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.users_db.lock().unwrap(), "users")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.config.data_dir.join("admin").join("system.db");
|
||||
let users_db_path = state.config.data_dir.join("admin").join("users.db");
|
||||
let admin_db_path = state.config.data_dir.join("admin").join("admin.db");
|
||||
|
||||
let system_db_size = format_size(
|
||||
std::fs::metadata(&system_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let users_db_size = format_size(
|
||||
std::fs::metadata(&users_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
let admin_db_size = format_size(
|
||||
std::fs::metadata(&admin_db_path)
|
||||
.map(|m| m.len())
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
let users_dir = state.config.data_dir.join("users");
|
||||
let tenants_db_size = format_size(get_dir_size(&users_dir).unwrap_or(0));
|
||||
let total_data_size = format_size(get_dir_size(&state.config.data_dir).unwrap_or(0));
|
||||
|
||||
let job_history = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, job_name, status, started_at, finished_at, error_message FROM job_history ORDER BY started_at DESC LIMIT 20;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(JobHistoryRow {
|
||||
id: row.get(0)?,
|
||||
job_name: row.get(1)?,
|
||||
status: row.get(2)?,
|
||||
started_at: row.get(3)?,
|
||||
finished_at: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let health_checks = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, object_type, object_id, checked_at, status_code, error_message, is_healthy FROM health_checks ORDER BY checked_at DESC LIMIT 20;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(HealthCheckRow {
|
||||
id: row.get(0)?,
|
||||
object_type: row.get(1)?,
|
||||
object_id: row.get(2)?,
|
||||
checked_at: row.get(3)?,
|
||||
status_code: row.get(4)?,
|
||||
error_message: row.get(5)?,
|
||||
is_healthy: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let (registry_errors, registry_warnings) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&state.config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for issue in issues {
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::StaleReservation
|
||||
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||
warnings.push(format!(
|
||||
"Warning for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
));
|
||||
}
|
||||
_ => {
|
||||
errors.push(format!(
|
||||
"Error for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
(errors, warnings)
|
||||
}
|
||||
Err(e) => (vec![format!("Failed to run registry scan: {}", e)], vec![]),
|
||||
}
|
||||
};
|
||||
|
||||
let template = crate::templates::HealthTemplate {
|
||||
admin_username: user.username,
|
||||
db_reports,
|
||||
total_data_size,
|
||||
system_db_size,
|
||||
users_db_size,
|
||||
admin_db_size,
|
||||
tenants_db_size,
|
||||
job_history,
|
||||
health_checks,
|
||||
registry_errors,
|
||||
registry_warnings,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
@@ -0,0 +1,849 @@
|
||||
//! Admin web UI handlers (feature-split modules).
|
||||
//!
|
||||
//! Handlers are organized by domain; shared auth, audit, export, and helpers
|
||||
//! live in this module root so child modules can access them via `super`.
|
||||
|
||||
use crate::auth::{
|
||||
authenticate_admin_session, authenticate_user_session, generate_csrf_token, generate_token,
|
||||
hash_password, verify_csrf, verify_password, verify_sha256,
|
||||
};
|
||||
use crate::charts::{generate_bar_chart, generate_line_chart};
|
||||
use crate::db::admin::{
|
||||
create_api_key, delete_api_key, get_config, get_user_count, list_api_keys, set_config,
|
||||
write_audit_log as write_audit_log_legacy,
|
||||
};
|
||||
use crate::db::analytics::{
|
||||
clean_referrer, get_clicks_trend, get_clicks_trend_raw, get_metric_rankings,
|
||||
get_metric_rankings_raw, get_monthly_clicks_trend, get_target_unique_visitors,
|
||||
get_target_visit_total_filtered, get_target_visits_all_in_memory, get_target_visits_paginated,
|
||||
get_total_clicks, get_visits_schema_columns, parse_ua,
|
||||
};
|
||||
use crate::db::content::{
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id,
|
||||
get_landing_page_count, get_url_by_id, get_url_count_by_tag, get_url_counts,
|
||||
list_landing_pages, list_urls,
|
||||
};
|
||||
use crate::models::User;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::{get_client_ip, get_db_file_info, get_memory_usage};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
Form,
|
||||
};
|
||||
use axum_extra::extract::cookie::Cookie;
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use flate2::read::GzDecoder;
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use rusqlite::{params, OptionalExtension};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::net::SocketAddr;
|
||||
use tar::Builder;
|
||||
use uuid::Uuid;
|
||||
|
||||
// --- Shared constants, auth, audit, and export helpers ---
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn write_audit_log(
|
||||
conn: &rusqlite::Connection,
|
||||
state: &AppState,
|
||||
username: &str,
|
||||
action: &str,
|
||||
object_type: Option<&str>,
|
||||
object_id: Option<&str>,
|
||||
ip_address: Option<&str>,
|
||||
user_agent: Option<&str>,
|
||||
) -> rusqlite::Result<crate::models::AuditLog> {
|
||||
let res = write_audit_log_legacy(
|
||||
conn,
|
||||
username,
|
||||
action,
|
||||
object_type,
|
||||
object_id,
|
||||
ip_address,
|
||||
user_agent,
|
||||
);
|
||||
|
||||
// Also write to unified audit events in system.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!("IP: {:?}, UA: {:?}", ip_address, user_agent);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
username,
|
||||
action,
|
||||
object_type.unwrap_or(""),
|
||||
object_id.unwrap_or(""),
|
||||
Some(&metadata),
|
||||
);
|
||||
|
||||
res
|
||||
}
|
||||
pub(crate) const PAGE_SIZE: usize = 25;
|
||||
pub(crate) const ANALYTICS_PAGE_SIZE: usize = 50;
|
||||
pub(crate) const MAX_JSON_EXPORT_ROWS: usize = 50_000;
|
||||
// Helper: Verify admin session and return user or redirect to login
|
||||
pub(crate) async fn require_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(User, String), Redirect> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/admin/login")),
|
||||
};
|
||||
match authenticate_admin_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/admin/login")),
|
||||
}
|
||||
}
|
||||
// Helper: Verify tenant user session and return user or redirect to login
|
||||
pub(crate) async fn require_user_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(crate::models::TenantUser, String), Redirect> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/login")),
|
||||
};
|
||||
match authenticate_user_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/login")),
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
pub struct AnalyticsQuery {
|
||||
pub analytics_page: Option<usize>,
|
||||
pub date_from: Option<String>,
|
||||
pub date_to: Option<String>,
|
||||
}
|
||||
pub(crate) fn validate_date_filters(
|
||||
date_from: Option<&str>,
|
||||
date_to: Option<&str>,
|
||||
) -> Result<(Option<String>, Option<String>), StatusCode> {
|
||||
let from_parsed = match date_from {
|
||||
Some(df) if !df.is_empty() => match chrono::NaiveDate::parse_from_str(df, "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
let to_parsed = match date_to {
|
||||
Some(dt) if !dt.is_empty() => match chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
Ok(d) => Some(d),
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let (Some(f), Some(t)) = (from_parsed, to_parsed) {
|
||||
if f > t {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
from_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
to_parsed.map(|d| d.format("%Y-%m-%d").to_string()),
|
||||
))
|
||||
}
|
||||
pub(crate) fn escape_csv_field(field: &str) -> String {
|
||||
let needs_escaping =
|
||||
field.contains(',') || field.contains('"') || field.contains('\n') || field.contains('\r');
|
||||
if needs_escaping {
|
||||
let escaped = field.replace('"', "\"\"");
|
||||
format!("\"{}\"", escaped)
|
||||
} else {
|
||||
field.to_string()
|
||||
}
|
||||
}
|
||||
pub(crate) struct DbExportStream {
|
||||
receiver: tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::convert::Infallible>>,
|
||||
}
|
||||
|
||||
impl futures_util::stream::Stream for DbExportStream {
|
||||
type Item = Result<axum::body::Bytes, std::convert::Infallible>;
|
||||
|
||||
fn poll_next(
|
||||
mut self: std::pin::Pin<&mut Self>,
|
||||
cx: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<Option<Self::Item>> {
|
||||
self.receiver.poll_recv(cx)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn perform_csv_export(
|
||||
state: AppState,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = state.analytics_db.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = analytics_db.lock().unwrap();
|
||||
|
||||
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||
if has_utm_source {
|
||||
header.push_str(",UTM Source");
|
||||
}
|
||||
if has_utm_campaign {
|
||||
header.push_str(",UTM Campaign");
|
||||
}
|
||||
header.push('\n');
|
||||
|
||||
if tx
|
||||
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let select_fields = if has_utm_source && has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||
} else if has_utm_source {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||
} else if has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||
} else {
|
||||
"timestamp, ip_address, country, referer, user_agent"
|
||||
};
|
||||
|
||||
let mut sql = format!(
|
||||
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||
select_fields
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||
|
||||
if let Some(df) = clean_date_from.as_deref() {
|
||||
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||
}
|
||||
|
||||
if let Some(dt) = clean_date_to.as_deref() {
|
||||
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
let next_day = parsed_date + chrono::Duration::days(1);
|
||||
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!(
|
||||
"{}T00:00:00Z",
|
||||
next_day.format("%Y-%m-%d")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||
|
||||
let mut stmt = match conn.prepare(&sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut csv_buffer = String::new();
|
||||
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
let timestamp: String = row.get(0).unwrap_or_default();
|
||||
let ip_address: String = row.get(1).unwrap_or_default();
|
||||
let country: String = row.get(2).unwrap_or_default();
|
||||
let referer: String = row.get(3).unwrap_or_default();
|
||||
let user_agent: String = row.get(4).unwrap_or_default();
|
||||
|
||||
let (browser, _, _) = parse_ua(&user_agent);
|
||||
let referrer = clean_referrer(&referer);
|
||||
let country_display = if country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
country
|
||||
};
|
||||
|
||||
let mut line = format!(
|
||||
"{},{},{},{},{},{}",
|
||||
escape_csv_field(×tamp),
|
||||
escape_csv_field(&ip_address),
|
||||
escape_csv_field(&country_display),
|
||||
escape_csv_field(&referrer),
|
||||
escape_csv_field(&browser),
|
||||
escape_csv_field(&user_agent)
|
||||
);
|
||||
|
||||
let mut col_idx = 5;
|
||||
if has_utm_source {
|
||||
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||
col_idx += 1;
|
||||
}
|
||||
if has_utm_campaign {
|
||||
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||
}
|
||||
line.push('\n');
|
||||
|
||||
csv_buffer.push_str(&line);
|
||||
if csv_buffer.len() >= 8192 {
|
||||
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
csv_buffer = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
if !csv_buffer.is_empty() {
|
||||
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||
}
|
||||
});
|
||||
|
||||
let stream = DbExportStream { receiver: rx };
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.csv", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.csv", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "text/csv"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
axum::body::Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
pub(crate) async fn perform_json_export(
|
||||
state: AppState,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonExportRow {
|
||||
timestamp: String,
|
||||
ip_address: String,
|
||||
country: String,
|
||||
referrer: String,
|
||||
browser: String,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||
let referrer = clean_referrer(&r.referer);
|
||||
let country_display = if r.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
r.country
|
||||
};
|
||||
JsonExportRow {
|
||||
timestamp: r.timestamp,
|
||||
ip_address: r.ip_address,
|
||||
country: country_display,
|
||||
referrer,
|
||||
browser,
|
||||
user_agent: r.user_agent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body_str = match serde_json::to_string(&export_rows) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.json", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.json", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/json"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
body_str,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
// Helpers
|
||||
pub(crate) fn format_size(bytes: u64) -> String {
|
||||
if bytes < 1024 {
|
||||
format!("{} B", bytes)
|
||||
} else if bytes < 1024 * 1024 {
|
||||
format!("{:.2} KB", bytes as f64 / 1024.0)
|
||||
} else {
|
||||
format!("{:.2} MB", bytes as f64 / (1024.0 * 1024.0))
|
||||
}
|
||||
}
|
||||
pub(crate) fn get_dir_size(dir: &std::path::Path) -> std::io::Result<u64> {
|
||||
let mut total = 0;
|
||||
if dir.is_dir() {
|
||||
for entry in std::fs::read_dir(dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
total += get_dir_size(&path)?;
|
||||
} else {
|
||||
total += entry.metadata()?.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
}
|
||||
pub(crate) async fn perform_user_csv_export(
|
||||
user_dbs: crate::state::UserDbs,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
let cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
(cols.contains("utm_source"), cols.contains("utm_campaign"))
|
||||
};
|
||||
|
||||
let (tx, rx) =
|
||||
tokio::sync::mpsc::channel::<Result<axum::body::Bytes, std::convert::Infallible>>(32);
|
||||
let analytics_db = user_dbs.analytics.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let conn = analytics_db.lock().unwrap();
|
||||
|
||||
let mut header = "Timestamp,IP Address,Country,Referrer,Browser,User-Agent".to_string();
|
||||
if has_utm_source {
|
||||
header.push_str(",UTM Source");
|
||||
}
|
||||
if has_utm_campaign {
|
||||
header.push_str(",UTM Campaign");
|
||||
}
|
||||
header.push('\n');
|
||||
|
||||
if tx
|
||||
.blocking_send(Ok(axum::body::Bytes::from(header)))
|
||||
.is_err()
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let select_fields = if has_utm_source && has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source, utm_campaign"
|
||||
} else if has_utm_source {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_source"
|
||||
} else if has_utm_campaign {
|
||||
"timestamp, ip_address, country, referer, user_agent, utm_campaign"
|
||||
} else {
|
||||
"timestamp, ip_address, country, referer, user_agent"
|
||||
};
|
||||
|
||||
let mut sql = format!(
|
||||
"SELECT {} FROM visits WHERE target_type = ?1 AND target_id = ?2",
|
||||
select_fields
|
||||
);
|
||||
let mut params: Vec<Box<dyn rusqlite::ToSql>> =
|
||||
vec![Box::new(target_type.to_string()), Box::new(target_id)];
|
||||
|
||||
if let Some(df) = clean_date_from.as_deref() {
|
||||
sql.push_str(&format!(" AND timestamp >= ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!("{}T00:00:00Z", df)));
|
||||
}
|
||||
|
||||
if let Some(dt) = clean_date_to.as_deref() {
|
||||
if let Ok(parsed_date) = chrono::NaiveDate::parse_from_str(dt, "%Y-%m-%d") {
|
||||
let next_day = parsed_date + chrono::Duration::days(1);
|
||||
sql.push_str(&format!(" AND timestamp < ?{}", params.len() + 1));
|
||||
params.push(Box::new(format!(
|
||||
"{}T00:00:00Z",
|
||||
next_day.format("%Y-%m-%d")
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY timestamp DESC, id DESC");
|
||||
|
||||
let mut stmt = match conn.prepare(&sql) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
|
||||
let mut rows = match stmt.query(rusqlite::params_from_iter(param_refs)) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut csv_buffer = String::new();
|
||||
|
||||
while let Ok(Some(row)) = rows.next() {
|
||||
let timestamp: String = row.get(0).unwrap_or_default();
|
||||
let ip_address: String = row.get(1).unwrap_or_default();
|
||||
let country: String = row.get(2).unwrap_or_default();
|
||||
let referer: String = row.get(3).unwrap_or_default();
|
||||
let user_agent: String = row.get(4).unwrap_or_default();
|
||||
|
||||
let (browser, _, _) = parse_ua(&user_agent);
|
||||
let referrer = clean_referrer(&referer);
|
||||
let country_display = if country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
country
|
||||
};
|
||||
|
||||
let mut line = format!(
|
||||
"{},{},{},{},{},{}",
|
||||
escape_csv_field(×tamp),
|
||||
escape_csv_field(&ip_address),
|
||||
escape_csv_field(&country_display),
|
||||
escape_csv_field(&referrer),
|
||||
escape_csv_field(&browser),
|
||||
escape_csv_field(&user_agent)
|
||||
);
|
||||
|
||||
let mut col_idx = 5;
|
||||
if has_utm_source {
|
||||
let utm_src: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_src)));
|
||||
col_idx += 1;
|
||||
}
|
||||
if has_utm_campaign {
|
||||
let utm_camp: String = row.get(col_idx).unwrap_or_default();
|
||||
line.push_str(&format!(",{}", escape_csv_field(&utm_camp)));
|
||||
}
|
||||
line.push('\n');
|
||||
|
||||
csv_buffer.push_str(&line);
|
||||
if csv_buffer.len() >= 8192 {
|
||||
let bytes = axum::body::Bytes::from(csv_buffer);
|
||||
if tx.blocking_send(Ok(bytes)).is_err() {
|
||||
return;
|
||||
}
|
||||
csv_buffer = String::new();
|
||||
}
|
||||
}
|
||||
|
||||
if !csv_buffer.is_empty() {
|
||||
let _ = tx.blocking_send(Ok(axum::body::Bytes::from(csv_buffer)));
|
||||
}
|
||||
});
|
||||
|
||||
let stream = DbExportStream { receiver: rx };
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.csv", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.csv", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "text/csv"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
axum::body::Body::from_stream(stream),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
pub(crate) async fn perform_user_json_export(
|
||||
user_dbs: crate::state::UserDbs,
|
||||
target_type: &'static str,
|
||||
id: String,
|
||||
date_from: Option<String>,
|
||||
date_to: Option<String>,
|
||||
) -> Response {
|
||||
let (clean_date_from, clean_date_to) =
|
||||
match validate_date_filters(date_from.as_deref(), date_to.as_deref()) {
|
||||
Ok(res) => res,
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
get_landing_page_by_id(&conn, &id)
|
||||
.map(|p| p.is_some())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
};
|
||||
if !target_exists {
|
||||
return (StatusCode::NOT_FOUND, "Target not found").into_response();
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
if count > MAX_JSON_EXPORT_ROWS as i64 {
|
||||
return StatusCode::PAYLOAD_TOO_LARGE.into_response();
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
&id,
|
||||
clean_date_from.as_deref(),
|
||||
clean_date_to.as_deref(),
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct JsonExportRow {
|
||||
timestamp: String,
|
||||
ip_address: String,
|
||||
country: String,
|
||||
referrer: String,
|
||||
browser: String,
|
||||
user_agent: String,
|
||||
}
|
||||
|
||||
let export_rows: Vec<JsonExportRow> = visits_raw
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (browser, _, _) = parse_ua(&r.user_agent);
|
||||
let referrer = clean_referrer(&r.referer);
|
||||
let country_display = if r.country.is_empty() {
|
||||
"Unknown".to_string()
|
||||
} else {
|
||||
r.country
|
||||
};
|
||||
JsonExportRow {
|
||||
timestamp: r.timestamp,
|
||||
ip_address: r.ip_address,
|
||||
country: country_display,
|
||||
referrer,
|
||||
browser,
|
||||
user_agent: r.user_agent,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let body_str = match serde_json::to_string(&export_rows) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Serialization error").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let filename = if target_type == "url" {
|
||||
format!("url_{}_analytics.json", id)
|
||||
} else {
|
||||
format!("page_{}_analytics.json", id)
|
||||
};
|
||||
|
||||
(
|
||||
StatusCode::OK,
|
||||
[
|
||||
("Content-Type", "application/json"),
|
||||
(
|
||||
"Content-Disposition",
|
||||
&format!("attachment; filename=\"{}\"", filename),
|
||||
),
|
||||
("X-BZOD-Export-Records", &count.to_string()),
|
||||
],
|
||||
body_str,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// --- Feature modules ---
|
||||
|
||||
mod auth;
|
||||
pub use auth::*;
|
||||
mod dashboard;
|
||||
pub use dashboard::*;
|
||||
mod urls;
|
||||
pub use urls::*;
|
||||
mod pages;
|
||||
pub use pages::*;
|
||||
mod users;
|
||||
pub use users::*;
|
||||
mod analytics;
|
||||
pub use analytics::*;
|
||||
mod settings;
|
||||
pub use settings::*;
|
||||
mod audit;
|
||||
pub use audit::*;
|
||||
mod sessions;
|
||||
pub use sessions::*;
|
||||
mod quotas;
|
||||
pub use quotas::*;
|
||||
mod health;
|
||||
pub use health::*;
|
||||
mod backups;
|
||||
pub use backups::*;
|
||||
mod api_keys;
|
||||
pub use api_keys::*;
|
||||
mod moderation;
|
||||
pub use moderation::*;
|
||||
@@ -0,0 +1,639 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GlobalSlugRow {
|
||||
pub slug: String,
|
||||
pub owner_user_id: i64,
|
||||
pub target_type: String,
|
||||
pub target_id: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
pub deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ModerationLogEntry {
|
||||
pub id: String,
|
||||
pub timestamp: String,
|
||||
pub admin_username: String,
|
||||
pub target_user_id: i64,
|
||||
pub target_username: Option<String>,
|
||||
pub resource_type: String,
|
||||
pub resource_identifier: String,
|
||||
pub action: String,
|
||||
pub severity: String,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugHistoryRow {
|
||||
pub id: i64,
|
||||
pub slug: String,
|
||||
pub old_owner_user_id: Option<i64>,
|
||||
pub new_owner_user_id: Option<i64>,
|
||||
pub action: String,
|
||||
pub timestamp: String,
|
||||
pub admin_username: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/moderation
|
||||
pub async fn moderation_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let flagged_items = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at \
|
||||
FROM global_slugs WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason \
|
||||
FROM moderation_events ORDER BY timestamp DESC LIMIT 50;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ModerationLogEntry {
|
||||
id: row.get(0)?,
|
||||
timestamp: row.get(1)?,
|
||||
admin_username: row.get(2)?,
|
||||
target_user_id: row.get(3)?,
|
||||
target_username: row.get(4)?,
|
||||
resource_type: row.get(5)?,
|
||||
resource_identifier: row.get(6)?,
|
||||
action: row.get(7)?,
|
||||
severity: row.get(8)?,
|
||||
reason: row.get(9)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::ModerationTemplate {
|
||||
admin_username: user.username,
|
||||
flagged_items,
|
||||
logs,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminModerateForm {
|
||||
pub slug: String,
|
||||
pub action: String,
|
||||
pub severity: String,
|
||||
pub reason: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/moderation
|
||||
pub async fn moderation_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminModerateForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/moderation?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let action = form.action.trim().to_lowercase();
|
||||
if !["flagged", "disabled", "active", "deleted"].contains(&action.as_str()) {
|
||||
return Redirect::to("/admin/moderation?error=Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
let (owner_user_id, target_type) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let owner_username = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&users_conn, owner_user_id)
|
||||
.unwrap_or(None)
|
||||
.map(|u| u.username)
|
||||
};
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
let event_id = Uuid::new_v4().to_string();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO moderation_events (id, timestamp, admin_username, target_user_id, target_username, resource_type, resource_identifier, action, severity, reason)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10);",
|
||||
rusqlite::params![
|
||||
event_id,
|
||||
now,
|
||||
user.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
form.slug,
|
||||
action,
|
||||
form.severity,
|
||||
form.reason
|
||||
],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"CONTENT_MODERATION",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!("Action: {}, Reason: {}", action, form.reason)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/moderation?success=Moderation action '{}' applied",
|
||||
action
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SlugsQuery {
|
||||
pub search: Option<String>,
|
||||
pub owner: Option<i64>,
|
||||
pub status: Option<String>,
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// GET /admin/slugs
|
||||
pub async fn slugs_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<SlugsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
|
||||
let mut sql = "SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at FROM global_slugs WHERE 1=1".to_string();
|
||||
let mut values = vec![];
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(o) = query.owner {
|
||||
sql.push_str(" AND owner_user_id = ?");
|
||||
values.push(rusqlite::types::Value::Integer(o));
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
|
||||
let slugs = {
|
||||
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let history = {
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT id, slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username \
|
||||
FROM slug_history ORDER BY timestamp DESC LIMIT 50;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(SlugHistoryRow {
|
||||
id: row.get(0)?,
|
||||
slug: row.get(1)?,
|
||||
old_owner_user_id: row.get(2)?,
|
||||
new_owner_user_id: row.get(3)?,
|
||||
action: row.get(4)?,
|
||||
timestamp: row.get(5)?,
|
||||
admin_username: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::SlugsTemplate {
|
||||
admin_username: user.username,
|
||||
slugs,
|
||||
history,
|
||||
csrf_token,
|
||||
search_filter: query.search,
|
||||
owner_filter: query.owner,
|
||||
status_filter: query.status,
|
||||
success: query.success,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminTransferForm {
|
||||
pub slug: String,
|
||||
pub new_owner_user_id: i64,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/transfer
|
||||
pub async fn slugs_transfer_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminTransferForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_exists = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[form.new_owner_user_id],
|
||||
|row| row.get::<_, bool>(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !user_exists {
|
||||
return Redirect::to("/admin/slugs?error=New owner user ID does not exist").into_response();
|
||||
}
|
||||
|
||||
let (old_owner, target_type, mut new_target_id) =
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
).optional().unwrap_or(None);
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner == form.new_owner_user_id {
|
||||
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_url = match crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_url.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_page = match crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy page record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_page.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!(
|
||||
"Transferred from {} to {}",
|
||||
old_owner, form.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminSlugStatusForm {
|
||||
pub slug: String,
|
||||
pub status: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/status
|
||||
pub async fn slugs_status_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminSlugStatusForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let status = form.status.trim().to_lowercase();
|
||||
if !["active", "flagged", "disabled"].contains(&status.as_str()) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_STATUS_UPDATE",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!("Status set to {}", status)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} status updated to {}",
|
||||
form.slug, status
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct AdminSlugDeleteForm {
|
||||
pub slug: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/slugs/delete
|
||||
pub async fn slugs_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<AdminSlugDeleteForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let old_owner_user_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_RELEASE",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some("Slug released/deleted from global index"),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} released successfully",
|
||||
form.slug
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserPagesQuery {
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserPageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub state: String,
|
||||
pub html_content: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn user_pages_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UserPagesQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
(pages, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserPagesTemplate {
|
||||
admin_username: user.username.clone(),
|
||||
username: user.username,
|
||||
pages,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUserPageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/user/pages?error=Custom code must be exactly 4 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to(
|
||||
"/user/pages?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/user/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, user.id, "landings").unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/pages?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "landings");
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_pages_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/user/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &id) {
|
||||
Ok(Some(page)) => {
|
||||
let _ = crate::db::users::decrement_quota_counter(
|
||||
&state.users_db.lock().unwrap(),
|
||||
user.id,
|
||||
"landings",
|
||||
);
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&page.code,
|
||||
user.id,
|
||||
);
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/user/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
_ => Redirect::to("/user/pages?error=Page not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/pages
|
||||
#[derive(Deserialize)]
|
||||
pub struct PagesQuery {
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn pages_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<PagesQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (pages, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = get_landing_page_count(&conn).unwrap_or(0);
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let pages = list_landing_pages(&conn, PAGE_SIZE as i64, offset as i64).unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(pages, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::PagesTemplate {
|
||||
admin_username: user.username,
|
||||
pages,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub state: String,
|
||||
pub html_content: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/pages/create
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(2);
|
||||
} else {
|
||||
if code.len() != 4 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/pages?error=Custom code must be exactly 4 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/pages?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let clean_slug = form.slug.trim().to_lowercase();
|
||||
if clean_slug.is_empty() {
|
||||
return Redirect::to("/admin/pages?error=Slug is required").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "landings")
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/pages?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/pages?error=Short code already exists").into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "page", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/pages?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
create_landing_page(
|
||||
&conn,
|
||||
&code,
|
||||
&clean_slug,
|
||||
&form.title,
|
||||
&form.html_content,
|
||||
&form.state,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if form.state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![page.id, global_status, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(
|
||||
&users_conn,
|
||||
admin_user_id,
|
||||
"landings",
|
||||
);
|
||||
}
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_CREATION",
|
||||
Some("page"),
|
||||
Some(&page.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
pub async fn pages_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/pages?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin/quotas
|
||||
pub async fn quotas_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT user_id, max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb FROM quotas ORDER BY user_id ASC;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(crate::models::UserQuotas {
|
||||
user_id: row.get(0)?,
|
||||
max_urls: row.get(1)?,
|
||||
max_landings: row.get(2)?,
|
||||
max_api_tokens: row.get(3)?,
|
||||
max_storage_mb: row.get(4)?,
|
||||
current_urls: row.get(5)?,
|
||||
current_landings: row.get(6)?,
|
||||
current_api_tokens: row.get(7)?,
|
||||
current_storage_mb: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::QuotasTemplate {
|
||||
admin_username: user.username,
|
||||
quotas,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/quotas
|
||||
pub async fn quotas_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/quotas?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let action = form.get("action").cloned().unwrap_or_default();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
|
||||
if action == "reconcile_all" {
|
||||
let user_ids: Vec<i64> = {
|
||||
let mut stmt = users_conn.prepare("SELECT id FROM users;").unwrap();
|
||||
let rows = stmt.query_map([], |row| row.get(0)).unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
for uid in user_ids {
|
||||
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||
}
|
||||
}
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"QUOTAS_RECONCILE_ALL",
|
||||
"quota",
|
||||
"all",
|
||||
None,
|
||||
);
|
||||
|
||||
Redirect::to("/admin/quotas?success=All user quotas reconciled successfully")
|
||||
.into_response()
|
||||
} else if action == "reconcile" {
|
||||
let uid_str = form.get("user_id").cloned().unwrap_or_default();
|
||||
let uid = uid_str.parse::<i64>().unwrap_or(0);
|
||||
if let Ok(user_dbs) = state.get_user_dbs(uid) {
|
||||
let user_content_conn = user_dbs.content.lock().unwrap();
|
||||
let _ = crate::db::users::reconcile_user_quotas(&users_conn, uid, &user_content_conn);
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"QUOTAS_RECONCILE",
|
||||
"quota",
|
||||
&uid.to_string(),
|
||||
None,
|
||||
);
|
||||
Redirect::to(&format!("/admin/users/{}?success=Quotas reconciled", uid)).into_response()
|
||||
} else {
|
||||
Redirect::to("/admin/quotas?error=User databases not found").into_response()
|
||||
}
|
||||
} else {
|
||||
Redirect::to("/admin/quotas?error=Invalid action").into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
|
||||
// GET /admin/sessions
|
||||
pub async fn sessions_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let sessions = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn.prepare("SELECT id, user_id, expires_at, created_at FROM sessions ORDER BY created_at DESC;").unwrap();
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(crate::models::UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::SessionsTemplate {
|
||||
admin_username: user.username,
|
||||
sessions,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/sessions/revoke/:id
|
||||
pub async fn sessions_revoke_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [&id]);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"SESSION_REVOKED",
|
||||
"session",
|
||||
&id,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/sessions?success=Session revoked").into_response()
|
||||
}
|
||||
|
||||
// POST /admin/sessions/revoke-all
|
||||
pub async fn sessions_revoke_all_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let form_csrf = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &form_csrf) {
|
||||
return Redirect::to("/admin/sessions?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute("DELETE FROM sessions;", []);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_sys = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn_sys,
|
||||
&user.username,
|
||||
"SESSIONS_ALL_REVOKED",
|
||||
"session",
|
||||
"all",
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/sessions?success=All active sessions revoked").into_response()
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserUrlsQuery {
|
||||
pub tag: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserUrlForm {
|
||||
pub destination: String,
|
||||
#[serde(default)]
|
||||
pub code: String,
|
||||
#[serde(default)]
|
||||
pub custom_slug: String,
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
#[serde(default)]
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
#[serde(default)]
|
||||
pub expires_at: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
#[serde(default)]
|
||||
pub max_access_count: String,
|
||||
#[serde(default)]
|
||||
pub utm_source: String,
|
||||
#[serde(default)]
|
||||
pub utm_medium: String,
|
||||
#[serde(default)]
|
||||
pub utm_campaign: String,
|
||||
}
|
||||
|
||||
pub async fn user_urls_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UserUrlsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(urls, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||
|
||||
let template = crate::templates::UserUrlsTemplate {
|
||||
admin_username: user.username.clone(),
|
||||
username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
base_url,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn user_urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUserUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to("/user/urls?error=Custom code must be exactly 6 hex characters")
|
||||
.into_response();
|
||||
}
|
||||
} else if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to(
|
||||
"/user/urls?error=Custom slug must start with ! followed by 1-24 a-z, 0-9, -, _",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, user.id, "urls").unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/user/urls?error=Short code/slug already exists").into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
let res = {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, user.id, "urls");
|
||||
}
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, user.id);
|
||||
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn user_urls_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_user_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let user_dbs = match state.get_user_dbs(user.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/user/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &id) {
|
||||
Ok(Some(url)) => {
|
||||
let _ = crate::db::users::decrement_quota_counter(
|
||||
&state.users_db.lock().unwrap(),
|
||||
user.id,
|
||||
"urls",
|
||||
);
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&url.code,
|
||||
user.id,
|
||||
);
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/user/urls").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/user/urls?error=Failed to delete link: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
_ => Redirect::to("/user/urls?error=Link not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /admin/urls
|
||||
#[derive(Deserialize)]
|
||||
pub struct UrlsQuery {
|
||||
pub tag: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
}
|
||||
|
||||
pub async fn urls_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UrlsQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (urls, total_pages, page, visible_pages) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let total_records = if let Some(tag_str) = query.tag.as_deref() {
|
||||
get_url_count_by_tag(&conn, tag_str).unwrap_or(0)
|
||||
} else {
|
||||
get_url_counts(&conn).map(|(t, _, _)| t).unwrap_or(0)
|
||||
};
|
||||
let calculated_total_pages = (total_records as usize).div_ceil(PAGE_SIZE);
|
||||
let total_pages = std::cmp::max(1, calculated_total_pages);
|
||||
let requested_page = query.page.unwrap_or(1);
|
||||
let current_page = if requested_page == 0 {
|
||||
1
|
||||
} else {
|
||||
requested_page
|
||||
}
|
||||
.clamp(1, total_pages);
|
||||
let offset = (current_page - 1) * PAGE_SIZE;
|
||||
|
||||
let urls = list_urls(&conn, PAGE_SIZE as i64, offset as i64, query.tag.as_deref())
|
||||
.unwrap_or_default();
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
let visible_pages: Vec<usize> = (start_page..=end_page).collect();
|
||||
|
||||
(urls, total_pages, current_page, visible_pages)
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let proto = if state.config.cookie_secure {
|
||||
"https"
|
||||
} else {
|
||||
"http"
|
||||
};
|
||||
let base_url = state
|
||||
.config
|
||||
.base_url
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://localhost:{}", proto, state.config.port));
|
||||
|
||||
let template = crate::templates::UrlsTemplate {
|
||||
admin_username: user.username,
|
||||
urls,
|
||||
csrf_token,
|
||||
error: query.error,
|
||||
tag_filter: query.tag,
|
||||
base_url,
|
||||
current_page: page,
|
||||
total_pages,
|
||||
visible_pages,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
pub custom_slug: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
pub tags: String,
|
||||
pub csrf_token: String,
|
||||
pub expires_at: String,
|
||||
pub password: String,
|
||||
pub max_access_count: String,
|
||||
pub utm_source: String,
|
||||
pub utm_medium: String,
|
||||
pub utm_campaign: String,
|
||||
}
|
||||
|
||||
// POST /admin/urls/create
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let admin_user_id = user.id.parse::<i64>().unwrap_or(1);
|
||||
|
||||
// Custom Slug takes priority if provided
|
||||
let mut code = form.custom_slug.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = form.code.trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return Redirect::to(
|
||||
"/admin/urls?error=Custom code must be exactly 6 hex characters",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !crate::utils::validation::validate_custom_slug(&code) {
|
||||
return Redirect::to("/admin/urls?error=Custom slug must start with ! followed by 1-24 characters of a-z, 0-9, -, _")
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&form.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: Some(&form.utm_source),
|
||||
medium: Some(&form.utm_medium),
|
||||
campaign: Some(&form.utm_campaign),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return Redirect::to(&format!("/admin/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let expires_at_opt = crate::services::urls::parse_expires_at_input(&form.expires_at);
|
||||
|
||||
let password_hash_opt = if form.password.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/admin/urls?error=Hashing error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let max_access_count_opt = if form.max_access_count.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
match form.max_access_count.trim().parse::<i64>() {
|
||||
Ok(c) => Some(c),
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/urls?error=Invalid max access count").into_response()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let tags_list: Vec<String> = form
|
||||
.tags
|
||||
.split(',')
|
||||
.map(|t| t.trim().to_string())
|
||||
.filter(|t| !t.is_empty())
|
||||
.collect();
|
||||
|
||||
let title_opt = if form.title.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.title.trim())
|
||||
};
|
||||
let desc_opt = if form.description.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.description.trim())
|
||||
};
|
||||
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if !crate::db::users::check_quota_limit(&users_conn, admin_user_id, "urls").unwrap_or(false)
|
||||
{
|
||||
return Redirect::to("/admin/urls?error=Quota limit exceeded").into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
return Redirect::to("/admin/urls?error=Short code/slug already exists")
|
||||
.into_response();
|
||||
}
|
||||
// Always use owner_user_id = 1 for admin content so it resolves via state.content_db
|
||||
if let Err(e) =
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")
|
||||
{
|
||||
return Redirect::to(&format!("/admin/urls?error=Failed to reserve slug: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let res = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
&dest,
|
||||
title_opt,
|
||||
desc_opt,
|
||||
&tags_list,
|
||||
expires_at_opt.as_deref(),
|
||||
password_hash_opt.as_deref(),
|
||||
max_access_count_opt,
|
||||
)
|
||||
};
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::users::increment_quota_counter(&users_conn, admin_user_id, "urls");
|
||||
}
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_CREATION",
|
||||
Some("url"),
|
||||
Some(&url.id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
Redirect::to(&format!("/admin/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
pub async fn urls_delete(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<String>,
|
||||
Form(form): Form<std::collections::HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = form.get("csrf_token").cloned().unwrap_or_default();
|
||||
if !verify_csrf(&session_id, &csrf_token) {
|
||||
return Redirect::to("/admin/urls?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,704 @@
|
||||
use super::*;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UsersQuery {
|
||||
pub success: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CreateUserForm {
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
pub account_type: String,
|
||||
pub metadata: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserStatusForm {
|
||||
pub status: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UpdateUserTypeForm {
|
||||
pub account_type: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResetPasswordForm {
|
||||
pub new_password: String,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct DeleteUserForm {
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
pub async fn users_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(query): Query<UsersQuery>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let users = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::list_users(&conn).unwrap_or_default()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UsersTemplate {
|
||||
admin_username: user.username,
|
||||
users,
|
||||
csrf_token,
|
||||
success: query.success,
|
||||
error: query.error,
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
pub async fn users_create_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Form(form): Form<CreateUserForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let username = form.username.trim().to_lowercase();
|
||||
if username.len() < 3 {
|
||||
return Redirect::to("/admin/users?error=Username must be at least 3 characters")
|
||||
.into_response();
|
||||
}
|
||||
if !username
|
||||
.chars()
|
||||
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
|
||||
{
|
||||
return Redirect::to(
|
||||
"/admin/users?error=Username must contain only alphanumeric characters, hyphens, or underscores",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if form.password.trim().len() < 8 {
|
||||
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let account_type = if form.account_type.trim().is_empty() {
|
||||
"standard"
|
||||
} else {
|
||||
form.account_type.trim()
|
||||
};
|
||||
|
||||
let metadata = if form.metadata.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(form.metadata.trim())
|
||||
};
|
||||
|
||||
let hash = match hash_password(&form.password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||
};
|
||||
|
||||
let new_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::create_user(&conn, &username, &hash, account_type, metadata) {
|
||||
Ok(u) => u,
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::ConstraintViolation =>
|
||||
{
|
||||
return Redirect::to("/admin/users?error=Username already exists").into_response();
|
||||
}
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!("/admin/users?error=Database error: {}", e))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_CREATION",
|
||||
Some("user"),
|
||||
Some(&new_user.id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to("/admin/users?success=User created successfully").into_response()
|
||||
}
|
||||
|
||||
pub async fn users_update_status_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateUserStatusForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let status = form.status.trim().to_lowercase();
|
||||
if !["active", "disabled", "suspended", "pending", "deleted"].contains(&status.as_str()) {
|
||||
return Redirect::to("/admin/users?error=Invalid user status").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_status(&conn, id, &status) {
|
||||
Ok(_) => {
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_STATUS_UPDATE",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/users?success=User status updated").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to update status: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_update_type_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UpdateUserTypeForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
let account_type = form.account_type.trim().to_lowercase();
|
||||
if !["admin", "standard", "organization", "service", "system"].contains(&account_type.as_str())
|
||||
{
|
||||
return Redirect::to("/admin/users?error=Invalid account type").into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::update_user_account_type(&conn, id, &account_type) {
|
||||
Ok(_) => {
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_ACCOUNT_TYPE_UPDATE",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
None,
|
||||
None,
|
||||
);
|
||||
Redirect::to("/admin/users?success=User account type updated").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to update account type: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_reset_password_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<ResetPasswordForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
if form.new_password.trim().len() < 8 {
|
||||
return Redirect::to("/admin/users?error=Password must be at least 8 characters")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let hash = match hash_password(&form.new_password) {
|
||||
Ok(h) => h,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Internal hashing error").into_response(),
|
||||
};
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::reset_user_password(&conn, id, &hash) {
|
||||
Ok(_) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_PASSWORD_RESET",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/users?success=Password reset successfully").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to reset password: {}",
|
||||
e
|
||||
))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn users_delete_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<DeleteUserForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to("/admin/users?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
match crate::web::multi_user::delete_user_resources(&state, id, &user.username, false) {
|
||||
Ok(_) => {
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"USER_DELETION",
|
||||
Some("user"),
|
||||
Some(&id.to_string()),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
Redirect::to("/admin/users?success=User deleted successfully").into_response()
|
||||
}
|
||||
Err(err) => Redirect::to(&format!("/admin/users?error={}", err)).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GA Hardening UI Handlers and Structs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct UserDetailStats {
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
pub current_urls: i64,
|
||||
pub current_landings: i64,
|
||||
pub current_api_tokens: i64,
|
||||
pub current_storage_mb: i64,
|
||||
pub url_pct: i64,
|
||||
pub landing_pct: i64,
|
||||
pub token_pct: i64,
|
||||
pub storage_pct: i64,
|
||||
pub total_visits: i64,
|
||||
}
|
||||
|
||||
pub fn get_user_detail_stats(
|
||||
state: &AppState,
|
||||
user_id: i64,
|
||||
) -> Result<UserDetailStats, Box<dyn std::error::Error>> {
|
||||
use rusqlite::OptionalExtension;
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||
FROM quotas WHERE user_id = ?1;",
|
||||
[user_id],
|
||||
|row| Ok(crate::models::UserQuotas {
|
||||
user_id,
|
||||
max_urls: row.get(0)?,
|
||||
max_landings: row.get(1)?,
|
||||
max_api_tokens: row.get(2)?,
|
||||
max_storage_mb: row.get(3)?,
|
||||
current_urls: row.get(4)?,
|
||||
current_landings: row.get(5)?,
|
||||
current_api_tokens: row.get(6)?,
|
||||
current_storage_mb: row.get(7)?,
|
||||
})
|
||||
).optional()?
|
||||
};
|
||||
|
||||
let quotas = quotas.unwrap_or(crate::models::UserQuotas {
|
||||
user_id,
|
||||
max_urls: 100,
|
||||
max_landings: 10,
|
||||
max_api_tokens: 5,
|
||||
max_storage_mb: 100,
|
||||
current_urls: 0,
|
||||
current_landings: 0,
|
||||
current_api_tokens: 0,
|
||||
current_storage_mb: 0,
|
||||
});
|
||||
|
||||
let total_visits = {
|
||||
if let Ok(dbs) = state.get_user_dbs(user_id) {
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
conn.query_row("SELECT COUNT(*) FROM visits;", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
}
|
||||
};
|
||||
|
||||
let url_pct = if quotas.max_urls > 0 {
|
||||
(quotas.current_urls * 100) / quotas.max_urls
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let landing_pct = if quotas.max_landings > 0 {
|
||||
(quotas.current_landings * 100) / quotas.max_landings
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let token_pct = if quotas.max_api_tokens > 0 {
|
||||
(quotas.current_api_tokens * 100) / quotas.max_api_tokens
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let storage_pct = if quotas.max_storage_mb > 0 {
|
||||
(quotas.current_storage_mb * 100) / quotas.max_storage_mb
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(UserDetailStats {
|
||||
max_urls: quotas.max_urls,
|
||||
max_landings: quotas.max_landings,
|
||||
max_api_tokens: quotas.max_api_tokens,
|
||||
max_storage_mb: quotas.max_storage_mb,
|
||||
current_urls: quotas.current_urls,
|
||||
current_landings: quotas.current_landings,
|
||||
current_api_tokens: quotas.current_api_tokens,
|
||||
current_storage_mb: quotas.current_storage_mb,
|
||||
url_pct,
|
||||
landing_pct,
|
||||
token_pct,
|
||||
storage_pct,
|
||||
total_visits,
|
||||
})
|
||||
}
|
||||
|
||||
// GET /admin/users/new
|
||||
pub async fn users_new_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UsersNewTemplate {
|
||||
admin_username: user.username,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/users/:id
|
||||
pub async fn user_detail_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let stats = match get_user_detail_stats(&state, id) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return Redirect::to("/admin/users?error=Database error").into_response(),
|
||||
};
|
||||
|
||||
let sessions = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare("SELECT id, user_id, expires_at, created_at FROM sessions WHERE user_id = ?1;")
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([id], |row| {
|
||||
Ok(crate::models::UserSession {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
expires_at: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let tokens = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, user_id, token_hash, created_at FROM api_tokens WHERE user_id = ?1;",
|
||||
)
|
||||
.unwrap();
|
||||
let rows = stmt
|
||||
.query_map([id], |row| {
|
||||
Ok(crate::models::UserApiToken {
|
||||
id: row.get(0)?,
|
||||
user_id: row.get(1)?,
|
||||
token_hash: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserDetailTemplate {
|
||||
admin_username: user.username,
|
||||
target_user,
|
||||
stats,
|
||||
sessions,
|
||||
tokens,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
// GET /admin/users/:id/edit
|
||||
pub async fn user_edit_get(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let target_user = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let u_res = conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata \
|
||||
FROM users WHERE id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
);
|
||||
match u_res {
|
||||
Ok(u) => u,
|
||||
Err(_) => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
let quotas = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT max_urls, max_landings, max_api_tokens, max_storage_mb, current_urls, current_landings, current_api_tokens, current_storage_mb \
|
||||
FROM quotas WHERE user_id = ?1;",
|
||||
[id],
|
||||
|row| Ok(crate::models::UserQuotas {
|
||||
user_id: id,
|
||||
max_urls: row.get(0)?,
|
||||
max_landings: row.get(1)?,
|
||||
max_api_tokens: row.get(2)?,
|
||||
max_storage_mb: row.get(3)?,
|
||||
current_urls: row.get(4)?,
|
||||
current_landings: row.get(5)?,
|
||||
current_api_tokens: row.get(6)?,
|
||||
current_storage_mb: row.get(7)?,
|
||||
})
|
||||
).unwrap_or(crate::models::UserQuotas {
|
||||
user_id: id,
|
||||
max_urls: 100,
|
||||
max_landings: 10,
|
||||
max_api_tokens: 5,
|
||||
max_storage_mb: 100,
|
||||
current_urls: 0,
|
||||
current_landings: 0,
|
||||
current_api_tokens: 0,
|
||||
current_storage_mb: 0,
|
||||
})
|
||||
};
|
||||
|
||||
let csrf_token = generate_csrf_token(&session_id);
|
||||
|
||||
let template = crate::templates::UserEditTemplate {
|
||||
admin_username: user.username,
|
||||
target_user,
|
||||
quotas,
|
||||
csrf_token,
|
||||
success: params.get("success").cloned(),
|
||||
error: params.get("error").cloned(),
|
||||
};
|
||||
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct UserEditForm {
|
||||
pub account_type: String,
|
||||
pub metadata: String,
|
||||
pub max_urls: i64,
|
||||
pub max_landings: i64,
|
||||
pub max_api_tokens: i64,
|
||||
pub max_storage_mb: i64,
|
||||
pub csrf_token: String,
|
||||
}
|
||||
|
||||
// POST /admin/users/:id/edit
|
||||
pub async fn user_edit_post(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
Path(id): Path<i64>,
|
||||
Form(form): Form<UserEditForm>,
|
||||
) -> Response {
|
||||
let (_user, session_id) = match require_auth(&state, &jar).await {
|
||||
Ok(u) => u,
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
if !verify_csrf(&session_id, &form.csrf_token) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users/{}/edit?error=Invalid CSRF token",
|
||||
id
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE users SET account_type = ?1, metadata = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![form.account_type, form.metadata, id],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5) \
|
||||
ON CONFLICT(user_id) DO UPDATE SET \
|
||||
max_urls = excluded.max_urls, \
|
||||
max_landings = excluded.max_landings, \
|
||||
max_api_tokens = excluded.max_api_tokens, \
|
||||
max_storage_mb = excluded.max_storage_mb;",
|
||||
rusqlite::params![
|
||||
id,
|
||||
form.max_urls,
|
||||
form.max_landings,
|
||||
form.max_api_tokens,
|
||||
form.max_storage_mb
|
||||
],
|
||||
);
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/users/{}?success=User updated successfully",
|
||||
id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
@@ -99,34 +99,23 @@ pub async fn api_create_url(
|
||||
}
|
||||
}
|
||||
|
||||
let mut dest = payload.destination.trim().to_string();
|
||||
if let Ok(mut parsed) = reqwest::Url::parse(&dest) {
|
||||
let mut has_utm = false;
|
||||
{
|
||||
let mut query = parsed.query_pairs_mut();
|
||||
if let Some(ref src) = payload.utm_source {
|
||||
if !src.trim().is_empty() {
|
||||
query.append_pair("utm_source", src.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref med) = payload.utm_medium {
|
||||
if !med.trim().is_empty() {
|
||||
query.append_pair("utm_medium", med.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
if let Some(ref camp) = payload.utm_campaign {
|
||||
if !camp.trim().is_empty() {
|
||||
query.append_pair("utm_campaign", camp.trim());
|
||||
has_utm = true;
|
||||
}
|
||||
}
|
||||
let dest = match crate::services::urls::prepare_destination(
|
||||
&payload.destination,
|
||||
crate::services::urls::UtmParams {
|
||||
source: payload.utm_source.as_deref(),
|
||||
medium: payload.utm_medium.as_deref(),
|
||||
campaign: payload.utm_campaign.as_deref(),
|
||||
},
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({ "error": msg })),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if has_utm {
|
||||
dest = parsed.to_string();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let password_hash = if let Some(ref pwd) = payload.password {
|
||||
if pwd.is_empty() {
|
||||
@@ -353,6 +342,15 @@ pub async fn api_update_url(
|
||||
Json(payload): Json<UpdateUrlRequest>,
|
||||
) -> Response {
|
||||
let tags = payload.tags.unwrap_or_default();
|
||||
if !crate::utils::validation::validate_redirect_destination(&payload.destination) {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(serde_json::json!({
|
||||
"error": "Destination must be a valid http(s) URL without control characters"
|
||||
})),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match update_url(
|
||||
&conn,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::auth::ApiUser;
|
||||
use crate::services::bulk_urls::{
|
||||
create_urls_bulk, ensure_url_quota, BulkUrlCreateItem, BulkUrlError,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
use crate::utils::{get_client_ip, lock_db};
|
||||
use axum::{
|
||||
extract::{ConnectInfo, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
@@ -68,7 +69,18 @@ pub async fn api_bulk_qr(
|
||||
// Retrieve URLs from database
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = match lock_db(&state.content_db, "content_db") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: e.to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
for id in &payload.ids {
|
||||
match crate::db::content::get_url_by_id(&conn, id) {
|
||||
Ok(Some(url)) => urls.push(url),
|
||||
@@ -115,9 +127,8 @@ pub async fn api_bulk_qr(
|
||||
// Generate ZIP
|
||||
match crate::services::bulk::export_qr_zip(&urls, &format, &base_url) {
|
||||
Ok(zip_data) => {
|
||||
// Write Audit Log
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
// Write Audit Log (best-effort; do not fail the download on audit lock poison)
|
||||
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
user.0.username(),
|
||||
@@ -147,6 +158,16 @@ pub async fn api_bulk_qr(
|
||||
}
|
||||
}
|
||||
|
||||
fn bulk_url_error_response(err: BulkUrlError) -> Response {
|
||||
let (status, msg) = match &err {
|
||||
BulkUrlError::BadRequest(m) => (StatusCode::BAD_REQUEST, m.clone()),
|
||||
BulkUrlError::Conflict(m) => (StatusCode::CONFLICT, m.clone()),
|
||||
BulkUrlError::Forbidden(m) => (StatusCode::FORBIDDEN, m.clone()),
|
||||
BulkUrlError::Internal(m) => (StatusCode::INTERNAL_SERVER_ERROR, m.clone()),
|
||||
};
|
||||
(status, Json(BulkErrorResponse { error: msg })).into_response()
|
||||
}
|
||||
|
||||
// POST /api/v1/bulk/url
|
||||
pub async fn api_bulk_url(
|
||||
State(state): State<AppState>,
|
||||
@@ -185,214 +206,39 @@ pub async fn api_bulk_url(
|
||||
}
|
||||
};
|
||||
|
||||
// Check quota
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Some(quotas) =
|
||||
crate::db::users::get_user_quotas(&users_conn, target_user_id).unwrap_or(None)
|
||||
{
|
||||
if quotas.current_urls + (payload.len() as i64) > quotas.max_urls {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Quota limit exceeded".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(BulkErrorResponse {
|
||||
error: "User quota not found".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = ensure_url_quota(&state.users_db, target_user_id, payload.len() as i64) {
|
||||
return bulk_url_error_response(e);
|
||||
}
|
||||
|
||||
let mut conn = content_db.lock().unwrap();
|
||||
let tx = match conn.transaction() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to start database transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
let items: Vec<BulkUrlCreateItem> = payload
|
||||
.into_iter()
|
||||
.map(|item| BulkUrlCreateItem {
|
||||
destination: item.destination,
|
||||
code: item.code,
|
||||
title: item.title,
|
||||
description: item.description,
|
||||
tags: item.tags,
|
||||
expires_at: item.expires_at,
|
||||
password: item.password,
|
||||
max_access_count: item.max_access_count,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let created_urls = match create_urls_bulk(
|
||||
&content_db,
|
||||
&state.system_db,
|
||||
&state.users_db,
|
||||
target_user_id,
|
||||
items,
|
||||
) {
|
||||
Ok(urls) => urls,
|
||||
Err(e) => return bulk_url_error_response(e),
|
||||
};
|
||||
|
||||
let mut created_urls = Vec::new();
|
||||
let mut reserved_slugs: Vec<String> = Vec::new();
|
||||
|
||||
for item in payload {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3); // 6 hex
|
||||
} else {
|
||||
if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
let _ = tx.rollback();
|
||||
// Release reserving slugs
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' must be 6 hex characters", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Reserve slug
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
// Check availability in system.db and also check in our currently reserved slugs in this batch
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code)
|
||||
.unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
|
||||
if !available {
|
||||
let _ = tx.rollback();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Short code '{}' already exists", code),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
let _ = tx.rollback();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to reserve slug '{}': {}", code, e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
reserved_slugs.push(code.clone());
|
||||
}
|
||||
|
||||
let password_hash = if let Some(ref pwd) = item.password {
|
||||
match hash_password(pwd) {
|
||||
Ok(h) => Some(h),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&system_conn,
|
||||
slug,
|
||||
target_user_id,
|
||||
);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Password hashing error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let tags = item.tags.unwrap_or_default();
|
||||
match crate::db::content::create_url_extended(
|
||||
&tx,
|
||||
&code,
|
||||
&item.destination,
|
||||
item.title.as_deref(),
|
||||
item.description.as_deref(),
|
||||
&tags,
|
||||
item.expires_at.as_deref(),
|
||||
password_hash.as_deref(),
|
||||
item.max_access_count,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ =
|
||||
crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Database insert error: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = tx.commit() {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for slug in &reserved_slugs {
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, slug, target_user_id);
|
||||
}
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: format!("Failed to commit transaction: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
for url in &created_urls {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), url.code],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Increment quota counters
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
for _ in 0..created_urls.len() {
|
||||
let _ = crate::db::users::increment_quota_counter(&users_conn, target_user_id, "urls");
|
||||
}
|
||||
}
|
||||
|
||||
// Write Audit Log for the entire batch
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
{
|
||||
let system_conn = state.db.system.lock().unwrap();
|
||||
if let Ok(system_conn) = lock_db(&state.db.system, "system_db") {
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
user.0.username(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
use axum::{
|
||||
extract::Path,
|
||||
http::{header, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::path::{Component, PathBuf};
|
||||
|
||||
const IMAGES_DIR: &str = "/app/images";
|
||||
|
||||
pub async fn image_handler(Path(path): Path<String>) -> Response {
|
||||
// preview.png is reserved for social-media metadata.
|
||||
// It is served from /app/www/images/preview.png,
|
||||
// not from the persistent application image directory.
|
||||
if path == "preview.png" {
|
||||
return crate::web::pages::social_preview().await;
|
||||
}
|
||||
|
||||
let relative = PathBuf::from(&path);
|
||||
|
||||
// Prevent path traversal.
|
||||
if relative.components().any(|component| {
|
||||
matches!(
|
||||
component,
|
||||
Component::ParentDir | Component::RootDir | Component::Prefix(_)
|
||||
)
|
||||
}) {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid image path").into_response();
|
||||
}
|
||||
|
||||
let image_path = PathBuf::from(IMAGES_DIR).join(relative);
|
||||
|
||||
let content = match tokio::fs::read(&image_path).await {
|
||||
Ok(content) => content,
|
||||
Err(_) => {
|
||||
return (StatusCode::NOT_FOUND, "Image not found").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let content_type = match image_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.as_deref()
|
||||
{
|
||||
Some("png") => "image/png",
|
||||
Some("jpg") | Some("jpeg") => "image/jpeg",
|
||||
Some("gif") => "image/gif",
|
||||
Some("webp") => "image/webp",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("avif") => "image/avif",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
|
||||
let content_type = HeaderValue::from_static(content_type);
|
||||
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, content_type),
|
||||
(
|
||||
header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("public, max-age=86400"),
|
||||
),
|
||||
],
|
||||
content,
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
@@ -11,3 +11,4 @@ pub mod routes;
|
||||
pub mod system;
|
||||
|
||||
pub use routes::create_router;
|
||||
pub mod images;
|
||||
@@ -181,3 +181,51 @@ pub async fn deploy_script() -> Response {
|
||||
Err(_) => (StatusCode::NOT_FOUND, "deploy.sh not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn social_preview() -> Response {
|
||||
let mut target_path = std::path::PathBuf::from("www/images/preview.png");
|
||||
|
||||
if !target_path.exists() {
|
||||
if let Ok(exe_path) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = exe_path.parent() {
|
||||
let path1 = exe_dir.join("www/images/preview.png");
|
||||
if path1.exists() {
|
||||
target_path = path1;
|
||||
} else if let Some(parent1) = exe_dir.parent() {
|
||||
let path2 = parent1.join("www/images/preview.png");
|
||||
if path2.exists() {
|
||||
target_path = path2;
|
||||
} else if let Some(parent2) = parent1.parent() {
|
||||
let path3 = parent2.join("www/images/preview.png");
|
||||
if path3.exists() {
|
||||
target_path = path3;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !target_path.exists() {
|
||||
if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") {
|
||||
let path = std::path::PathBuf::from(manifest_dir).join("www/images/preview.png");
|
||||
if path.exists() {
|
||||
target_path = path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match tokio::fs::read(target_path).await {
|
||||
Ok(content) => (
|
||||
StatusCode::OK,
|
||||
[
|
||||
("content-type", "image/png"),
|
||||
("cache-control", "public, max-age=86400"),
|
||||
],
|
||||
content,
|
||||
)
|
||||
.into_response(),
|
||||
|
||||
Err(_) => (StatusCode::NOT_FOUND, "preview.png not found").into_response(),
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
Path(code): Path<String>,
|
||||
jar: CookieJar,
|
||||
headers: axum::http::HeaderMap,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
@@ -55,8 +56,9 @@ pub async fn gate_post(
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
let cookie_name = format!("bzod_gate_{}", code);
|
||||
let secure_flag = crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build((cookie_name, "authorized"))
|
||||
.secure(state.config.cookie_secure)
|
||||
.secure(secure_flag)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.http_only(true)
|
||||
.path("/")
|
||||
|
||||
@@ -1,20 +1,298 @@
|
||||
//! Public short-code redirect hot path.
|
||||
//!
|
||||
//! Design notes:
|
||||
//! - Destination `Location` headers never panic on malformed values.
|
||||
//! - Content DB work uses a single mutex acquisition where safe.
|
||||
//! - Expiration is enforced on the read path; persistent `expired=1` is left to
|
||||
//! the background expiry job (`jobs::expiry`), not written here.
|
||||
//! - Blocking rusqlite work runs in `spawn_blocking` so Tokio workers are not starved.
|
||||
//! - Analytics enqueue remains non-blocking (`try_send` via the queue).
|
||||
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Path, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{header, HeaderMap, HeaderValue, StatusCode},
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use axum_extra::extract::CookieJar;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::{error, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||
enum ResolveOutcome {
|
||||
Ready(Box<ResolvedUrl>),
|
||||
/// Early HTTP response that does not need further processing.
|
||||
Early {
|
||||
status: StatusCode,
|
||||
body: &'static str,
|
||||
},
|
||||
/// Permanent redirect to a relative path (e.g. page target → `/p/{code}`).
|
||||
PermanentPath(String),
|
||||
DbError {
|
||||
operation: &'static str,
|
||||
message: String,
|
||||
owner_user_id: Option<i64>,
|
||||
resource_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Safe client-facing DB error after structured server-side logging.
|
||||
fn db_error_response(
|
||||
operation: &str,
|
||||
code: &str,
|
||||
owner_user_id: Option<i64>,
|
||||
resource_id: Option<&str>,
|
||||
err: impl std::fmt::Display,
|
||||
) -> Response {
|
||||
error!(
|
||||
operation = operation,
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = resource_id.unwrap_or(""),
|
||||
error = %err,
|
||||
"redirect path database error"
|
||||
);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
|
||||
/// Build a permanent redirect without panicking on invalid destinations.
|
||||
///
|
||||
/// Defense in depth:
|
||||
/// 1. Canonical destination rules (http/https, no control chars) — same as writes.
|
||||
/// 2. `HeaderValue` construction — rejects remaining illegal header bytes.
|
||||
///
|
||||
/// Neither step may panic. Full destination values are not logged.
|
||||
fn permanent_redirect_to(destination: &str, code: &str) -> Response {
|
||||
if !crate::utils::validation::validate_redirect_destination(destination) {
|
||||
warn!(
|
||||
operation = "validate_redirect_destination",
|
||||
code = code,
|
||||
destination_len = destination.len(),
|
||||
"invalid stored redirect destination rejected"
|
||||
);
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid redirect destination",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
match HeaderValue::from_str(destination) {
|
||||
Ok(loc) => {
|
||||
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||
resp.headers_mut().insert(header::LOCATION, loc);
|
||||
resp
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
operation = "build_location_header",
|
||||
code = code,
|
||||
error = %err,
|
||||
// Do not log the full destination if it may contain control chars;
|
||||
// log length only for forensics.
|
||||
destination_len = destination.len(),
|
||||
"invalid redirect destination rejected (possible response-splitting attempt)"
|
||||
);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid redirect destination",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of the initial global-slug + URL resolution phase.
|
||||
struct ResolvedUrl {
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Lookup global slug namespace then load the URL from the tenant content DB.
|
||||
/// Runs entirely on a blocking thread.
|
||||
fn resolve_url_blocking(
|
||||
system_db: Arc<Mutex<rusqlite::Connection>>,
|
||||
state: AppState,
|
||||
code: &str,
|
||||
) -> ResolveOutcome {
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = match system_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_system_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "prepare_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match stmt
|
||||
.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
Some(info) => info,
|
||||
None => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::GONE,
|
||||
body: "This content has been disabled or moderated",
|
||||
};
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
return ResolveOutcome::PermanentPath(format!("/p/{}", code));
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = match content_conn.content.lock() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "lock_content_db",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
match crate::db::content::get_url_by_code(&conn, code) {
|
||||
Ok(Some(url)) => url,
|
||||
Ok(None) => {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "Short code not found",
|
||||
};
|
||||
}
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_url_by_code",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||
owner_user_id,
|
||||
url,
|
||||
content: content_conn.content,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Increment access count and load preview under a single content-DB lock.
|
||||
fn increment_and_preview_blocking(
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
url_id: &str,
|
||||
code: &str,
|
||||
owner_user_id: i64,
|
||||
fallback_access_count: i64,
|
||||
) -> Result<(i64, Option<LinkPreview>), String> {
|
||||
let conn = content
|
||||
.lock()
|
||||
.map_err(|e| format!("lock_content_db_hot: {}", e))?;
|
||||
|
||||
let new_access_count = match crate::db::content::increment_access_count(&conn, url_id) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
// Preserve prior soft-failure semantics for the counter value used only
|
||||
// internally; never silence the underlying error.
|
||||
error!(
|
||||
operation = "increment_access_count",
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = url_id,
|
||||
error = %e,
|
||||
"failed to increment access count; continuing with estimated value"
|
||||
);
|
||||
fallback_access_count + 1
|
||||
}
|
||||
};
|
||||
|
||||
let preview = match crate::db::preview::get_preview(&conn, url_id) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
error!(
|
||||
operation = "get_preview",
|
||||
code = code,
|
||||
owner_user_id = owner_user_id,
|
||||
resource_id = url_id,
|
||||
error = %e,
|
||||
"failed to load link preview; continuing without preview"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok((new_access_count, preview))
|
||||
}
|
||||
|
||||
// GET /:code
|
||||
// Resolve and redirect
|
||||
pub async fn resolve_redirect(
|
||||
@@ -31,69 +309,51 @@ pub async fn resolve_redirect(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// 1. Query global slug namespace in system.db
|
||||
let slug_info = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;"
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
stmt.query_row(rusqlite::params![code], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
};
|
||||
let system_db = state.system_db.clone();
|
||||
let state_for_lookup = state.clone();
|
||||
let code_for_lookup = code.clone();
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "url".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
return Redirect::permanent(&format!("/p/{}", code)).into_response();
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
match crate::db::content::get_url_by_code(&conn, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
let outcome = match tokio::task::spawn_blocking(move || {
|
||||
resolve_url_blocking(system_db, state_for_lookup, &code_for_lookup)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => outcome,
|
||||
Err(e) => {
|
||||
return db_error_response("spawn_blocking_resolve", &code, None, None, e.to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (StatusCode::NOT_FOUND, "Short code not found").into_response(),
|
||||
let resolved = match outcome {
|
||||
ResolveOutcome::Ready(r) => r,
|
||||
ResolveOutcome::Early { status, body } => return (status, body).into_response(),
|
||||
ResolveOutcome::PermanentPath(path) => {
|
||||
return Redirect::permanent(&path).into_response();
|
||||
}
|
||||
ResolveOutcome::DbError {
|
||||
operation,
|
||||
message,
|
||||
owner_user_id,
|
||||
resource_id,
|
||||
} => {
|
||||
return db_error_response(
|
||||
operation,
|
||||
&code,
|
||||
owner_user_id,
|
||||
resource_id.as_deref(),
|
||||
message,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Expiration check
|
||||
let ResolvedUrl {
|
||||
owner_user_id,
|
||||
url,
|
||||
content,
|
||||
} = *resolved;
|
||||
|
||||
// 3. Expiration check (read-only on the hot path).
|
||||
// Background job `jobs::expiry::run_expiry_checker` persists expired=1.
|
||||
if url.expired {
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
@@ -101,14 +361,6 @@ pub async fn resolve_redirect(
|
||||
if let Some(ref expires_at_str) = url.expires_at {
|
||||
if let Ok(expires_at) = chrono::DateTime::parse_from_rfc3339(expires_at_str) {
|
||||
if expires_at.with_timezone(&Utc) < Utc::now() {
|
||||
// Mark as expired in DB asynchronously/immediately
|
||||
{
|
||||
let conn = content_conn.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET expired = 1 WHERE id = ?1;",
|
||||
[url.id.clone()],
|
||||
);
|
||||
}
|
||||
return (StatusCode::GONE, "This link has expired").into_response();
|
||||
}
|
||||
}
|
||||
@@ -136,15 +388,40 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Increment access count & retrieve preview config
|
||||
let _new_access_count = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
crate::db::content::increment_access_count(&conn, &url.id).unwrap_or(url.access_count + 1)
|
||||
};
|
||||
|
||||
let preview_opt = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
crate::db::preview::get_preview(&conn, &url.id).unwrap_or(None)
|
||||
// 6. Increment access count & retrieve preview config (single content lock, off executor)
|
||||
let url_id = url.id.clone();
|
||||
let code_for_hot = code.clone();
|
||||
let fallback_access_count = url.access_count;
|
||||
let preview_opt = match tokio::task::spawn_blocking(move || {
|
||||
increment_and_preview_blocking(
|
||||
content,
|
||||
&url_id,
|
||||
&code_for_hot,
|
||||
owner_user_id,
|
||||
fallback_access_count,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(Ok((_new_access_count, preview))) => preview,
|
||||
Ok(Err(msg)) => {
|
||||
return db_error_response(
|
||||
"increment_and_preview",
|
||||
&code,
|
||||
Some(owner_user_id),
|
||||
Some(&url.id),
|
||||
msg,
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return db_error_response(
|
||||
"spawn_blocking_hot",
|
||||
&code,
|
||||
Some(owner_user_id),
|
||||
Some(&url.id),
|
||||
e.to_string(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
@@ -195,14 +472,32 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
{
|
||||
use axum::http::{header, HeaderValue};
|
||||
let mut resp = (StatusCode::MOVED_PERMANENTLY, "").into_response();
|
||||
resp.headers_mut().insert(
|
||||
header::LOCATION,
|
||||
HeaderValue::from_str(&url.destination).unwrap(),
|
||||
);
|
||||
resp
|
||||
}
|
||||
permanent_redirect_to(&url.destination, &code)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_rejects_crlf() {
|
||||
let resp = permanent_redirect_to("https://evil.example/\r\nX-Injected: yes", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
assert!(resp.headers().get(header::LOCATION).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_rejects_control_chars() {
|
||||
let resp = permanent_redirect_to("https://evil.example/\x00payload", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn permanent_redirect_accepts_valid_url() {
|
||||
let resp = permanent_redirect_to("https://example.com/path?q=1", "abc123");
|
||||
assert_eq!(resp.status(), StatusCode::MOVED_PERMANENTLY);
|
||||
let loc = resp.headers().get(header::LOCATION).unwrap();
|
||||
assert_eq!(loc, "https://example.com/path?q=1");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
use crate::state::AppState;
|
||||
use crate::web::{admin, api, bulk, multi_user, pages, password_gate, qr, redirect, system};
|
||||
use crate::web::{
|
||||
admin, api, bulk, images, multi_user, pages, password_gate, qr, redirect, system,
|
||||
};
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
@@ -95,6 +97,7 @@ pub fn create_router(state: AppState) -> Router {
|
||||
.route("/admin/pages/delete/:id", post(admin::pages_delete))
|
||||
.route("/admin/analytics/url/:id", get(admin::url_analytics_get))
|
||||
.route("/deploy.sh", get(pages::deploy_script))
|
||||
.route("/images/*path", get(images::image_handler))
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/csv",
|
||||
get(admin::url_analytics_csv_export),
|
||||
|
||||
@@ -79,8 +79,8 @@ pub async fn status_endpoint(
|
||||
queue_size: 0,
|
||||
memory_usage: get_memory_usage(),
|
||||
uptime_seconds: uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
})
|
||||
.into_response()
|
||||
}
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Landing Page Form -->
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -24,7 +23,7 @@
|
||||
<form action="/admin/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
@@ -36,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem;">
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
@@ -49,7 +48,7 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state">
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
@@ -59,16 +58,15 @@
|
||||
|
||||
<div class="form-group">
|
||||
<label for="html_content">Raw HTML Document *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="font-family: monospace; font-size: 0.85rem; height: 300px; background-color: #05070f; border-color: rgba(255,255,255,0.05);" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>Landing Page</title> </head> <body> <h1>Welcome!</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Landing Pages Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color);">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
Registered Pages
|
||||
@@ -76,69 +74,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -147,8 +151,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
@@ -4,6 +4,14 @@
|
||||
|
||||
{% block active_urls %}active{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
@media (max-width: 720px) {
|
||||
#utm_fields > div {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
{% endblock %}
|
||||
|
||||
{% block header_title %}Short URL Registry{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -13,9 +21,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<!-- Create Link Form -->
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Shorten a New URL
|
||||
@@ -97,9 +104,8 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Links Table -->
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
Registered Links
|
||||
@@ -113,7 +119,15 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -121,108 +135,112 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No shortened URLs registered. Create one to get started!
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center; gap: 0.25rem;">
|
||||
<a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace; font-size: 1rem;">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" style="color: #fbbf24; display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="font-size: 0.8rem; color: var(--text-secondary); margin-top: 0.25rem; font-weight: 500;">
|
||||
{{ url.title.as_deref().unwrap_or("") }}
|
||||
</div>
|
||||
</td>
|
||||
<td style="max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
|
||||
<a href="{{ url.destination }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem;" title="{{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); margin-top: 0.25rem;">
|
||||
{{ desc }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div style="font-size: 0.75rem; color: #fca5a5; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div style="font-size: 0.75rem; color: #38bdf8; margin-top: 0.25rem; display: flex; align-items: center; gap: 0.25rem;">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div style="font-size: 0.75rem; color: var(--text-secondary); margin-top: 0.25rem;">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div style="display: flex; gap: 0.25rem; flex-wrap: wrap; max-width: 150px;">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge" style="background-color: rgba(255,255,255,0.05); color: var(--text-secondary); text-decoration: none; font-size: 0.7rem; border: 1px solid var(--border-color);">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/admin/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/admin/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ url.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/admin/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -230,8 +248,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination Controls -->
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/admin/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;"><< First</a>
|
||||
<a href="/admin/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem; font-size: 0.85rem;">< Prev</a>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
@@ -23,40 +23,44 @@
|
||||
<form action="/user/pages/create" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
<div class="page-form-grid-2">
|
||||
<div class="form-group">
|
||||
<label for="title">Page Title *</label>
|
||||
<input type="text" id="title" name="title" class="form-input" placeholder="e.g. Summer Campaign" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="slug">SEO Slug *</label>
|
||||
<input type="text" id="slug" name="slug" class="form-input" placeholder="e.g. summer-promo" required pattern="[a-zA-Z0-9\-_]+" title="Only alphanumeric characters, dashes, and underscores allowed">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
<div class="page-form-grid-3">
|
||||
<div class="form-group">
|
||||
<label for="code">Short Code (4-Hex, optional)</label>
|
||||
<input type="text" id="code" name="code" class="form-input" placeholder="e.g. a1b2" pattern="[0-9a-fA-F]{4}" title="Must be exactly 4 hex characters">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="custom_slug">Custom Slug (optional)</label>
|
||||
<input type="text" id="custom_slug" name="custom_slug" class="form-input" placeholder="e.g. !my-page" pattern="![a-z0-9\-_]{1,24}" title="Must start with ! followed by 1-24 characters (a-z, 0-9, -, _)">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="state">Publish State</label>
|
||||
<select id="state" name="state" class="form-input">
|
||||
<option value="draft">Draft</option>
|
||||
<option value="published" selected>Published</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="html_content">HTML Content *</label>
|
||||
<textarea id="html_content" name="html_content" class="form-input" style="height: 260px; font-family: monospace;" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
<textarea id="html_content" name="html_content" class="form-input page-html-editor" placeholder="<!DOCTYPE html> <html> <head> <title>My Page</title> </head> <body> <h1>Hello</h1> </body> </html>" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn">Save Page</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Save Page</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
|
||||
My Landing Pages
|
||||
@@ -64,69 +68,75 @@
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-pages-table">
|
||||
<colgroup>
|
||||
<col class="col-page-title">
|
||||
<col class="col-page-paths">
|
||||
<col class="col-page-status">
|
||||
<col class="col-page-qr">
|
||||
<col class="col-page-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page Title</th>
|
||||
<th>Short Path</th>
|
||||
<th>SEO Preview Path</th>
|
||||
<th>Page</th>
|
||||
<th>Paths</th>
|
||||
<th>Status</th>
|
||||
<th>Analytics</th>
|
||||
<th>QR Code</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if pages.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="8" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="5" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No landing pages created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for page in pages %}
|
||||
<tr>
|
||||
<td>
|
||||
<div style="font-weight: 700; color: var(--text-primary);">
|
||||
{{ page.title }}
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: var(--text-muted); font-family: monospace;">
|
||||
UUID: {{ page.id[0..8] }}...
|
||||
<td data-label="Page">
|
||||
<div class="page-title-block">
|
||||
<div class="page-title-heading">{{ page.title }}</div>
|
||||
<div class="page-title-meta">UUID: {{ page.id[0..8] }}...</div>
|
||||
<div class="page-title-meta">Created {{ page.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}" target="_blank" style="color: var(--accent-color); font-weight: 700; text-decoration: none; font-family: monospace;">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
<td data-label="Paths">
|
||||
<div class="page-paths">
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">Short Path</span>
|
||||
<a href="/p/{{ page.code }}" target="_blank" class="page-path-link">
|
||||
/p/{{ page.code }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="page-path-group">
|
||||
<span class="page-path-caption">SEO Preview Path</span>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" class="page-path-link secondary">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/p/{{ page.code }}/{{ page.slug }}" target="_blank" style="color: var(--text-secondary); text-decoration: none; font-size: 0.85rem; font-family: monospace;">
|
||||
/p/{{ page.code }}/{{ page.slug }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Status" class="status-cell">
|
||||
<span class="badge badge-{{ page.state }}">
|
||||
{{ page.state }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
</td>
|
||||
{% let code = page.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td style="font-size: 0.8rem; color: var(--text-secondary);">
|
||||
{{ page.created_at[0..10] }}
|
||||
</td>
|
||||
<td>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/page/{{ page.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.6rem; font-size: 0.8rem; display: inline-flex; align-items: center; gap: 0.25rem;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/pages/delete/{{ page.id }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this page?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.6rem; font-size: 0.8rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -135,7 +145,7 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/pages?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/pages?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div style="display: grid; grid-template-columns: 1fr 2fr; gap: 1.5rem; align-items: start;">
|
||||
<div class="card">
|
||||
<div class="urls-dashboard">
|
||||
<div class="card url-form-card">
|
||||
<h3 style="font-size: 1.1rem; margin-bottom: 1.25rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
Create a New URL
|
||||
@@ -47,20 +47,34 @@
|
||||
<label for="tags">Tags (comma-separated, optional)</label>
|
||||
<input type="text" id="tags" name="tags" class="form-input" placeholder="e.g. blog, tech, personal">
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Link</button>
|
||||
<button type="submit" class="btn" style="width: 100%; margin-top: 0.5rem;">Create Link</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card" style="padding: 0; overflow: hidden;">
|
||||
<div style="padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border-color); display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="card registered-links-shell" style="padding: 0; overflow: hidden;">
|
||||
<div class="registered-links-header">
|
||||
<h3 style="font-size: 1.1rem; display: flex; align-items: center; gap: 0.5rem;">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
|
||||
My Short Links
|
||||
</h3>
|
||||
{% if let Some(tag) = tag_filter %}
|
||||
<span class="badge badge-healthy" style="text-transform: none;">
|
||||
Filtered by tag: {{ tag }}
|
||||
<a href="/user/urls" style="color: inherit; text-decoration: none; margin-left: 0.5rem; font-weight: bold;">×</a>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<table class="registered-links-table">
|
||||
<colgroup>
|
||||
<col class="col-short">
|
||||
<col class="col-destination">
|
||||
<col class="col-qr">
|
||||
<col class="col-health">
|
||||
<col class="col-tags">
|
||||
<col class="col-actions">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Short Link</th>
|
||||
@@ -68,36 +82,111 @@
|
||||
<th>QR Code</th>
|
||||
<th>Health</th>
|
||||
<th>Tags</th>
|
||||
<th>Analytics</th>
|
||||
<th>Created</th>
|
||||
<th>Action</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% if urls.is_empty() %}
|
||||
<tr>
|
||||
<td colspan="9" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
<td colspan="6" style="text-align: center; color: var(--text-secondary); padding: 3rem;">
|
||||
No short URLs created yet.
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
{% for url in urls %}
|
||||
<tr>
|
||||
<td><a href="/{{ url.code }}" target="_blank" style="color: var(--accent-color); text-decoration: none; font-family: monospace;">/{{ url.code }}</a></td>
|
||||
<td style="max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ url.destination }}</td>
|
||||
<td data-label="Short Link">
|
||||
<div class="short-link-wrap">
|
||||
<div class="short-link-primary">
|
||||
<a href="/{{ url.code }}" target="_blank" class="short-link-anchor">
|
||||
{{ base_url.replace("https://", "").replace("http://", "") }}/{{ url.code }}
|
||||
</a>
|
||||
{% if url.is_password_protected() %}
|
||||
<span title="Password Protected" aria-label="Password protected link" style="color: var(--warning-color); display: inline-flex; align-items: center;">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2" ry="2"></rect><path d="M7 11V7a5 5 0 0 1 10 0v4"></path></svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if let Some(title) = url.title.as_deref() %}
|
||||
{% if !title.is_empty() %}
|
||||
<div class="short-link-title">{{ title }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="short-link-meta">Created {{ url.created_at[0..10] }}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Destination">
|
||||
<div class="destination-block">
|
||||
<a href="{{ url.destination }}" target="_blank" class="destination-link" title="{{ url.destination }}" aria-label="Destination URL: {{ url.destination }}">
|
||||
{{ url.destination }}
|
||||
</a>
|
||||
{% if let Some(desc) = url.description.as_deref() %}
|
||||
{% if !desc.is_empty() %}
|
||||
<div class="destination-description" title="{{ desc }}">{{ desc }}</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(expires_at) = url.expires_at.as_deref() %}
|
||||
{% if !expires_at.is_empty() %}
|
||||
<div class="destination-meta expiry">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>
|
||||
Expires: {{ expires_at[0..10] }} {{ expires_at[11..16] }}
|
||||
{% if url.expired %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Expired</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% if let Some(max) = url.max_access_count %}
|
||||
<div class="destination-meta access">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>
|
||||
Clicks: {{ url.access_count }} / {{ max }}
|
||||
{% if url.is_access_exhausted() %}
|
||||
<span class="badge badge-dead" style="font-size: 0.6rem; padding: 0.1rem 0.25rem; margin-left: 0.25rem;">Limit Reached</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
{% if url.access_count > 0 %}
|
||||
<div class="destination-meta neutral">
|
||||
Clicks: {{ url.access_count }}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
{% let code = url.code.as_str() %}
|
||||
{% include "components/qr_preview.html" %}
|
||||
<td>{{ url.status }}</td>
|
||||
<td>{% if url.tags.is_empty() %}-{% else %}{{ url.tags.join(", ") }}{% endif %}</td>
|
||||
<td>
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">View</a>
|
||||
<td data-label="Health" class="status-cell">
|
||||
<span class="badge badge-{{ url.status }}">
|
||||
{{ url.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ url.created_at[0..10] }}</td>
|
||||
<td>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-secondary" style="padding: 0.4rem 0.75rem;">Delete</button>
|
||||
</form>
|
||||
<td data-label="Tags">
|
||||
{% if url.tags.is_empty() %}
|
||||
<span class="tags-empty">—</span>
|
||||
{% else %}
|
||||
<div class="tags-wrap">
|
||||
{% for t in url.tags %}
|
||||
<a href="/user/urls?tag={{ t }}" class="badge tag-badge">
|
||||
{{ t }}
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<div class="action-stack">
|
||||
<a href="/user/analytics/url/{{ url.id }}" class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; text-decoration: none; display: inline-flex; align-items: center; justify-content: center;">
|
||||
📊 Analytics
|
||||
</a>
|
||||
<form action="/user/urls/delete/{{ url.id }}" method="POST" onsubmit="return confirm('Delete this link?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
<button type="submit" class="btn btn-danger" style="padding: 0.4rem 0.75rem;">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -106,10 +195,10 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="pagination" style="display: flex; justify-content: center; align-items: center; gap: 0.5rem; margin-top: 1rem; padding: 1rem; border-top: 1px solid var(--border-color);">
|
||||
<div class="pagination">
|
||||
{% if current_page > 1 %}
|
||||
<a href="/user/urls?page=1" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
<a href="/user/urls?page=1{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;"><< First</a>
|
||||
<a href="/user/urls?page={{ current_page - 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">< Prev</a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;"><< First</span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">< Prev</span>
|
||||
@@ -119,13 +208,13 @@
|
||||
{% if self.is_current(p) %}
|
||||
<span class="btn" style="padding: 0.4rem 0.8rem; font-weight: 700;">{{ p }}</span>
|
||||
{% else %}
|
||||
<a href="/user/urls?page={{ p }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
<a href="/user/urls?page={{ p }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">{{ p }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if current_page < total_pages %}
|
||||
<a href="/user/urls?page={{ current_page + 1 }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
<a href="/user/urls?page={{ current_page + 1 }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Next ></a>
|
||||
<a href="/user/urls?page={{ total_pages }}{% if let Some(t) = tag_filter %}&tag={{ t }}{% endif %}" class="btn btn-secondary" style="padding: 0.4rem 0.8rem;">Last >></a>
|
||||
{% else %}
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Next ></span>
|
||||
<span class="btn btn-secondary" style="opacity: 0.5; cursor: not-allowed; padding: 0.4rem 0.8rem;">Last >></span>
|
||||
|
||||
@@ -28,7 +28,9 @@ fn create_temp_config(temp_dir: PathBuf) -> Config {
|
||||
|
||||
fn build_state(config: Config) -> (Db, AppState) {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
|
||||
@@ -44,7 +44,9 @@ async fn start_test_server(
|
||||
) -> (reqwest::Client, String, tokio::task::JoinHandle<()>, Db) {
|
||||
let config = create_temp_config(temp_dir);
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 100);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 100, rx);
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
|
||||
@@ -37,7 +37,9 @@ fn compute_sha256(value: &str) -> String {
|
||||
|
||||
fn build_state(config: Config) -> (Db, bzod::state::AppState) {
|
||||
let db = Db::init(&config).expect("Failed to init Db");
|
||||
let queue = AnalyticsQueue::new(db.clone(), 1000);
|
||||
let (tx, rx) = tokio::sync::watch::channel(false);
|
||||
Box::leak(Box::new(tx));
|
||||
let (queue, _) = AnalyticsQueue::new(db.clone(), 1000, rx);
|
||||
let state = bzod::state::AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
|
||||