Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb5d827322 | ||
|
|
feed352c2e | ||
|
|
e42d1a5d80 | ||
|
|
c2e138f823 | ||
|
|
d398341f01 | ||
|
|
d7e0ac7679 | ||
|
|
f138a645f8 | ||
|
|
ac66945c93 | ||
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 |
No files matched your search
@@ -1,17 +1,19 @@
|
||||
# BZOD Platform Configuration
|
||||
HOST=0.0.0.0
|
||||
PORT=8080
|
||||
DATA_DIR=./data
|
||||
# Physical BZOD data root.
|
||||
# REQUIRED: Set this explicitly; there is no implicit ./data fallback.
|
||||
NX9_BZOD_DATA_DIR=/var/lib/bzod/data
|
||||
|
||||
# Security Settings
|
||||
COOKIE_SECURE=false
|
||||
SESSION_SECRET=bzod-default-session-secret-change-me-in-production-please-do-it
|
||||
|
||||
# Bootstrap Admin Credentials
|
||||
# Default username: admin
|
||||
# Default password: admin
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD_SHA256=8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
|
||||
# REQUIRED for a fresh deployment.
|
||||
# Use a strong unique password.
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# Cron & Cleaner Intervals (in minutes)
|
||||
LINK_CHECK_INTERVAL_MINS=60
|
||||
|
||||
@@ -6,3 +6,7 @@ data/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
bzod.env
|
||||
|
||||
# Local database backups
|
||||
backups/
|
||||
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.7.0"
|
||||
version = "0.8.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.7.0"
|
||||
version = "0.8.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
|
||||
# Persistent runtime directories.
|
||||
# /app/images is intentionally external/persistent in Compose.
|
||||
RUN mkdir -p \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images && \
|
||||
chown -R bzod:bzod \
|
||||
/app/data \
|
||||
/app/config \
|
||||
/app/images \
|
||||
/app/templates \
|
||||
/app/www \
|
||||
/usr/local/bin/bzod \
|
||||
/usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
# Create data directory
|
||||
RUN mkdir -p /app/data && \
|
||||
chown -R bzod:bzod /app
|
||||
|
||||
USER bzod
|
||||
|
||||
ENV DATA_DIR=/app/data \
|
||||
# Runtime configuration
|
||||
ENV NX9_BZOD_DATA_DIR=/app/data \
|
||||
CONFIG_DIR=/app/config \
|
||||
IMAGES_DIR=/app/images \
|
||||
PORT=8654 \
|
||||
HOST=0.0.0.0 \
|
||||
COOKIE_SECURE=true
|
||||
|
||||
EXPOSE 8654
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT}/status || exit 1
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
--timeout=5s \
|
||||
--start-period=10s \
|
||||
--retries=3 \
|
||||
CMD curl -fsS "http://127.0.0.1:${PORT}/status" || exit 1
|
||||
|
||||
ENTRYPOINT ["bzod"]
|
||||
USER bzod
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["serve"]
|
||||
@@ -6,7 +6,7 @@
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
[](https://github.com/thakares/bzod)
|
||||
[](https://codeberg.org/thakares/bzod)
|
||||
@@ -93,7 +93,7 @@ No recurring subscription fees.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Efficiency (v0.7.0)
|
||||
## Runtime Efficiency (v0.8.0)
|
||||
|
||||
| Metric | Value |
|
||||
|---------|------:|
|
||||
@@ -168,9 +168,10 @@ services:
|
||||
- "8654:8654"
|
||||
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- /var/lib/bzod/data:/app/data
|
||||
|
||||
environment:
|
||||
- NX9_BZOD_DATA_DIR=/app/data
|
||||
- RUST_LOG=info
|
||||
```
|
||||
|
||||
@@ -182,6 +183,28 @@ docker compose up -d
|
||||
|
||||
---
|
||||
|
||||
# Data Directory & Configuration Precedence
|
||||
|
||||
BZOD requires an explicit physical data root. There is **no implicit `./data` fallback**.
|
||||
|
||||
Precedence:
|
||||
1. **CLI `--data-dir`** (Highest)
|
||||
2. **`NX9_BZOD_DATA_DIR`** environment variable
|
||||
3. **`config.toml` / `bzod.toml` `data_dir`** setting
|
||||
4. **Configuration Error** if no data directory is configured.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Via environment variable
|
||||
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
|
||||
|
||||
# Via CLI argument
|
||||
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Native Installation
|
||||
|
||||
Download the latest release.
|
||||
@@ -202,16 +225,16 @@ bzod --help
|
||||
|
||||
# Initialize
|
||||
|
||||
Create the administrator.
|
||||
Create the administrator (specifying `--data-dir` or `NX9_BZOD_DATA_DIR`):
|
||||
|
||||
```bash
|
||||
bzod create-admin
|
||||
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod create-admin
|
||||
```
|
||||
|
||||
Start the server.
|
||||
Start the server:
|
||||
|
||||
```bash
|
||||
bzod serve
|
||||
NX9_BZOD_DATA_DIR=/var/lib/bzod/data bzod serve
|
||||
```
|
||||
|
||||
Default server:
|
||||
@@ -234,6 +257,7 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
|
||||
Environment=NX9_BZOD_DATA_DIR=/var/lib/bzod/data
|
||||
ExecStart=/usr/local/bin/bzod serve
|
||||
|
||||
Restart=always
|
||||
@@ -1337,7 +1361,7 @@ Community feedback helps guide future development.
|
||||
|
||||
**Current Version**
|
||||
|
||||
**v0.7.0**
|
||||
**v0.8.0**
|
||||
|
||||
Production Ready
|
||||
|
||||
|
||||
@@ -1,251 +1,475 @@
|
||||
#!/usr/bin/env bash
|
||||
# BZOD Production Deployment Script
|
||||
#
|
||||
# BZOD Production Docker Deployment
|
||||
#
|
||||
# Privacy-First URL Shortener & Landing Page Platform
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# Or:
|
||||
# sudo bash deploy.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# BZOD_VERSION=0.8.0
|
||||
# BZOD_IMAGE=nx9-url-shortener
|
||||
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
||||
# BZOD_PORT=8654
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.7.0"
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
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"
|
||||
BZOD_VERSION="${BZOD_VERSION:-0.8.0}"
|
||||
BZOD_IMAGE="${BZOD_IMAGE:-nx9-url-shortener}"
|
||||
BZOD_ROOT="${BZOD_ROOT:-/DATA/AppData/nx9-url-shortener}"
|
||||
BZOD_PORT="${BZOD_PORT:-8654}"
|
||||
BASE_URL="${BASE_URL:-https://bzo.in}"
|
||||
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-bzod}"
|
||||
|
||||
NX9_BZOD_DATA_DIR="${BZOD_ROOT}/data"
|
||||
CONFIG_DIR="${BZOD_ROOT}/config"
|
||||
IMAGES_DIR="${BZOD_ROOT}/images"
|
||||
COMPOSE_DIR="${BZOD_ROOT}/compose"
|
||||
|
||||
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
|
||||
ENV_FILE="${COMPOSE_DIR}/bzod.env"
|
||||
BACKUP_ROOT="${BZOD_ROOT}/backups"
|
||||
|
||||
IMAGE="${BZOD_IMAGE}:${BZOD_VERSION}"
|
||||
|
||||
# ============================================================
|
||||
# Output
|
||||
# ============================================================
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
BLUE='\033[0;34m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
# Temporary file cleanup
|
||||
TMP_BINARY=""
|
||||
cleanup() {
|
||||
rm -f "${TMP_BINARY:-}" "${TMP_GHCR:-}"
|
||||
info() {
|
||||
echo -e "${BLUE}$*${NC}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
echo -e "${BLUE}=== BZOD - Privacy-First URL Shortener & Landing Page Platform ===${NC}"
|
||||
echo -e "Production deployment started...\n"
|
||||
success() {
|
||||
echo -e "${GREEN}$*${NC}"
|
||||
}
|
||||
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
echo -e "${RED}Error: This script must be run as root (use sudo).${NC}"
|
||||
warning() {
|
||||
echo -e "${YELLOW}$*${NC}"
|
||||
}
|
||||
|
||||
error() {
|
||||
echo -e "${RED}$*${NC}" >&2
|
||||
}
|
||||
|
||||
die() {
|
||||
error "$*"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ============================================================
|
||||
# Root check
|
||||
# ============================================================
|
||||
|
||||
if [[ "${EUID}" -ne 0 ]]; then
|
||||
die "This script must be run as root. Use: sudo bash deploy.sh"
|
||||
fi
|
||||
|
||||
# 1. Install Base Dependencies
|
||||
echo -e "${BLUE}[1/8] Installing base system dependencies...${NC}"
|
||||
echo
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo -e "${BLUE} BZOD — Production Docker Deployment${NC}"
|
||||
echo -e "${BLUE}============================================================${NC}"
|
||||
echo
|
||||
echo "Version: ${BZOD_VERSION}"
|
||||
echo "Image: ${IMAGE}"
|
||||
echo "Application: ${BZOD_ROOT}"
|
||||
echo "Data: ${NX9_BZOD_DATA_DIR}"
|
||||
echo "Config: ${CONFIG_DIR}"
|
||||
echo "Images: ${IMAGES_DIR}"
|
||||
echo "Port: ${BZOD_PORT}"
|
||||
echo
|
||||
|
||||
# ============================================================
|
||||
# 1. Install Docker
|
||||
# ============================================================
|
||||
|
||||
info "[1/8] Checking Docker..."
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
info "Docker is not installed. Installing Docker..."
|
||||
|
||||
apt-get update -qq
|
||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
# 2. Install Binary (safe atomic download)
|
||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
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
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL \
|
||||
https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
|
||||
REPO="thakares/nx9-url-shortener"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${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
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
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
|
||||
. /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
|
||||
|
||||
# Atomic replace with backup
|
||||
if [ -f "${INSTALL_PATH}" ]; then
|
||||
cp "${INSTALL_PATH}" "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
systemctl enable --now docker
|
||||
fi
|
||||
|
||||
install -m 755 "${TMP_BINARY}" "${INSTALL_PATH}"
|
||||
|
||||
# Verify
|
||||
if [ ! -x "${INSTALL_PATH}" ]; then
|
||||
echo -e "${RED}Binary installation failed${NC}"
|
||||
exit 1
|
||||
if ! docker compose version >/dev/null 2>&1; then
|
||||
die "Docker Compose plugin is unavailable."
|
||||
fi
|
||||
|
||||
"${INSTALL_PATH}" --version >/dev/null && echo -e "${GREEN}✓ Binary verified (--version)${NC}" || {
|
||||
echo -e "${RED}Binary verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
success "✓ Docker and Docker Compose available"
|
||||
|
||||
# Verify -V also works
|
||||
"${INSTALL_PATH}" -V >/dev/null && echo -e "${GREEN}✓ Binary verified (-V)${NC}" || {
|
||||
echo -e "${RED}Binary -V verification failed${NC}"
|
||||
exit 1
|
||||
}
|
||||
# ============================================================
|
||||
# 2. Create persistent directories
|
||||
# ============================================================
|
||||
|
||||
# Show installed version and verify it matches requested version
|
||||
VERSION=$("${INSTALL_PATH}" --version 2>/dev/null | head -n1 || echo "unknown")
|
||||
EXPECTED_VERSION="bzod ${BZOD_VERSION}"
|
||||
if [ "${VERSION}" != "${EXPECTED_VERSION}" ]; then
|
||||
echo -e "${RED}Version mismatch: expected '${EXPECTED_VERSION}', got '${VERSION}'${NC}"
|
||||
exit 1
|
||||
fi
|
||||
echo -e "${GREEN}✓ Installed ${VERSION} (${ARCH})${NC}"
|
||||
info "[2/8] Creating persistent application directories..."
|
||||
|
||||
# 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}"
|
||||
fi
|
||||
mkdir -p \
|
||||
"${NX9_BZOD_DATA_DIR}" \
|
||||
"${CONFIG_DIR}" \
|
||||
"${IMAGES_DIR}" \
|
||||
"${COMPOSE_DIR}" \
|
||||
"${BACKUP_ROOT}"
|
||||
|
||||
# 4. Setup Directories
|
||||
echo -e "\n${BLUE}[4/8] Setting up directories...${NC}"
|
||||
mkdir -p "${CONFIG_DIR}" "${DATA_DIR}"
|
||||
chown -R "${SERVICE_USER}:${SERVICE_USER}" "/var/lib/bzod"
|
||||
chmod 700 "${CONFIG_DIR}"
|
||||
chmod 755 "${IMAGES_DIR}"
|
||||
|
||||
success "✓ Persistent directories ready"
|
||||
|
||||
# ============================================================
|
||||
# 3. Configuration
|
||||
# ============================================================
|
||||
|
||||
info "[3/8] Preparing configuration..."
|
||||
|
||||
if [[ ! -f "${ENV_FILE}" ]]; then
|
||||
|
||||
cat > "${ENV_FILE}" <<EOF
|
||||
BZOD_VERSION=${BZOD_VERSION}
|
||||
BZOD_IMAGE=${BZOD_IMAGE}
|
||||
|
||||
# 5. Configuration (preserve on upgrades)
|
||||
echo -e "\n${BLUE}[5/8] Configuration...${NC}"
|
||||
if [ ! -f "${ENV_FILE}" ]; then
|
||||
echo -e "${BLUE}Generating new secure configuration...${NC}"
|
||||
cat <<EOF > "${ENV_FILE}"
|
||||
HOST=0.0.0.0
|
||||
PORT=8654
|
||||
DATA_DIR=${DATA_DIR}
|
||||
|
||||
NX9_BZOD_DATA_DIR=/app/data
|
||||
CONFIG_DIR=/app/config
|
||||
IMAGES_DIR=/app/images
|
||||
|
||||
COOKIE_SECURE=true
|
||||
RUST_LOG=info
|
||||
SESSION_SECRET=$(openssl rand -hex 32)
|
||||
BASE_URL=${BASE_URL}
|
||||
EOF
|
||||
|
||||
chmod 600 "${ENV_FILE}"
|
||||
chown root:"${SERVICE_USER}" "${ENV_FILE}"
|
||||
|
||||
success "✓ New Docker configuration created"
|
||||
|
||||
else
|
||||
echo -e "${GREEN}Existing configuration preserved${NC}"
|
||||
|
||||
warning "Existing Docker configuration preserved"
|
||||
|
||||
# Update image/version while preserving all other settings.
|
||||
sed -i \
|
||||
-E "s#^BZOD_VERSION=.*#BZOD_VERSION=${BZOD_VERSION}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
sed -i \
|
||||
-E "s#^BZOD_IMAGE=.*#BZOD_IMAGE=${BZOD_IMAGE}#" \
|
||||
"${ENV_FILE}" || true
|
||||
|
||||
if grep -q '^BASE_URL=' "${ENV_FILE}"; then
|
||||
sed -i \
|
||||
-E "s#^BASE_URL=.*#BASE_URL=${BASE_URL}#" \
|
||||
"${ENV_FILE}" || true
|
||||
else
|
||||
echo "BASE_URL=${BASE_URL}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=/var/lib/bzod
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${INSTALL_PATH} serve
|
||||
# ============================================================
|
||||
# 4. Create Compose definition
|
||||
# ============================================================
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
info "[4/8] Writing Docker Compose configuration..."
|
||||
|
||||
# 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
|
||||
cat > "${COMPOSE_FILE}" <<'EOF'
|
||||
services:
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
bzod:
|
||||
image: ${BZOD_IMAGE}:${BZOD_VERSION}
|
||||
container_name: bzod
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-8654}:8654"
|
||||
|
||||
environment:
|
||||
HOST: "${HOST:-0.0.0.0}"
|
||||
PORT: "${PORT:-8654}"
|
||||
|
||||
NX9_BZOD_DATA_DIR: "/app/data"
|
||||
CONFIG_DIR: "/app/config"
|
||||
IMAGES_DIR: "/app/images"
|
||||
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
|
||||
RUST_LOG: "${RUST_LOG:-info}"
|
||||
BASE_URL: "${BASE_URL:-https://bzo.in}"
|
||||
|
||||
volumes:
|
||||
|
||||
# Persistent application databases.
|
||||
- ${BZOD_ROOT}/data:/app/data
|
||||
|
||||
# Persistent application configuration.
|
||||
- ${BZOD_ROOT}/config:/app/config
|
||||
|
||||
# User-uploaded / application images.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# /app/images is required by the image router.
|
||||
- ${BZOD_ROOT}/images:/app/images
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-fsS",
|
||||
"http://127.0.0.1:8654/status"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
EOF
|
||||
|
||||
chmod 644 "${SYSTEMD_UNIT}"
|
||||
systemctl daemon-reload
|
||||
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
# Database creation and migration is handled automatically by 'bzod serve'
|
||||
if [ -f "${DATA_DIR}/admin/admin.db" ] || [ -f "${DATA_DIR}/admin.db" ]; then
|
||||
echo -e "${GREEN}✓ Existing database detected (upgrade mode)${NC}"
|
||||
|
||||
# Pre-upgrade: stop service and backup databases
|
||||
if systemctl is-active --quiet bzod 2>/dev/null; then
|
||||
echo -e "${BLUE} Stopping BZOD for safe database backup...${NC}"
|
||||
systemctl stop bzod
|
||||
# Append BZOD_ROOT because compose needs it.
|
||||
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
|
||||
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-v${BZOD_VERSION}"
|
||||
# Port variable expected by compose.
|
||||
if ! grep -q '^PORT=' "${ENV_FILE}"; then
|
||||
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
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}"
|
||||
cp -a "${DATA_DIR}" "${BACKUP_DIR}/data" 2>/dev/null || true
|
||||
cp "${ENV_FILE}" "${BACKUP_DIR}/bzod.env" 2>/dev/null || true
|
||||
echo -e "${GREEN} ✓ Pre-upgrade backup created at ${BACKUP_DIR}${NC}"
|
||||
else
|
||||
echo -e "${GREEN}✓ Fresh installation (databases will be created on first start)${NC}"
|
||||
|
||||
if [[ -d "${NX9_BZOD_DATA_DIR}" ]]; then
|
||||
cp -a "${NX9_BZOD_DATA_DIR}" "${BACKUP_DIR}/data"
|
||||
fi
|
||||
|
||||
systemctl enable --now bzod
|
||||
|
||||
# 8. Validation + Rollback
|
||||
sleep 3
|
||||
|
||||
if ! systemctl is-active --quiet bzod; then
|
||||
echo -e "${RED}Service failed to start! Rolling back...${NC}"
|
||||
if [ -f "${INSTALL_PATH}.bak" ]; then
|
||||
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
|
||||
systemctl restart bzod || true
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
|
||||
fi
|
||||
journalctl -u bzod -n 50 --no-pager
|
||||
|
||||
if [[ -d "${IMAGES_DIR}" ]]; then
|
||||
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
|
||||
fi
|
||||
|
||||
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
|
||||
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
|
||||
|
||||
success "✓ Backup created:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
# ============================================================
|
||||
# 6. Pull new image
|
||||
# ============================================================
|
||||
|
||||
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
|
||||
|
||||
# Build locally from the current deployment tree. The package/repository is
|
||||
# nx9-url-shortener; the application binary and container remain named bzod.
|
||||
if ! docker build \
|
||||
--tag "${IMAGE}" \
|
||||
--file "${BZOD_ROOT}/Dockerfile" \
|
||||
"${BZOD_ROOT}"; then
|
||||
die "Unable to build ${IMAGE}"
|
||||
fi
|
||||
|
||||
success "✓ Docker image built locally"
|
||||
|
||||
# ============================================================
|
||||
# 7. Deploy
|
||||
# ============================================================
|
||||
|
||||
info "[7/8] Deploying BZOD..."
|
||||
|
||||
cd "${COMPOSE_DIR}"
|
||||
|
||||
# Stop/remove the existing container through Compose.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
down \
|
||||
--remove-orphans
|
||||
|
||||
# Start the requested image.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
up -d
|
||||
|
||||
success "✓ BZOD container started"
|
||||
|
||||
# ============================================================
|
||||
# 8. Validation
|
||||
# ============================================================
|
||||
|
||||
info "[8/8] Validating deployment..."
|
||||
|
||||
sleep 5
|
||||
|
||||
if ! docker inspect \
|
||||
--format '{{.State.Running}}' \
|
||||
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
|
||||
|
||||
error "BZOD container failed to start."
|
||||
echo
|
||||
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
logs --tail=100
|
||||
|
||||
error
|
||||
error "Deployment failed. Existing data was not removed."
|
||||
error "Backup: ${BACKUP_DIR}"
|
||||
|
||||
exit 1
|
||||
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}"
|
||||
# ------------------------------------------------------------
|
||||
# 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
|
||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
||||
fi
|
||||
warning "⚠ HTTP health check did not respond yet"
|
||||
warning "The container is running; inspect logs if necessary:"
|
||||
echo
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
|
||||
fi
|
||||
|
||||
# Final Message
|
||||
IP=$(hostname -I | awk '{print $1}' | head -n1)
|
||||
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
|
||||
echo -e "🌐 Web UI: http://${IP}:8654"
|
||||
echo -e "🔑 Admin: http://${IP}:8654/admin"
|
||||
echo -e "🖥 Architecture: ${ARCH}"
|
||||
echo -e "📦 Version: ${VERSION}"
|
||||
echo -e "\nNext step (first install):"
|
||||
echo -e " sudo -u bzod bzod create-admin"
|
||||
echo -e "\nCommands:"
|
||||
echo -e " journalctl -u bzod -f"
|
||||
echo -e " bzod doctor"
|
||||
echo -e " systemctl status bzod"
|
||||
# ============================================================
|
||||
# Verify image and binary
|
||||
# ============================================================
|
||||
|
||||
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
|
||||
echo
|
||||
info "Installed image:"
|
||||
docker image inspect "${IMAGE}" \
|
||||
--format ' {{.RepoTags}} ({{.Id}})' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Container:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format ' {{.Name}} {{.Config.Image}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Persistent mounts:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# Final status
|
||||
# ============================================================
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo
|
||||
|
||||
echo "Web UI:"
|
||||
echo " http://<server-ip>:${BZOD_PORT}"
|
||||
|
||||
echo
|
||||
echo "Persistent data:"
|
||||
echo " ${NX9_BZOD_DATA_DIR}"
|
||||
|
||||
echo
|
||||
echo "Persistent images:"
|
||||
echo " ${IMAGES_DIR}"
|
||||
|
||||
echo
|
||||
echo "Docker Compose:"
|
||||
echo " ${COMPOSE_FILE}"
|
||||
|
||||
echo
|
||||
echo "Backup:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
echo
|
||||
echo "Useful commands:"
|
||||
echo " docker compose -f ${COMPOSE_FILE} ps"
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
|
||||
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
|
||||
|
||||
echo
|
||||
success "Deployment complete."
|
||||
@@ -1,55 +1,87 @@
|
||||
name: app-bzod
|
||||
|
||||
services:
|
||||
bzod:
|
||||
build:
|
||||
context: /DATA/AppData/bzod
|
||||
context: /DATA/AppData/nx9-url-shortener
|
||||
dockerfile: Dockerfile
|
||||
cpu_shares: 90
|
||||
command: []
|
||||
|
||||
container_name: bzod
|
||||
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 31940M
|
||||
|
||||
environment:
|
||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
|
||||
- ADMIN_USERNAME=${ADMIN_USERNAME}
|
||||
- CONFIG_DIR=/app/config
|
||||
- COOKIE_SECURE=false
|
||||
- DATA_DIR=/app/data
|
||||
- NX9_BZOD_DATA_DIR=/app/data
|
||||
- HOST=0.0.0.0
|
||||
- IMAGES_DIR=/app/images
|
||||
- PORT=8654
|
||||
- RUST_LOG=info
|
||||
- BASE_URL=${BASE_URL}
|
||||
|
||||
hostname: bzod
|
||||
image: nx9-url-shortener:v0.7.0
|
||||
|
||||
image: nx9-url-shortener:v0.8.0
|
||||
|
||||
ports:
|
||||
- mode: ingress
|
||||
target: 8654
|
||||
published: "8654"
|
||||
protocol: tcp
|
||||
|
||||
restart: unless-stopped
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/data
|
||||
source: /DATA/AppData/nx9-url-shortener/data
|
||||
target: /app/data
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/config
|
||||
source: /DATA/AppData/nx9-url-shortener/config
|
||||
target: /app/config
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/www
|
||||
source: /DATA/AppData/nx9-url-shortener/www
|
||||
target: /app/www
|
||||
bind:
|
||||
create_host_path: true
|
||||
|
||||
- type: bind
|
||||
source: /DATA/AppData/bzod/images
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
exec /usr/local/bin/bzod "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
@@ -4,6 +4,31 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
||||
|
||||
# v0.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening
|
||||
|
||||
## Added
|
||||
|
||||
* Core Admin is strictly platform-operator-only and has no tenant application storage.
|
||||
* Inspection-only global URL and landing-page registries for Admin.
|
||||
* Strict Admin/tenant route boundary with HTTP 403 enforcement.
|
||||
* TenantId-based active ownership and tenant filesystem topology.
|
||||
* Tenant-aware analytics worker grouping.
|
||||
* Deterministic cross-role session invalidation and cookie clearing.
|
||||
|
||||
## Changed
|
||||
|
||||
* Removed active production dependencies on the legacy `system.db.global_slugs` registry.
|
||||
* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations.
|
||||
* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`.
|
||||
* User login and Admin login now establish role-specific sessions and clear the opposite-role session.
|
||||
|
||||
## Compatibility
|
||||
|
||||
* Historical v0.7.x migration and legacy restore compatibility remains preserved.
|
||||
* Legacy database/schema identifiers are retained only where required for migration and historical restore support.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
@@ -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.7.0 is:
|
||||
The current command list for BZOD v0.8.0 is:
|
||||
|
||||
```text
|
||||
$ bzod --help
|
||||
@@ -284,3 +284,27 @@ bzod doctor
|
||||
* SECURITY.md
|
||||
* API.md
|
||||
* ARCHITECTURE.md
|
||||
|
||||
---
|
||||
|
||||
# Data Directory Configuration
|
||||
|
||||
BZOD requires an explicit physical data directory root. There is **no implicit `./data` fallback**.
|
||||
|
||||
### Configuration Precedence
|
||||
|
||||
1. **CLI `--data-dir`** (Highest priority)
|
||||
```bash
|
||||
bzod serve --data-dir /var/lib/bzod/data
|
||||
bzod migrate --data-dir=/var/lib/bzod/data --dry-run
|
||||
```
|
||||
2. **Environment Variable `NX9_BZOD_DATA_DIR`**
|
||||
```bash
|
||||
export NX9_BZOD_DATA_DIR=/var/lib/bzod/data
|
||||
bzod serve
|
||||
```
|
||||
3. **Configuration File (`bzod.toml` / `config.toml`)**
|
||||
```toml
|
||||
data_dir = "/var/lib/bzod/data"
|
||||
```
|
||||
4. **Error**: If no data directory is provided via CLI, `NX9_BZOD_DATA_DIR`, or config file, BZOD terminates with an actionable error.
|
||||
@@ -1,4 +1,4 @@
|
||||
# BZOD v0.7.0 vs Self-Hosted URL Management Platforms
|
||||
# BZOD v0.8.0 vs Self-Hosted URL Management Platforms
|
||||
|
||||
BZOD is a modern, privacy-focused, self-hosted URL Management Platform written in Rust and developed as part of the NX9 Platform.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
# BZOD Database Architecture
|
||||
|
||||
BZOD v0.7.0 uses SQLite exclusively.
|
||||
BZOD v0.8.0 uses SQLite exclusively.
|
||||
|
||||
Rather than using a single monolithic database, BZOD separates data into administrative and tenant-specific databases. This architecture improves security, isolation, backup flexibility, disaster recovery, and scalability.
|
||||
|
||||
|
||||
@@ -45,7 +45,21 @@ cd nx9-url-shortener
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
## Create Administrator
|
||||
## Automated Administrator Bootstrap (First Start Only)
|
||||
|
||||
For fresh deployments, you can supply administrator credentials via environment variables so the container initializes the admin automatically:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD: "<your-secure-password>"
|
||||
```
|
||||
|
||||
These credentials are used **only** when no administrator exists. If an administrator is already present, this step is safely skipped and existing accounts are preserved.
|
||||
|
||||
## Manual Administrator Creation
|
||||
|
||||
Alternatively, if you prefer not to use environment variables, you can create the admin manually:
|
||||
|
||||
```bash
|
||||
docker exec -it bzod bzod create-admin
|
||||
@@ -86,7 +100,7 @@ services:
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8654
|
||||
DATA_DIR: /app/data
|
||||
NX9_BZOD_DATA_DIR: /app/data
|
||||
COOKIE_SECURE: "false"
|
||||
|
||||
healthcheck:
|
||||
@@ -191,10 +205,10 @@ BZOD Docker Container
|
||||
# Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
| ------------- | ------------------ | --------- |
|
||||
| ----------------- | ------------------ | ------------- |
|
||||
| HOST | Bind address | 0.0.0.0 |
|
||||
| PORT | Listen port | 8654 |
|
||||
| DATA_DIR | Database directory | /app/data |
|
||||
| NX9_BZOD_DATA_DIR | Database directory | (required) |
|
||||
| COOKIE_SECURE | Secure cookies | false |
|
||||
| RUST_LOG | Logging level | info |
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.7.0-linux-amd64.tar.gz
|
||||
wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.7.0-linux-amd64.tar.gz
|
||||
tar -xzf bzod-v0.8.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Install:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Multi-User Architecture Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,30 @@
|
||||
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
|
||||
|
||||
Release Date: 2026-08-21
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
|
||||
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
|
||||
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
|
||||
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
|
||||
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
|
||||
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
|
||||
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
|
||||
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
|
||||
|
||||
## Verification
|
||||
|
||||
- Phase 5 Core Separation tests: **4/4 passed**
|
||||
- Admin capability boundary tests: **11/11 passed**
|
||||
- Admin/user route and session boundary tests: **27/27 passed**
|
||||
- Phase 6A elimination tests: **6/6 passed**
|
||||
- Workspace regression suite: **all tests passed**
|
||||
- `cargo fmt --all -- --check`: **PASS**
|
||||
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@ BZOD is designed as a self-hosted URL shortener and landing page platform with a
|
||||
* Disaster recovery
|
||||
* Operational simplicity
|
||||
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.7.0.
|
||||
This document describes the security architecture, threat model, authentication mechanisms, authorization controls, and operational security recommendations for BZOD v0.8.0.
|
||||
|
||||
---
|
||||
|
||||
@@ -620,7 +620,7 @@ If compromise is suspected:
|
||||
|
||||
# Security Testing
|
||||
|
||||
BZOD v0.7.0 includes tests covering:
|
||||
BZOD v0.8.0 includes tests covering:
|
||||
|
||||
* Authentication
|
||||
* Authorization
|
||||
@@ -665,7 +665,7 @@ These may be addressed in future releases.
|
||||
|
||||
# Summary
|
||||
|
||||
BZOD v0.7.0 provides:
|
||||
BZOD v0.8.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.7.0
|
||||
Version: v0.8.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.7.0.
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0.
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.8.0 Upgrade Overview
|
||||
|
||||
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
|
||||
|
||||
Key upgrade characteristics:
|
||||
|
||||
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
|
||||
* Active production operations no longer use `system.db.global_slugs`.
|
||||
* Active tenant ownership is represented by immutable `TenantId`.
|
||||
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
|
||||
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD v0.5.1 is a platform hardening release focused on:
|
||||
|
||||
|
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 |
@@ -56,26 +56,33 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
}
|
||||
|
||||
info!(
|
||||
"Flushing {} visits to user analytics databases",
|
||||
"Flushing {} visits to tenant analytics databases",
|
||||
batch.len()
|
||||
);
|
||||
|
||||
// Group visits by owner_user_id
|
||||
let mut groups: std::collections::HashMap<i64, Vec<VisitRecord>> =
|
||||
// Group visits by owner_tenant_id (or legacy user 1 fallback)
|
||||
let mut groups: std::collections::HashMap<crate::identity::TenantId, Vec<VisitRecord>> =
|
||||
std::collections::HashMap::new();
|
||||
let mut legacy_user1_visits: Vec<VisitRecord> = Vec::new();
|
||||
|
||||
for record in batch.drain(..) {
|
||||
let user_id = record.owner_user_id.unwrap_or(1); // fallback to legacy_admin (user 1)
|
||||
groups.entry(user_id).or_default().push(record);
|
||||
if let Some(tenant_id) = record.owner_tenant_id {
|
||||
groups.entry(tenant_id).or_default().push(record);
|
||||
} else if record.owner_user_id == Some(1) {
|
||||
legacy_user1_visits.push(record);
|
||||
} else {
|
||||
error!("Dropping analytics visit with no owner_tenant_id");
|
||||
}
|
||||
}
|
||||
|
||||
for (user_id, user_visits) in groups {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("analytics.db");
|
||||
if let Some(parent) = db_path.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
for (tenant_id, tenant_visits) in groups {
|
||||
let db_path = db.topology.tenant_analytics_db(tenant_id);
|
||||
if !db_path.exists() {
|
||||
error!(
|
||||
"Refusing to write analytics for non-existent tenant analytics path: {:?}",
|
||||
db_path
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
match rusqlite::Connection::open(&db_path) {
|
||||
@@ -83,19 +90,31 @@ fn flush_batch(db: &Db, batch: &mut Vec<VisitRecord>) {
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
|
||||
|
||||
if let Err(e) = insert_visits_batch(&mut conn, &user_visits) {
|
||||
if let Err(e) = insert_visits_batch(&mut conn, &tenant_visits) {
|
||||
error!(
|
||||
"Failed to write analytics batch to user {} database: {:?}",
|
||||
user_id, e
|
||||
"Failed to write analytics batch to tenant {} database: {:?}",
|
||||
tenant_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
error!(
|
||||
"Failed to open analytics database for user {}: {:?}",
|
||||
user_id, e
|
||||
"Failed to open analytics database for tenant {}: {:?}",
|
||||
tenant_id, e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !legacy_user1_visits.is_empty() {
|
||||
if let Ok(legacy_db_path) = db.topology.analytics_db("1") {
|
||||
if legacy_db_path.exists() {
|
||||
if let Ok(mut conn) = rusqlite::Connection::open(&legacy_db_path) {
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "analytics");
|
||||
let _ = insert_visits_batch(&mut conn, &legacy_user1_visits);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,26 +198,9 @@ pub fn authenticate_user_session(
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Get tenant user (status must be 'active')
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([session.user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
// Get tenant user (status must be 'active', account_type != 'admin', and tenant_id is Some)
|
||||
let user_opt = crate::db::users::get_user_by_id(users_conn, session.user_id)?
|
||||
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
Ok(Some((user, session.id)))
|
||||
@@ -251,25 +234,8 @@ pub fn authenticate_api_key(
|
||||
let user_id_opt: Option<i64> = stmt.query_row([&hashed_key], |row| row.get(0)).optional()?;
|
||||
|
||||
if let Some(user_id) = user_id_opt {
|
||||
let mut stmt = users_conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1 AND status = 'active';"
|
||||
)?;
|
||||
let user_opt = stmt
|
||||
.query_row([user_id], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.optional()?;
|
||||
let user_opt = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||
.filter(|u| u.status == "active" && u.account_type != "admin" && u.tenant_id.is_some());
|
||||
|
||||
if let Some(user) = user_opt {
|
||||
return Ok(Some(ApiActor::User(user)));
|
||||
|
||||
@@ -44,7 +44,8 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// 2. Open databases
|
||||
let legacy_content_path = config.data_dir.join("users").join("1").join("content.db");
|
||||
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
let legacy_content_path = topology.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?;
|
||||
|
||||
if !legacy_content_path.exists() {
|
||||
info!(
|
||||
@@ -55,11 +56,7 @@ pub async fn run(
|
||||
}
|
||||
|
||||
db.init_user_databases(target_admin_id)?;
|
||||
let target_content_path = config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_admin_id.to_string())
|
||||
.join("content.db");
|
||||
let target_content_path = topology.content_db_i64(target_admin_id)?;
|
||||
|
||||
let mut legacy_conn = Connection::open(&legacy_content_path)?;
|
||||
let mut target_conn = Connection::open(&target_content_path)?;
|
||||
|
||||
@@ -16,6 +16,10 @@ struct UserBackupMetadata {
|
||||
created_at: String,
|
||||
account_type: String,
|
||||
metadata: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
uuid: Option<String>,
|
||||
quotas: UserBackupQuotas,
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ pub async fn run(
|
||||
);
|
||||
|
||||
// 4. Force checkpoint on user's databases
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let user_dir = crate::db::tenant::location_for_user(&user)?.dir(&db.topology)?;
|
||||
if let Ok(c) = rusqlite::Connection::open(user_dir.join("content.db")) {
|
||||
let _ = c.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
@@ -119,6 +123,8 @@ pub async fn run(
|
||||
created_at: user.created_at,
|
||||
account_type: user.account_type,
|
||||
metadata: user.metadata,
|
||||
tenant_id: user.tenant_id.map(|t| t.to_string()),
|
||||
uuid: user.uuid,
|
||||
quotas,
|
||||
};
|
||||
let metadata_bytes = serde_json::to_vec_pretty(&metadata_obj)?;
|
||||
|
||||
@@ -7,6 +7,7 @@ use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
username: Option<String>,
|
||||
password: Option<String>,
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -25,16 +26,18 @@ pub async fn run(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let password = read_input("Enter password: ");
|
||||
if password.trim().is_empty() {
|
||||
let final_password = match password {
|
||||
Some(p) => p,
|
||||
None => read_input("Enter password: "),
|
||||
};
|
||||
if final_password.trim().is_empty() {
|
||||
error!("Password cannot be empty");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
let hash = hash_password(&final_password).map_err(|e| e.to_string())?;
|
||||
let conn = db.users.lock().unwrap();
|
||||
let u = crate::db::users::create_admin_user(&conn, &final_username, &hash)?;
|
||||
db.init_user_databases(u.id)?;
|
||||
info!(
|
||||
"Successfully created admin user: {} (ID: {})",
|
||||
u.username, u.id
|
||||
|
||||
@@ -15,11 +15,6 @@ pub async fn run(
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
if user_id == 1 && !force {
|
||||
error!("Deleting legacy_admin system account requires --force flag");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Capture user details
|
||||
let user_details = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
@@ -32,37 +27,63 @@ pub async fn run(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = db.system.lock().unwrap();
|
||||
let tx = system_conn.transaction()?;
|
||||
if user_details.account_type == "admin" && !force {
|
||||
error!("Deleting administrator account requires --force flag");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get all slugs owned by the user
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")?;
|
||||
let rows = stmt.query_map([user_id], |row| row.get(0))?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
// Delete from global_slugs and write to history
|
||||
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let tid_str = tid.to_string();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
|
||||
// Get all URL slugs owned by tenant
|
||||
let mut stmt =
|
||||
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")?;
|
||||
let url_slugs: Vec<String> = stmt
|
||||
.query_map([&tid_str], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Get all page slugs owned by tenant
|
||||
let mut stmt2 = pages_conn
|
||||
.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")?;
|
||||
let page_slugs: Vec<String> = stmt2
|
||||
.query_map([&tid_str], |row| row.get(0))?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
|
||||
// Record history and retire/delete slugs
|
||||
for slug in url_slugs {
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, user_id, now, "cli"],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
for slug in page_slugs {
|
||||
let _ =
|
||||
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, user_id, now, "cli"],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Delete user folder and database files from disk
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
// 2. Delete tenant directory from disk
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let user_dir = db.topology.tenant_dir(*tid);
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Remove user entry from users.db (cascading deletes quotas/sessions/tokens)
|
||||
{
|
||||
|
||||
@@ -24,16 +24,27 @@ pub async fn run(
|
||||
|
||||
let mut all_healthy = true;
|
||||
|
||||
// Define target databases in the new layout
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let legacy_user_dir = config.data_dir.join("users").join("1");
|
||||
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
|
||||
let dbs = vec![
|
||||
("admin", admin_dir.join("admin.db")),
|
||||
("system", admin_dir.join("system.db")),
|
||||
("users", admin_dir.join("users.db")),
|
||||
("legacy content", legacy_user_dir.join("content.db")),
|
||||
("legacy analytics", legacy_user_dir.join("analytics.db")),
|
||||
("admin", topology.admin_db()),
|
||||
("system", topology.system_db()),
|
||||
("users", topology.users_registry_db()),
|
||||
("global_urls", topology.global_urls_db()),
|
||||
("global_landing_pages", topology.global_landing_pages_db()),
|
||||
("reserved", topology.reserved_db()),
|
||||
(
|
||||
"legacy content",
|
||||
topology
|
||||
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
),
|
||||
(
|
||||
"legacy analytics",
|
||||
topology
|
||||
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
),
|
||||
];
|
||||
|
||||
for (db_name, db_path) in dbs {
|
||||
@@ -92,8 +103,8 @@ pub async fn run(
|
||||
// Global Slug Registry Integrity Check
|
||||
println!("Global Slug Registry Integrity Check");
|
||||
println!("====================================");
|
||||
let system_db_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
let system_db_path = topology.system_db();
|
||||
let users_db_path = topology.users_registry_db();
|
||||
|
||||
if system_db_path.exists() && users_db_path.exists() {
|
||||
match (
|
||||
@@ -134,7 +145,7 @@ pub async fn run(
|
||||
);
|
||||
println!();
|
||||
println!("Owner:");
|
||||
println!(" User ID {}", issue.owner_user_id);
|
||||
println!(" Tenant ID {}", issue.owner_tenant_id);
|
||||
println!();
|
||||
println!("Database:");
|
||||
println!(" {}", issue.database_path.display());
|
||||
|
||||
@@ -17,11 +17,28 @@ pub async fn run(
|
||||
return Err("Invalid short code or custom slug format".into());
|
||||
}
|
||||
|
||||
let url_opt = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_by_code(&conn, &normalized_code)?
|
||||
let owner_info = {
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1 AND status = 'active';",
|
||||
rusqlite::params![normalized_code],
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.ok()
|
||||
};
|
||||
|
||||
let tid_str = match owner_info {
|
||||
Some(t) => t,
|
||||
None => return Err(format!("Short code not found: {}", normalized_code).into()),
|
||||
};
|
||||
let tid = tid_str
|
||||
.parse::<crate::identity::TenantId>()
|
||||
.map_err(|e| format!("Invalid tenant id for slug {}: {:?}", normalized_code, e))?;
|
||||
|
||||
let content_path = db.topology.tenant_content_db(tid);
|
||||
let conn = rusqlite::Connection::open(&content_path)?;
|
||||
let url_opt = crate::db::content::get_url_by_code(&conn, &normalized_code)?;
|
||||
|
||||
match url_opt {
|
||||
Some(url) => {
|
||||
println!("{}", url.destination);
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
use crate::auth::hash_password;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{error, info};
|
||||
|
||||
pub async fn run(
|
||||
data_dir: Option<String>,
|
||||
mut config: Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(d) = data_dir {
|
||||
config.data_dir = PathBuf::from(d);
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
let admin_count: i64 = {
|
||||
let conn = db.users.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM users WHERE account_type = 'admin';",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?
|
||||
};
|
||||
|
||||
if admin_count > 0 {
|
||||
info!("Administrator already exists; initialization skipped.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let username = match env::var("ADMIN_USERNAME") {
|
||||
Ok(u) if !u.trim().is_empty() => u.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let password = match env::var("ADMIN_PASSWORD") {
|
||||
Ok(p) if !p.trim().is_empty() => p.trim().to_string(),
|
||||
_ => {
|
||||
let msg = "No administrator exists.\nADMIN_USERNAME and ADMIN_PASSWORD are required for first-time initialization.";
|
||||
error!("{}", msg);
|
||||
return Err(msg.into());
|
||||
}
|
||||
};
|
||||
|
||||
let hash = hash_password(&password).map_err(|e| e.to_string())?;
|
||||
{
|
||||
let conn = db.users.lock().unwrap();
|
||||
let _ = crate::db::users::create_admin_user(&conn, &username, &hash)?;
|
||||
}
|
||||
info!("Administrator initialized successfully.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -15,12 +15,91 @@ pub async fn run(
|
||||
if dry_run {
|
||||
info!("Dry run enabled: pending database migrations will be reported but not applied.");
|
||||
info!("Data directory: {:?}", config.data_dir);
|
||||
let id_report =
|
||||
crate::db::identity_migrate::run_identity_migration(&config, true, false).await?;
|
||||
println!("\nIdentity Migration Preflight Report (Dry Run):");
|
||||
println!("----------------------------------------------");
|
||||
println!("Total users: {}", id_report.total_users);
|
||||
println!(
|
||||
"Users needing TenantId: {}",
|
||||
id_report.users_assigned_tenant_id
|
||||
);
|
||||
println!("Users needing UUID: {}", id_report.users_assigned_uuid);
|
||||
println!("Directories to move: {}", id_report.directories_moved);
|
||||
println!(
|
||||
"Directories already migrated: {}",
|
||||
id_report.directories_already_migrated
|
||||
);
|
||||
println!(
|
||||
"Legacy admin (users/1) preserved: {}",
|
||||
id_report.legacy_admin_preserved
|
||||
);
|
||||
|
||||
let slug_report =
|
||||
crate::db::slug_migrate::run_global_slug_migration(&config, true, false).await?;
|
||||
println!("\nGlobal Slug Migration Preflight Report (Dry Run):");
|
||||
println!("-------------------------------------------------");
|
||||
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
|
||||
println!("URL slugs to migrate: {}", slug_report.url_slugs_migrated);
|
||||
println!("Page slugs to migrate: {}", slug_report.page_slugs_migrated);
|
||||
println!("Reserved slugs: {}", slug_report.reserved_slugs_migrated);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
info!("Running database migrations...");
|
||||
info!("Running database schema migrations...");
|
||||
let _db = Db::init(&config)?;
|
||||
info!("Database migrations applied successfully.");
|
||||
info!("Database schema migrations applied successfully.");
|
||||
|
||||
info!("Running identity & directory migration lifecycle...");
|
||||
let id_report =
|
||||
crate::db::identity_migrate::run_identity_migration(&config, false, false).await?;
|
||||
println!("\nIdentity Migration Report:");
|
||||
println!("--------------------------");
|
||||
println!("Total users: {}", id_report.total_users);
|
||||
println!("TenantIds assigned: {}", id_report.users_assigned_tenant_id);
|
||||
println!("UUIDs assigned: {}", id_report.users_assigned_uuid);
|
||||
println!("Directories migrated: {}", id_report.directories_moved);
|
||||
println!(
|
||||
"Directories already migrated: {}",
|
||||
id_report.directories_already_migrated
|
||||
);
|
||||
println!(
|
||||
"Legacy admin preserved: {}",
|
||||
id_report.legacy_admin_preserved
|
||||
);
|
||||
println!("Validation passed: {}", id_report.validation_passed);
|
||||
|
||||
if !id_report.warnings.is_empty() {
|
||||
println!("\nWarnings:");
|
||||
for w in &id_report.warnings {
|
||||
println!(" - {}", w);
|
||||
}
|
||||
}
|
||||
|
||||
info!("Running global slug migration lifecycle...");
|
||||
let slug_report =
|
||||
crate::db::slug_migrate::run_global_slug_migration(&config, false, false).await?;
|
||||
println!("\nGlobal Slug Migration Report:");
|
||||
println!("-----------------------------");
|
||||
println!("Total legacy slugs: {}", slug_report.total_legacy_slugs);
|
||||
println!("URL slugs migrated: {}", slug_report.url_slugs_migrated);
|
||||
println!("Page slugs migrated: {}", slug_report.page_slugs_migrated);
|
||||
println!(
|
||||
"Reserved slugs verified: {}",
|
||||
slug_report.reserved_slugs_migrated
|
||||
);
|
||||
println!(
|
||||
"Existing target records verified: {}",
|
||||
slug_report.existing_records_verified
|
||||
);
|
||||
println!("Validation passed: {}", slug_report.validation_passed);
|
||||
|
||||
if !slug_report.warnings.is_empty() {
|
||||
println!("\nWarnings:");
|
||||
for w in &slug_report.warnings {
|
||||
println!(" - {}", w);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -86,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)]
|
||||
@@ -207,3 +213,34 @@ pub enum RepairCommands {
|
||||
data_dir: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl Commands {
|
||||
pub fn data_dir(&self) -> Option<&str> {
|
||||
match self {
|
||||
Commands::Serve { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Backup { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Restore { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Migrate { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Stats { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Validate { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::AuditDestinations { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::CreateAdmin { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::InitAdmin { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Doctor { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Shorten { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Expand { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::CreateUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::DeleteUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::DisableUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::EnableUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::ResetPassword { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::ListUsers { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::BackupUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::RestoreUser { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::AdminMigrate { data_dir, .. } => data_dir.as_deref(),
|
||||
Commands::Repair { command } => match command {
|
||||
RepairCommands::Registry { data_dir, .. } => data_dir.as_deref(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,55 +30,89 @@ pub async fn run(
|
||||
}
|
||||
|
||||
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");
|
||||
let topology = crate::db::topology::Topology::new(&config.data_dir);
|
||||
let system_db_path = topology.system_db();
|
||||
let users_db_path = topology.users_registry_db();
|
||||
let urls_db_path = topology.global_urls_db();
|
||||
let pages_db_path = topology.global_landing_pages_db();
|
||||
|
||||
if !system_db_path.exists() || !users_db_path.exists() {
|
||||
println!("Error: system.db or users.db not found.");
|
||||
if !system_db_path.exists()
|
||||
|| !users_db_path.exists()
|
||||
|| !urls_db_path.exists()
|
||||
|| !pages_db_path.exists()
|
||||
{
|
||||
println!("Error: Core databases (system.db, users.db, slugs/*.db) not found.");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut sys_conn = Connection::open(&system_db_path)?;
|
||||
let sys_conn = Connection::open(&system_db_path)?;
|
||||
let usr_conn = Connection::open(&users_db_path)?;
|
||||
let mut urls_conn = Connection::open(&urls_db_path)?;
|
||||
let mut pages_conn = Connection::open(&pages_db_path)?;
|
||||
|
||||
let slug_filter = slug.as_deref();
|
||||
|
||||
if dry_run {
|
||||
println!("BZOD Registry Repair\n");
|
||||
println!("Scanning Global Slug Registry...");
|
||||
println!("BZOD Registry Repair (v0.8)\n");
|
||||
println!("Scanning Authoritative Slug Registries (global_urls.db, global_landing_pages.db)...");
|
||||
|
||||
let issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
let orphaned = issues
|
||||
.into_iter()
|
||||
|
||||
let true_orphans = issues
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
| RegistryIssueType::TrueOrphan
|
||||
| RegistryIssueType::MissingTenant
|
||||
)
|
||||
})
|
||||
.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();
|
||||
let corrupt = issues
|
||||
.iter()
|
||||
.filter(|i| i.issue_type == RegistryIssueType::CorruptDatabase)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
println!("\nDetected:");
|
||||
println!("\nPages:\n {} orphaned", orphaned_pages);
|
||||
println!("\nURLs:\n {} orphaned", orphaned_urls);
|
||||
let access_failures = issues
|
||||
.iter()
|
||||
.filter(|i| i.issue_type == RegistryIssueType::AccessFailure)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
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!("\nDetected Issues (Total: {}):", issues.len());
|
||||
println!(" Orphaned/Missing Targets: {}", true_orphans.len());
|
||||
println!(" Corrupt Databases (Protected): {}", corrupt.len());
|
||||
println!(" Access Failures (Protected): {}", access_failures.len());
|
||||
|
||||
println!("\nNo changes have been made.");
|
||||
if !true_orphans.is_empty() {
|
||||
println!("\nThe following orphaned entries would be repaired/removed:");
|
||||
for issue in &true_orphans {
|
||||
println!(
|
||||
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||
" {} [{}] — Tenant: {}",
|
||||
issue.slug,
|
||||
issue.target_type.to_uppercase(),
|
||||
issue.owner_tenant_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if !corrupt.is_empty() {
|
||||
println!("\nProtected from destructive repair (Corrupt DBs):");
|
||||
for issue in &corrupt {
|
||||
println!(
|
||||
" {} [{}] — Path: {:?}",
|
||||
issue.slug,
|
||||
issue.target_type.to_uppercase(),
|
||||
issue.database_path
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made (dry-run).");
|
||||
println!(
|
||||
"Run again with:\n bzod repair registry --force{}",
|
||||
if let Some(s) = slug_filter {
|
||||
format!(" --slug {}", s)
|
||||
} else {
|
||||
@@ -87,89 +121,73 @@ pub async fn run(
|
||||
);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, start_time.elapsed()
|
||||
"Registry Repair Scan completed. Orphaned: {}, Corrupt: {}, Duration: {:?}",
|
||||
true_orphans.len(),
|
||||
corrupt.len(),
|
||||
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
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, slug_filter)?;
|
||||
|
||||
// Only repair true orphans, missing targets, or missing tenants.
|
||||
// Never delete records with CorruptDatabase or AccessFailure per safety policies.
|
||||
let repairable = issues
|
||||
.into_iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
| RegistryIssueType::TrueOrphan
|
||||
| RegistryIssueType::MissingTenant
|
||||
)
|
||||
})
|
||||
.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.");
|
||||
if repairable.is_empty() {
|
||||
println!("No repairable registry issues found.");
|
||||
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 tx_urls = urls_conn.transaction()?;
|
||||
let tx_pages = pages_conn.transaction()?;
|
||||
|
||||
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");
|
||||
for issue in &repairable {
|
||||
if issue.target_type == "url" {
|
||||
removed_count += tx_urls
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
|
||||
} 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" }
|
||||
);
|
||||
removed_count += tx_pages.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&issue.slug],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
// Record audit event in system.db
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&sys_conn,
|
||||
"cli",
|
||||
"REGISTRY_REPAIR",
|
||||
"registry",
|
||||
slug_filter.unwrap_or("*"),
|
||||
Some(&format!(
|
||||
"Repaired/removed {} orphaned slug entries",
|
||||
removed_count
|
||||
)),
|
||||
);
|
||||
|
||||
println!("Registry Repair Complete.");
|
||||
println!("Repaired/Removed: {} entries", removed_count);
|
||||
|
||||
info!(
|
||||
"Registry Repair Started. Scanned. Orphaned Pages: {}, Orphaned URLs: {}. Removed: {}. Duration: {:?}",
|
||||
orphaned_pages, orphaned_urls, removed_count, start_time.elapsed()
|
||||
"Registry Repair Complete. Removed: {}. Duration: {:?}",
|
||||
removed_count,
|
||||
start_time.elapsed()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,19 +215,15 @@ fn classify_registry_issues(
|
||||
|
||||
for issue in issues {
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::DuplicateSlug
|
||||
RegistryIssueType::Conflict
|
||||
| RegistryIssueType::InvalidTargetType
|
||||
| RegistryIssueType::InvalidStatus => {
|
||||
errors.push(issue);
|
||||
}
|
||||
RegistryIssueType::MissingOwner if !is_legacy => {
|
||||
RegistryIssueType::MissingTenant 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);
|
||||
}
|
||||
}
|
||||
@@ -281,8 +277,7 @@ pub fn perform_restore(
|
||||
}
|
||||
|
||||
// 5. Run validation on the normalized temp_dir
|
||||
let mut temp_config = Config::load();
|
||||
temp_config.data_dir = temp_dir.clone();
|
||||
let temp_config = Config::load_with_cli(Some(&temp_dir))?;
|
||||
|
||||
// Namespace audit
|
||||
match crate::db::users::audit_slug_namespace(&temp_config) {
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::fs::File;
|
||||
use std::path::PathBuf;
|
||||
use tar::Archive;
|
||||
use tracing::{error, info};
|
||||
use uuid::Uuid;
|
||||
use zstd::Decoder;
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
@@ -15,6 +19,10 @@ struct UserBackupMetadata {
|
||||
created_at: String,
|
||||
account_type: String,
|
||||
metadata: Option<String>,
|
||||
#[serde(default)]
|
||||
tenant_id: Option<String>,
|
||||
#[serde(default)]
|
||||
uuid: Option<String>,
|
||||
quotas: UserBackupQuotas,
|
||||
}
|
||||
|
||||
@@ -70,26 +78,72 @@ pub async fn run(
|
||||
|
||||
info!("Restoring user {} from backup...", metadata.username);
|
||||
|
||||
// 2. Resolve target user ID and upsert user record in users.db
|
||||
let target_user_id = {
|
||||
// 2. Resolve identity per Correction #1:
|
||||
// Order:
|
||||
// 1. Archive contains TenantId + UUID -> preserve exactly.
|
||||
// 2. Legacy archive matched to existing users.db account -> resolve and preserve that user's existing TenantId + UUID.
|
||||
// 3. Genuinely new legacy restore with no existing identity match -> explicitly allocate new TenantId + UUID.
|
||||
let (target_user_id, target_tenant_id) = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let existing_user =
|
||||
crate::db::users::get_user_by_username(&users_conn, &metadata.username)?;
|
||||
|
||||
match existing_user {
|
||||
Some(u) => {
|
||||
let tenant_id = if let Some(tid) = u.tenant_id {
|
||||
tid
|
||||
} else if let Some(ref tid_str) = metadata.tenant_id {
|
||||
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
|
||||
} else {
|
||||
TenantId::generate()
|
||||
};
|
||||
|
||||
let user_uuid = if let Some(ref uid) = u.uuid {
|
||||
uid.clone()
|
||||
} else if let Some(ref uid_str) = metadata.uuid {
|
||||
uid_str.clone()
|
||||
} else {
|
||||
Uuid::new_v4().to_string()
|
||||
};
|
||||
|
||||
users_conn.execute(
|
||||
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4 WHERE id = ?5;",
|
||||
rusqlite::params![metadata.password_hash, metadata.status, metadata.account_type, metadata.metadata, u.id],
|
||||
"UPDATE users SET password_hash = ?1, status = ?2, account_type = ?3, metadata = ?4, tenant_id = ?5, uuid = ?6 WHERE id = ?7;",
|
||||
rusqlite::params![
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid,
|
||||
u.id
|
||||
],
|
||||
)?;
|
||||
users_conn.execute(
|
||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![u.id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
|
||||
rusqlite::params![
|
||||
u.id,
|
||||
metadata.quotas.max_urls,
|
||||
metadata.quotas.max_landings,
|
||||
metadata.quotas.max_api_tokens,
|
||||
metadata.quotas.max_storage_mb
|
||||
],
|
||||
)?;
|
||||
u.id
|
||||
(u.id, tenant_id)
|
||||
}
|
||||
None => {
|
||||
let tenant_id = if let Some(ref tid_str) = metadata.tenant_id {
|
||||
TenantId::parse(tid_str).unwrap_or_else(|_| TenantId::generate())
|
||||
} else {
|
||||
TenantId::generate()
|
||||
};
|
||||
|
||||
let user_uuid = if let Some(ref uid_str) = metadata.uuid {
|
||||
uid_str.clone()
|
||||
} else {
|
||||
Uuid::new_v4().to_string()
|
||||
};
|
||||
|
||||
let id_taken: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
@@ -100,16 +154,35 @@ pub async fn run(
|
||||
|
||||
let new_id = if !id_taken {
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![metadata.id, metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);",
|
||||
rusqlite::params![
|
||||
metadata.id,
|
||||
metadata.username,
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.created_at,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid
|
||||
],
|
||||
)?;
|
||||
metadata.id
|
||||
} else {
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![metadata.username, metadata.password_hash, metadata.status, metadata.created_at, metadata.account_type, metadata.metadata],
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
rusqlite::params![
|
||||
metadata.username,
|
||||
metadata.password_hash,
|
||||
metadata.status,
|
||||
metadata.created_at,
|
||||
metadata.account_type,
|
||||
metadata.metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid
|
||||
],
|
||||
)?;
|
||||
users_conn.last_insert_rowid()
|
||||
};
|
||||
@@ -117,19 +190,23 @@ pub async fn run(
|
||||
users_conn.execute(
|
||||
"INSERT OR REPLACE INTO quotas (user_id, max_urls, max_landings, max_api_tokens, max_storage_mb)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5);",
|
||||
rusqlite::params![new_id, metadata.quotas.max_urls, metadata.quotas.max_landings, metadata.quotas.max_api_tokens, metadata.quotas.max_storage_mb],
|
||||
rusqlite::params![
|
||||
new_id,
|
||||
metadata.quotas.max_urls,
|
||||
metadata.quotas.max_landings,
|
||||
metadata.quotas.max_api_tokens,
|
||||
metadata.quotas.max_storage_mb
|
||||
],
|
||||
)?;
|
||||
new_id
|
||||
(new_id, tenant_id)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Extract database files to /data/users/<target_user_id>/
|
||||
let dest_dir = config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_user_id.to_string());
|
||||
// 3. Extract database files to /data/users/<TenantId>/
|
||||
let dest_dir = db.topology.tenant_dir(target_tenant_id);
|
||||
std::fs::create_dir_all(&dest_dir)?;
|
||||
std::fs::create_dir_all(dest_dir.join("extensions"))?;
|
||||
|
||||
let f2 = File::open(&file_path)?;
|
||||
let zst_dec2 = Decoder::new(f2)?;
|
||||
@@ -156,27 +233,192 @@ pub async fn run(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Register slugs in global_slugs using the shared helper
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
target_user_id,
|
||||
&dest_dir.join("content.db"),
|
||||
)?;
|
||||
// 4. Register restored slugs in v0.8 slug databases (global_urls.db, global_landing_pages.db)
|
||||
let content_path = dest_dir.join("content.db");
|
||||
if content_path.exists() {
|
||||
let restored_content_conn = rusqlite::Connection::open(&content_path)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
// 4a. Validate URL slugs for collisions
|
||||
let mut url_slugs = Vec::new();
|
||||
let mut stmt =
|
||||
restored_content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
|
||||
// Check collision with global_urls
|
||||
let existing_url_owner: Option<String> = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_url_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' is already registered to another tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Check collision with global_landing_pages
|
||||
let existing_page_owner: Option<String> = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_page_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' collides with landing page owned by tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
// Check reserved
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if is_reserved {
|
||||
return Err(format!(
|
||||
"Slug collision: URL slug '{code}' is a reserved system keyword"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
url_slugs.push((code, target_id, created_at, global_status));
|
||||
}
|
||||
|
||||
// 4b. Validate Landing Page slugs for collisions
|
||||
let mut page_slugs = Vec::new();
|
||||
let mut stmt = restored_content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let existing_url_owner: Option<String> = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_url_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' collides with URL owned by tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let existing_page_owner: Option<String> = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some(ref owner) = existing_page_owner {
|
||||
if owner != target_tenant_id.as_str() {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' is already registered to another tenant ({owner})"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
}
|
||||
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&code],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
if is_reserved {
|
||||
return Err(format!(
|
||||
"Slug collision: Landing page slug '{code}' is a reserved system keyword"
|
||||
)
|
||||
.into());
|
||||
}
|
||||
|
||||
page_slugs.push((code, target_id, created_at, global_status));
|
||||
}
|
||||
|
||||
// 4c. Register validated URL slugs
|
||||
for (code, target_id, created_at, global_status) in url_slugs {
|
||||
let _ = urls_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![
|
||||
code,
|
||||
target_tenant_id.as_str(),
|
||||
target_id,
|
||||
created_at,
|
||||
now,
|
||||
global_status
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 4d. Register validated Landing Page slugs
|
||||
for (code, target_id, created_at, global_status) in page_slugs {
|
||||
let _ = pages_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![
|
||||
code,
|
||||
target_tenant_id.as_str(),
|
||||
target_id,
|
||||
created_at,
|
||||
now,
|
||||
global_status
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Reconcile quotas for restored user
|
||||
let restored_content_conn = rusqlite::Connection::open(dest_dir.join("content.db"))?;
|
||||
crate::db::users::reconcile_user_quotas(
|
||||
&db.users.lock().unwrap(),
|
||||
target_user_id,
|
||||
&restored_content_conn,
|
||||
)?;
|
||||
}
|
||||
|
||||
info!(
|
||||
"User '{}' (ID: {}) successfully restored from backup.",
|
||||
metadata.username, target_user_id
|
||||
"User '{}' (ID: {}, Tenant: {}) successfully restored from backup.",
|
||||
metadata.username, target_user_id, target_tenant_id
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -133,8 +133,6 @@ pub async fn run(
|
||||
|
||||
let state = AppState {
|
||||
admin_db: db.admin.clone(),
|
||||
content_db: db.content.clone(),
|
||||
analytics_db: db.analytics.clone(),
|
||||
system_db: db.system.clone(),
|
||||
users_db: db.users.clone(),
|
||||
user_dbs: std::sync::Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
|
||||
|
||||
@@ -18,6 +18,24 @@ pub async fn run(
|
||||
}
|
||||
let db = Db::init(&config)?;
|
||||
|
||||
// Resolve active standard user tenant
|
||||
let (user_id, tid) = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let users = crate::db::users::list_users(&users_conn)?;
|
||||
let active_user = users.into_iter().find(|u| {
|
||||
u.account_type == "standard" && u.status == "active" && u.tenant_id.is_some()
|
||||
});
|
||||
match active_user {
|
||||
Some(u) => (u.id, u.tenant_id.unwrap()),
|
||||
None => {
|
||||
return Err(
|
||||
"Cannot shorten URL: No active standard user tenant found. Create a standard user first with `bzod user create`."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Validate/normalize slug/code
|
||||
let code = match slug {
|
||||
Some(s) => {
|
||||
@@ -33,17 +51,24 @@ pub async fn run(
|
||||
None => crate::utils::random::generate_token(3),
|
||||
};
|
||||
|
||||
// 3. Register slug in system.db with status 'reserving' and check availability
|
||||
// 3. Register slug in global_urls with status 'reserving'
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code)? {
|
||||
return Err("Short code/slug already exists".into());
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
if let Err(e) =
|
||||
crate::db::slugs::reserve_url_slug(&reserved_conn, &urls_conn, &pages_conn, &code, &tid)
|
||||
{
|
||||
return Err(format!("Slug '{}' already exists or is unavailable: {}", code, e).into());
|
||||
}
|
||||
crate::db::users::register_global_slug(&system_conn, &code, 1, "url", "", "reserving")?;
|
||||
}
|
||||
|
||||
// 4. Persist URL
|
||||
let conn = db.content.lock().unwrap();
|
||||
// 4. Persist URL in tenant content DB
|
||||
let content_path = db.topology.tenant_content_db(tid);
|
||||
let conn = rusqlite::Connection::open(&content_path)?;
|
||||
let _ = crate::db::sqlite::enable_wal(&conn, "content");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&conn, "content");
|
||||
|
||||
let res = crate::db::content::create_url_extended(
|
||||
&conn,
|
||||
&code,
|
||||
@@ -58,18 +83,15 @@ pub async fn run(
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug in system.db
|
||||
// Activate slug in global_urls
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
system_conn.execute(
|
||||
"UPDATE global_slugs SET target_id = ?1, status = 'active', updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![url.id, chrono::Utc::now().to_rfc3339(), code],
|
||||
)?;
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id)?;
|
||||
}
|
||||
// Increment quota for user ID 1
|
||||
// Increment quota
|
||||
{
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::increment_quota_counter(&users_conn, 1, "urls")?;
|
||||
crate::db::users::increment_quota_counter(&users_conn, user_id, "urls")?;
|
||||
}
|
||||
|
||||
let proto = if config.cookie_secure {
|
||||
@@ -87,8 +109,8 @@ pub async fn run(
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, 1);
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &tid);
|
||||
Err(e.into())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,16 +15,24 @@ pub async fn run(
|
||||
println!("Storage Directory: {:?}", config.data_dir);
|
||||
|
||||
let files = vec![
|
||||
("admin.db", config.data_dir.join("admin/admin.db")),
|
||||
("system.db", config.data_dir.join("admin/system.db")),
|
||||
("users.db", config.data_dir.join("admin/users.db")),
|
||||
("admin.db", db.topology.admin_db()),
|
||||
("system.db", db.topology.system_db()),
|
||||
("users.db", db.topology.users_registry_db()),
|
||||
("global_urls.db", db.topology.global_urls_db()),
|
||||
(
|
||||
"global_landing_pages.db",
|
||||
db.topology.global_landing_pages_db(),
|
||||
),
|
||||
("reserved.db", db.topology.reserved_db()),
|
||||
(
|
||||
"legacy content.db",
|
||||
config.data_dir.join("users/1/content.db"),
|
||||
db.topology
|
||||
.content_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||
),
|
||||
(
|
||||
"legacy analytics.db",
|
||||
config.data_dir.join("users/1/analytics.db"),
|
||||
db.topology
|
||||
.analytics_db(crate::db::topology::LEGACY_ADMIN_USER_KEY)?,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -47,8 +55,29 @@ pub async fn run(
|
||||
println!("Users Count: {}", users_count);
|
||||
|
||||
let (urls_total, urls_active, urls_dead) = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn)?
|
||||
let conn = db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
println!(
|
||||
"Shortened URLs: {} total ({} active / {} dead)",
|
||||
@@ -56,14 +85,19 @@ pub async fn run(
|
||||
);
|
||||
|
||||
let pages_count = {
|
||||
let conn = db.content.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn)?
|
||||
let conn = db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
println!("Landing Pages: {}", pages_count);
|
||||
|
||||
let total_visits = {
|
||||
let conn = db.analytics.lock().unwrap();
|
||||
crate::db::analytics::get_total_clicks(&conn)?
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
println!("Redirect Clicks: {}", total_visits);
|
||||
|
||||
|
||||
@@ -3,6 +3,45 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub const NX9_BZOD_DATA_DIR_ENV: &str = "NX9_BZOD_DATA_DIR";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ConfigError {
|
||||
MissingDataDir,
|
||||
InvalidConfig(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConfigError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingDataDir => write!(
|
||||
f,
|
||||
"data directory is not configured; set NX9_BZOD_DATA_DIR or use --data-dir=<path>"
|
||||
),
|
||||
Self::InvalidConfig(msg) => write!(f, "configuration error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum DataDirSource {
|
||||
Cli,
|
||||
Env,
|
||||
ConfigFile,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DataDirSource {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Cli => write!(f, "CLI"),
|
||||
Self::Env => write!(f, "NX9_BZOD_DATA_DIR"),
|
||||
Self::ConfigFile => write!(f, "config.toml"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Config {
|
||||
pub host: String,
|
||||
@@ -45,11 +84,23 @@ struct TomlBackupConfig {
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load() -> Self {
|
||||
// 1. Built-in defaults
|
||||
pub fn load() -> Result<Self, ConfigError> {
|
||||
Self::load_with_cli(None::<&std::path::Path>)
|
||||
}
|
||||
|
||||
pub fn load_with_cli<P: AsRef<std::path::Path>>(
|
||||
cli_data_dir: Option<P>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
Self::load_from_sources(cli_data_dir, true)
|
||||
}
|
||||
|
||||
pub fn load_from_sources<P: AsRef<std::path::Path>>(
|
||||
cli_data_dir: Option<P>,
|
||||
load_dotenv: bool,
|
||||
) -> Result<Self, ConfigError> {
|
||||
// 1. Built-in defaults (no implicit data_dir default)
|
||||
let mut host = "0.0.0.0".to_string();
|
||||
let mut port = 8080u16;
|
||||
let mut data_dir = PathBuf::from("./data");
|
||||
let mut admin_username = "admin".to_string();
|
||||
let mut bootstrap_password_sha256 = "".to_string();
|
||||
let mut session_secret =
|
||||
@@ -63,12 +114,18 @@ impl Config {
|
||||
let mut backup_dir = PathBuf::from("./backups");
|
||||
let mut base_url = None;
|
||||
|
||||
// 2. Load bzod.toml if it exists
|
||||
let mut toml_path = "bzod.toml".to_string();
|
||||
if fs::metadata("bzod.toml").is_err() && fs::metadata("config/bzod.toml").is_ok() {
|
||||
toml_path = "config/bzod.toml".to_string();
|
||||
}
|
||||
if let Ok(toml_content) = fs::read_to_string(&toml_path) {
|
||||
let mut resolved_data_dir: Option<PathBuf> = None;
|
||||
let mut data_dir_source: Option<DataDirSource> = None;
|
||||
|
||||
// 2. Load bzod.toml / config.toml if it exists
|
||||
let possible_tomls = [
|
||||
"bzod.toml",
|
||||
"config/bzod.toml",
|
||||
"config.toml",
|
||||
"config/config.toml",
|
||||
];
|
||||
for toml_path in possible_tomls {
|
||||
if let Ok(toml_content) = fs::read_to_string(toml_path) {
|
||||
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
|
||||
if let Some(h) = toml_config.host {
|
||||
host = h;
|
||||
@@ -77,7 +134,10 @@ impl Config {
|
||||
port = p;
|
||||
}
|
||||
if let Some(d) = toml_config.data_dir {
|
||||
data_dir = PathBuf::from(d);
|
||||
if !d.trim().is_empty() {
|
||||
resolved_data_dir = Some(PathBuf::from(d));
|
||||
data_dir_source = Some(DataDirSource::ConfigFile);
|
||||
}
|
||||
}
|
||||
if let Some(u) = toml_config.admin_username {
|
||||
admin_username = u;
|
||||
@@ -118,16 +178,20 @@ impl Config {
|
||||
if let Some(bu) = toml_config.base_url {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load .env if present
|
||||
if load_dotenv {
|
||||
let _ = dotenvy::dotenv();
|
||||
if fs::metadata("config/.env").is_ok() {
|
||||
let _ = dotenvy::from_path("config/.env");
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Load from Environment Variables (taking highest precedence)
|
||||
// 4. Load from Environment Variables (taking precedence over config file)
|
||||
if let Ok(h) = env::var("HOST") {
|
||||
host = h;
|
||||
}
|
||||
@@ -136,8 +200,11 @@ impl Config {
|
||||
port = p;
|
||||
}
|
||||
}
|
||||
if let Ok(d_str) = env::var("DATA_DIR") {
|
||||
data_dir = PathBuf::from(d_str);
|
||||
if let Ok(d_str) = env::var(NX9_BZOD_DATA_DIR_ENV) {
|
||||
if !d_str.trim().is_empty() {
|
||||
resolved_data_dir = Some(PathBuf::from(d_str));
|
||||
data_dir_source = Some(DataDirSource::Env);
|
||||
}
|
||||
}
|
||||
if let Ok(u) = env::var("ADMIN_USERNAME") {
|
||||
admin_username = u;
|
||||
@@ -187,7 +254,26 @@ impl Config {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
|
||||
Self {
|
||||
// 5. CLI override (highest precedence)
|
||||
if let Some(cli_dir) = cli_data_dir {
|
||||
let path_ref = cli_dir.as_ref();
|
||||
if !path_ref.as_os_str().is_empty() {
|
||||
resolved_data_dir = Some(path_ref.to_path_buf());
|
||||
data_dir_source = Some(DataDirSource::Cli);
|
||||
}
|
||||
}
|
||||
|
||||
let data_dir = match resolved_data_dir {
|
||||
Some(dir) => dir,
|
||||
None => return Err(ConfigError::MissingDataDir),
|
||||
};
|
||||
|
||||
if let Some(source) = data_dir_source {
|
||||
tracing::info!("Data directory: {}", data_dir.display());
|
||||
tracing::info!("Data directory source: {}", source);
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
host,
|
||||
port,
|
||||
data_dir,
|
||||
@@ -202,6 +288,6 @@ impl Config {
|
||||
backup_interval_mins,
|
||||
backup_dir,
|
||||
base_url,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -639,6 +639,7 @@ pub fn get_target_visits_paginated(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
@@ -695,6 +696,7 @@ pub fn get_target_visits_all_in_memory(
|
||||
accept_language: row.get("accept_language")?,
|
||||
country: row.get("country")?,
|
||||
status_code: row.get("status_code")?,
|
||||
owner_tenant_id: None,
|
||||
owner_user_id: row.get("owner_user_id")?,
|
||||
})
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Explicit Phase 3 Identity & Directory Migration Engine.
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect DB and filesystem, identify unmigrated users)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Identity Migration (assign immutable TenantId + UUID in users.db)
|
||||
//! 4. Filesystem Migration (atomically move users/<id>/ to users/<TenantId>/)
|
||||
//! 5. Validation (verify all users, directories, and databases)
|
||||
//! 6. Completion Marker (record audit event and system setting)
|
||||
//!
|
||||
//! Legacy Admin (users/1) is preserved as a Core/legacy concern and NOT converted
|
||||
//! to a normal TenantId directory.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::{is_v08_user_id, Topology};
|
||||
use crate::db::Db;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct IdentityMigrationReport {
|
||||
pub total_users: usize,
|
||||
pub users_assigned_tenant_id: usize,
|
||||
pub users_assigned_uuid: usize,
|
||||
pub directories_moved: usize,
|
||||
pub directories_already_migrated: usize,
|
||||
pub legacy_admin_preserved: bool,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PreflightPlan {
|
||||
pub total_users: usize,
|
||||
pub normal_users: usize,
|
||||
pub users_needing_tenant_id: Vec<(i64, String)>,
|
||||
pub users_needing_uuid: Vec<(i64, String)>,
|
||||
pub directories_to_move: Vec<(i64, PathBuf, TenantId)>,
|
||||
pub directories_already_migrated: usize,
|
||||
}
|
||||
|
||||
/// Run the full explicit identity migration lifecycle.
|
||||
pub async fn run_identity_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<IdentityMigrationReport, Box<dyn std::error::Error>> {
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db for Core connections
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let plan = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_preflight(&users_conn, &topology)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Preflight: total_users={}, normal_users={}, needing_tenant_id={}, needing_uuid={}, dirs_to_move={}",
|
||||
plan.total_users,
|
||||
plan.normal_users,
|
||||
plan.users_needing_tenant_id.len(),
|
||||
plan.users_needing_uuid.len(),
|
||||
plan.directories_to_move.len()
|
||||
);
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run mode enabled. No identity changes or directory moves will be performed.");
|
||||
return Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: plan.users_needing_tenant_id.len(),
|
||||
users_assigned_uuid: plan.users_needing_uuid.len(),
|
||||
directories_moved: plan.directories_to_move.len(),
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if there is work to do and backup is not skipped)
|
||||
let needs_migration = !plan.users_needing_tenant_id.is_empty()
|
||||
|| !plan.users_needing_uuid.is_empty()
|
||||
|| !plan.directories_to_move.is_empty();
|
||||
|
||||
if needs_migration && !skip_backup {
|
||||
info!("Creating pre-migration backup...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Identity Migration (users.db backfill)
|
||||
let (assigned_tids, assigned_uuids) = {
|
||||
let mut users_conn = db.users.lock().unwrap();
|
||||
migrate_user_table_identities(&mut users_conn)?
|
||||
};
|
||||
|
||||
// 5. Filesystem Migration (users/<id>/ -> users/<TenantId>/)
|
||||
let moved_dirs = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
migrate_tenant_directories(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 6. Validation
|
||||
let validation_passed = {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
validate_identity_migration(&users_conn, &topology, &mut warnings)?
|
||||
};
|
||||
|
||||
// 7. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Identity migration completed: {} tenant_ids assigned, {} uuids assigned, {} directories moved",
|
||||
assigned_tids, assigned_uuids, moved_dirs
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"IDENTITY_MIGRATION_COMPLETED",
|
||||
"identity",
|
||||
"users.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_identity_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(IdentityMigrationReport {
|
||||
total_users: plan.total_users,
|
||||
users_assigned_tenant_id: assigned_tids,
|
||||
users_assigned_uuid: assigned_uuids,
|
||||
directories_moved: moved_dirs,
|
||||
directories_already_migrated: plan.directories_already_migrated,
|
||||
legacy_admin_preserved: true,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect existing database and filesystem to build a preflight plan.
|
||||
pub fn inspect_preflight(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
) -> Result<PreflightPlan, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let total_users = users.len();
|
||||
|
||||
let mut normal_users = 0;
|
||||
let mut users_needing_tenant_id = Vec::new();
|
||||
let mut users_needing_uuid = Vec::new();
|
||||
let mut directories_to_move = Vec::new();
|
||||
let mut directories_already_migrated = 0;
|
||||
|
||||
for user in &users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normal_users += 1;
|
||||
|
||||
if user.tenant_id.is_none() {
|
||||
users_needing_tenant_id.push((user.id, user.username.clone()));
|
||||
}
|
||||
if user.uuid.is_none() {
|
||||
users_needing_uuid.push((user.id, user.username.clone()));
|
||||
}
|
||||
|
||||
if let Some(tid) = user.tenant_id {
|
||||
let legacy_dir = topology.user_dir_i64(user.id)?;
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
directories_to_move.push((user.id, legacy_dir, tid));
|
||||
} else if target_dir.exists() {
|
||||
directories_already_migrated += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(PreflightPlan {
|
||||
total_users,
|
||||
normal_users,
|
||||
users_needing_tenant_id,
|
||||
users_needing_uuid,
|
||||
directories_to_move,
|
||||
directories_already_migrated,
|
||||
})
|
||||
}
|
||||
|
||||
/// Assign immutable TenantId and UUID for all normal users missing them in users.db.
|
||||
/// Restart-safe: never regenerates or modifies existing identities.
|
||||
pub fn migrate_user_table_identities(
|
||||
users_conn: &mut Connection,
|
||||
) -> Result<(usize, usize), Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut assigned_tids = 0;
|
||||
let mut assigned_uuids = 0;
|
||||
|
||||
let tx = users_conn.transaction()?;
|
||||
|
||||
for user in users {
|
||||
// Skip legacy admin / system accounts
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut needs_update = false;
|
||||
let mut tid_str = user.tenant_id.map(|t| t.as_str().to_string());
|
||||
let mut uuid_str = user.uuid;
|
||||
|
||||
if tid_str.is_none() {
|
||||
// Allocate unique TenantId
|
||||
let tid = crate::identity::TenantId::generate();
|
||||
tid_str = Some(tid.as_str().to_string());
|
||||
assigned_tids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if uuid_str.is_none() {
|
||||
// Allocate unique UUID
|
||||
let u = uuid::Uuid::new_v4().to_string();
|
||||
uuid_str = Some(u);
|
||||
assigned_uuids += 1;
|
||||
needs_update = true;
|
||||
}
|
||||
|
||||
if needs_update {
|
||||
tx.execute(
|
||||
"UPDATE users SET tenant_id = ?1, uuid = ?2 WHERE id = ?3;",
|
||||
rusqlite::params![tid_str, uuid_str, user.id],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
Ok((assigned_tids, assigned_uuids))
|
||||
}
|
||||
|
||||
/// Move tenant directories from users/<id>/ to users/<TenantId>/ for normal users.
|
||||
/// Legacy users/1 is preserved.
|
||||
pub fn migrate_tenant_directories(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<usize, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut moved = 0;
|
||||
|
||||
for user in users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
// Preserve Core / system accounts intact
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(tid) = user.tenant_id else {
|
||||
warnings.push(format!(
|
||||
"User '{}' (ID {}) has no TenantId; skipping directory move",
|
||||
user.username, user.id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
let legacy_dir = match topology.user_dir_i64(user.id) {
|
||||
Ok(d) => d,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
|
||||
if legacy_dir.exists() && legacy_dir != target_dir {
|
||||
if !target_dir.exists() {
|
||||
// Atomic rename on same filesystem
|
||||
fs::rename(&legacy_dir, &target_dir)?;
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
info!(
|
||||
"Migrated tenant directory for user '{}': {:?} -> {:?}",
|
||||
user.username, legacy_dir, target_dir
|
||||
);
|
||||
moved += 1;
|
||||
} else {
|
||||
// Target already exists (interrupted / partial run)
|
||||
warn!(
|
||||
"Target directory {:?} already exists for user '{}'. Verifying contents.",
|
||||
target_dir, user.username
|
||||
);
|
||||
// Ensure extensions dir exists
|
||||
let _ = fs::create_dir_all(target_dir.join("extensions"));
|
||||
|
||||
// If legacy directory is now empty or duplicate, clean it up safely
|
||||
if let Ok(entries) = fs::read_dir(&legacy_dir) {
|
||||
if entries.count() == 0 {
|
||||
let _ = fs::remove_dir(&legacy_dir);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
/// Validate that every normal user has a valid TenantId, UUID, and matching directory.
|
||||
pub fn validate_identity_migration(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
let mut valid = true;
|
||||
|
||||
for user in &users {
|
||||
if user.account_type == "admin"
|
||||
|| user.account_type == "system"
|
||||
|| user.username == "legacy_admin"
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate TenantId
|
||||
match user.tenant_id {
|
||||
Some(tid) => {
|
||||
if !is_v08_user_id(tid.as_str()) {
|
||||
warnings.push(format!(
|
||||
"Invalid TenantId format '{}' for user '{}'",
|
||||
tid.as_str(),
|
||||
user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let target_dir = topology.tenant_dir(tid);
|
||||
if !target_dir.exists() {
|
||||
// Check if content db was initialized or if directory was not yet created
|
||||
warnings.push(format!(
|
||||
"Tenant directory {:?} does not exist for user '{}'",
|
||||
target_dir, user.username
|
||||
));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing TenantId",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Validate UUID
|
||||
match &user.uuid {
|
||||
Some(u) => {
|
||||
if uuid::Uuid::parse_str(u).is_err() {
|
||||
warnings.push(format!(
|
||||
"Invalid UUID format '{}' for user '{}'",
|
||||
u, user.username
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Normal user '{}' (ID {}) is missing UUID",
|
||||
user.username, user.id
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
@@ -35,7 +35,21 @@ pub fn run_migrations(
|
||||
);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute_batch(m.sql)?;
|
||||
match tx.execute_batch(m.sql) {
|
||||
Ok(()) => (),
|
||||
Err(e) => {
|
||||
let err_msg = e.to_string();
|
||||
if err_msg.contains("duplicate column name") {
|
||||
tracing::warn!(
|
||||
database = db_name,
|
||||
version = m.version,
|
||||
"Column already exists during migration, continuing"
|
||||
);
|
||||
} else {
|
||||
return Err(e.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
|
||||
crate::db::sqlite::set_user_version(conn, m.version as i32)?;
|
||||
@@ -568,4 +582,24 @@ pub const USERS_MIGRATIONS: &[Migration] = &[
|
||||
WHERE username = 'admin' AND account_type = 'standard';
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 3,
|
||||
name: "tenant_id",
|
||||
sql: r#"
|
||||
ALTER TABLE users ADD COLUMN tenant_id TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_tenant_id
|
||||
ON users(tenant_id)
|
||||
WHERE tenant_id IS NOT NULL;
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 4,
|
||||
name: "uuid",
|
||||
sql: r#"
|
||||
ALTER TABLE users ADD COLUMN uuid TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_uuid
|
||||
ON users(uuid)
|
||||
WHERE uuid IS NOT NULL;
|
||||
"#,
|
||||
},
|
||||
];
|
||||
@@ -7,46 +7,67 @@ use crate::db::sqlite::{enable_foreign_keys, enable_wal};
|
||||
use rusqlite::Connection;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::info;
|
||||
|
||||
pub mod admin;
|
||||
pub mod analytics;
|
||||
pub mod audit_events;
|
||||
pub mod content;
|
||||
pub mod identity_migrate;
|
||||
pub mod migrations;
|
||||
pub mod preview;
|
||||
pub mod qr;
|
||||
pub mod schema_v08;
|
||||
pub mod slug_migrate;
|
||||
pub mod slugs;
|
||||
pub mod sqlite;
|
||||
pub mod tenant;
|
||||
pub mod topology;
|
||||
pub mod users;
|
||||
|
||||
use crate::db::schema_v08::{
|
||||
GLOBAL_LANDING_PAGES_MIGRATIONS, GLOBAL_URLS_MIGRATIONS, RESERVED_SLUGS_MIGRATIONS,
|
||||
};
|
||||
use crate::db::topology::Topology;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Db {
|
||||
pub admin: Arc<Mutex<Connection>>,
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub system: Arc<Mutex<Connection>>,
|
||||
pub users: Arc<Mutex<Connection>>,
|
||||
pub global_urls: Arc<Mutex<Connection>>,
|
||||
pub global_landing_pages: Arc<Mutex<Connection>>,
|
||||
pub reserved: Arc<Mutex<Connection>>,
|
||||
pub data_dir: std::path::PathBuf,
|
||||
pub topology: Topology,
|
||||
}
|
||||
|
||||
fn open_prepared(
|
||||
path: &std::path::Path,
|
||||
name: &str,
|
||||
) -> Result<Connection, Box<dyn std::error::Error>> {
|
||||
info!("Opening {}.db", name);
|
||||
let conn = Connection::open(path)?;
|
||||
enable_wal(&conn, name)?;
|
||||
enable_foreign_keys(&conn, name)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn init(config: &Config) -> Result<Self, Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
use tracing::info;
|
||||
|
||||
// Ensure data directory exists
|
||||
if !config.data_dir.exists() {
|
||||
fs::create_dir_all(&config.data_dir)?;
|
||||
let topology = Topology::new(&config.data_dir);
|
||||
|
||||
if !topology.root().exists() {
|
||||
fs::create_dir_all(topology.root())?;
|
||||
}
|
||||
topology.ensure_core_dirs()?;
|
||||
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let users_dir = config.data_dir.join("users");
|
||||
fs::create_dir_all(&admin_dir)?;
|
||||
fs::create_dir_all(&users_dir)?;
|
||||
let admin_dir = topology.admin_dir();
|
||||
|
||||
// Automated Legacy Migration: check if legacy files are at the root
|
||||
let legacy_admin_db = config.data_dir.join("admin.db");
|
||||
let legacy_content_db = config.data_dir.join("content.db");
|
||||
let legacy_analytics_db = config.data_dir.join("analytics.db");
|
||||
let legacy_admin_db = topology.legacy_flat_admin_db();
|
||||
|
||||
// 1. If legacy admin.db exists at root, move admin/system DBs to config.data_dir/admin/
|
||||
if legacy_admin_db.exists() {
|
||||
@@ -60,7 +81,7 @@ impl Db {
|
||||
"system.db-shm",
|
||||
];
|
||||
for f in files {
|
||||
let src = config.data_dir.join(f);
|
||||
let src = topology.root().join(f);
|
||||
if src.exists() {
|
||||
let dst = admin_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
@@ -88,24 +109,9 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
let admin_path = admin_dir.join("admin.db");
|
||||
let system_path = admin_dir.join("system.db");
|
||||
let users_db_path = admin_dir.join("users.db");
|
||||
|
||||
info!("Opening admin.db");
|
||||
let mut admin_conn = Connection::open(admin_path)?;
|
||||
info!("Opening system.db");
|
||||
let mut system_conn = Connection::open(system_path)?;
|
||||
info!("Opening users.db");
|
||||
let mut users_conn = Connection::open(users_db_path)?;
|
||||
|
||||
enable_wal(&admin_conn, "admin")?;
|
||||
enable_wal(&system_conn, "system")?;
|
||||
enable_wal(&users_conn, "users")?;
|
||||
|
||||
enable_foreign_keys(&admin_conn, "admin")?;
|
||||
enable_foreign_keys(&system_conn, "system")?;
|
||||
enable_foreign_keys(&users_conn, "users")?;
|
||||
let mut admin_conn = open_prepared(&topology.admin_db(), "admin")?;
|
||||
let mut system_conn = open_prepared(&topology.system_db(), "system")?;
|
||||
let mut users_conn = open_prepared(&topology.users_registry_db(), "users")?;
|
||||
|
||||
// Run migrations for system.db first
|
||||
info!("Running system migrations");
|
||||
@@ -168,182 +174,54 @@ impl Db {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let _ = users_conn.execute("DELETE FROM sessions WHERE expires_at < ?1;", [now]);
|
||||
|
||||
// 2. If legacy content.db/analytics.db exists, move them to users/1/ (for legacy_admin)
|
||||
let legacy_migration_needed = legacy_content_db.exists() || legacy_analytics_db.exists();
|
||||
let mut global_urls_conn = open_prepared(&topology.global_urls_db(), "global_urls")?;
|
||||
let mut global_landing_pages_conn =
|
||||
open_prepared(&topology.global_landing_pages_db(), "global_landing_pages")?;
|
||||
let mut reserved_conn = open_prepared(&topology.reserved_db(), "reserved")?;
|
||||
|
||||
// Ensure legacy_admin (user ID 1) exists in users.db
|
||||
let legacy_admin_id = 1i64;
|
||||
let legacy_admin_exists: bool = users_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE id = ?1);",
|
||||
[legacy_admin_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !legacy_admin_exists {
|
||||
// Get copied administrator password hash
|
||||
let admin_password_hash: String = admin_conn
|
||||
.query_row(
|
||||
"SELECT password_hash FROM users ORDER BY created_at ASC LIMIT 1;",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or_else(|_| {
|
||||
// If admin_db is empty, hash a default password
|
||||
crate::auth::password::hash_password("legacy_admin_pass").unwrap_or_default()
|
||||
});
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
users_conn.execute(
|
||||
"INSERT INTO users (id, username, password_hash, status, created_at, account_type)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
rusqlite::params![
|
||||
legacy_admin_id,
|
||||
"legacy_admin",
|
||||
admin_password_hash,
|
||||
"disabled",
|
||||
now,
|
||||
"system"
|
||||
],
|
||||
)?;
|
||||
|
||||
// Seed quotas
|
||||
users_conn.execute(
|
||||
"INSERT INTO quotas (user_id) VALUES (?1);",
|
||||
[legacy_admin_id],
|
||||
)?;
|
||||
}
|
||||
|
||||
let legacy_user_dir = users_dir.join(legacy_admin_id.to_string());
|
||||
fs::create_dir_all(&legacy_user_dir)?;
|
||||
|
||||
if legacy_content_db.exists() || legacy_analytics_db.exists() {
|
||||
tracing::warn!("LEGACY DETECTED: content/analytics databases found at root. Moving to multi-tenant user ID 1 directory...");
|
||||
let content_files = vec!["content.db", "content.db-wal", "content.db-shm"];
|
||||
for f in content_files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
let analytics_files = vec!["analytics.db", "analytics.db-wal", "analytics.db-shm"];
|
||||
for f in analytics_files {
|
||||
let src = config.data_dir.join(f);
|
||||
if src.exists() {
|
||||
let dst = legacy_user_dir.join(f);
|
||||
let _ = fs::rename(&src, &dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Open the legacy_admin databases (user ID 1) as db.content and db.analytics
|
||||
let content_path = legacy_user_dir.join("content.db");
|
||||
let analytics_path = legacy_user_dir.join("analytics.db");
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
|
||||
enable_wal(&content_conn, "content")?;
|
||||
enable_wal(&analytics_conn, "analytics")?;
|
||||
|
||||
enable_foreign_keys(&content_conn, "content")?;
|
||||
enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
|
||||
// Run migrations for content.db and analytics.db
|
||||
run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
CONTENT_MIGRATIONS,
|
||||
&mut global_urls_conn,
|
||||
"global_urls",
|
||||
GLOBAL_URLS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
ANALYTICS_MIGRATIONS,
|
||||
&mut global_landing_pages_conn,
|
||||
"global_landing_pages",
|
||||
GLOBAL_LANDING_PAGES_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
|
||||
// If we just migrated legacy content, populate the global_slugs table in system.db
|
||||
if legacy_migration_needed {
|
||||
info!("Populating global slug index with legacy content...");
|
||||
let mut sys_lock = system_arc.lock().unwrap();
|
||||
let tx = sys_lock.transaction()?;
|
||||
|
||||
// Extract urls from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt =
|
||||
content_conn.prepare("SELECT code, id, created_at, status FROM urls;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let status: String = row.get(3)?;
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "url", target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract landing pages from content.db and insert into global_slugs
|
||||
{
|
||||
let mut stmt = content_conn
|
||||
.prepare("SELECT code, id, created_at, state FROM landing_pages;")?;
|
||||
let mut rows = stmt.query([])?;
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
let created_at: String = row.get(2)?;
|
||||
let state: String = row.get(3)?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
|
||||
let _ = tx.execute(
|
||||
"INSERT OR IGNORE INTO global_slugs (slug, owner_user_id, target_type, target_id, created_at, updated_at, status)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![slug, legacy_admin_id, "page", target_id, created_at, now, status],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tx.commit()?;
|
||||
info!("Global slug index populated successfully.");
|
||||
}
|
||||
run_migrations(
|
||||
&mut reserved_conn,
|
||||
"reserved",
|
||||
RESERVED_SLUGS_MIGRATIONS,
|
||||
Some(&system_arc),
|
||||
)?;
|
||||
crate::db::slugs::seed_reserved_slugs(&reserved_conn)?;
|
||||
|
||||
let db = Self {
|
||||
admin: Arc::new(Mutex::new(admin_conn)),
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
system: system_arc,
|
||||
users: Arc::new(Mutex::new(users_conn)),
|
||||
global_urls: Arc::new(Mutex::new(global_urls_conn)),
|
||||
global_landing_pages: Arc::new(Mutex::new(global_landing_pages_conn)),
|
||||
reserved: Arc::new(Mutex::new(reserved_conn)),
|
||||
data_dir: config.data_dir.clone(),
|
||||
topology,
|
||||
};
|
||||
|
||||
let _ = db.reconcile_global_slugs(config);
|
||||
|
||||
// Post-init: Clean up stale reservations
|
||||
// Post-init: Clean up stale reservations from v0.8 slug databases (older than 15 mins)
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
match crate::db::users::cleanup_stale_reservations(&system_conn, &config.data_dir) {
|
||||
if let (Ok(urls_conn), Ok(pages_conn)) =
|
||||
(db.global_urls.lock(), db.global_landing_pages.lock())
|
||||
{
|
||||
match crate::db::slugs::cleanup_stale_reservations(&urls_conn, &pages_conn, 900) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("Cleaned up {} stale reserving slugs", count);
|
||||
tracing::info!(
|
||||
"Cleaned up {} stale reserving slugs from v0.8 registry",
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -351,30 +229,6 @@ impl Db {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Post-init: Verify global registry integrity
|
||||
{
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
match crate::services::registry_validator::RegistryValidator::scan(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&config.data_dir,
|
||||
None,
|
||||
) {
|
||||
Ok(issues) => {
|
||||
for issue in issues {
|
||||
tracing::error!(
|
||||
"Global registry integrity issue: {:?} for slug {}",
|
||||
issue.issue_type,
|
||||
issue.slug
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to verify global registry integrity: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(db)
|
||||
@@ -384,24 +238,33 @@ impl Db {
|
||||
let admin = self.admin.lock().unwrap();
|
||||
let _ = admin.execute("VACUUM;", []);
|
||||
|
||||
let content = self.content.lock().unwrap();
|
||||
let _ = content.execute("VACUUM;", []);
|
||||
|
||||
let analytics = self.analytics.lock().unwrap();
|
||||
let _ = analytics.execute("VACUUM;", []);
|
||||
|
||||
let system = self.system.lock().unwrap();
|
||||
let _ = system.execute("VACUUM;", []);
|
||||
|
||||
let users = self.users.lock().unwrap();
|
||||
let _ = users.execute("VACUUM;", []);
|
||||
|
||||
let global_urls = self.global_urls.lock().unwrap();
|
||||
let _ = global_urls.execute("VACUUM;", []);
|
||||
|
||||
let global_landing_pages = self.global_landing_pages.lock().unwrap();
|
||||
let _ = global_landing_pages.execute("VACUUM;", []);
|
||||
|
||||
let reserved = self.reserved.lock().unwrap();
|
||||
let _ = reserved.execute("VACUUM;", []);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn init_user_databases(&self, user_id: i64) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let user_dir = self.data_dir.join("users").join(user_id.to_string());
|
||||
fs::create_dir_all(&user_dir)?;
|
||||
let user = {
|
||||
let conn = self.users.lock().unwrap();
|
||||
crate::db::users::get_user_by_id(&conn, user_id)?
|
||||
.ok_or_else(|| format!("cannot provision databases for unknown user {user_id}"))?
|
||||
};
|
||||
let location = crate::db::tenant::location_for_user(&user)?;
|
||||
let user_dir = location.dir(&self.topology)?;
|
||||
fs::create_dir_all(user_dir.join("extensions"))?;
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
@@ -444,7 +307,7 @@ impl Db {
|
||||
|
||||
pub fn reconcile_global_slugs(
|
||||
&self,
|
||||
config: &Config,
|
||||
_config: &Config,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
use chrono::Utc;
|
||||
|
||||
@@ -462,8 +325,10 @@ impl Db {
|
||||
drop(stmt);
|
||||
|
||||
for user_id in user_ids {
|
||||
let user_dir = config.data_dir.join("users").join(user_id.to_string());
|
||||
let content_path = user_dir.join("content.db");
|
||||
let content_path = match self.topology.content_db_i64(user_id) {
|
||||
Ok(p) => p,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if content_path.exists() {
|
||||
let content_conn = Connection::open(&content_path)?;
|
||||
@@ -523,16 +388,14 @@ impl Db {
|
||||
#[cfg(test)]
|
||||
mod db_init_tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn test_db_init() {
|
||||
let temp_dir = PathBuf::from("./temp_test_db_dir");
|
||||
let temp_dir = std::env::temp_dir().join(format!("test_db_init_{}", uuid::Uuid::new_v4()));
|
||||
if temp_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
let mut config = Config::load();
|
||||
config.data_dir = temp_dir.clone();
|
||||
let config = Config::load_with_cli(Some(&temp_dir)).unwrap();
|
||||
let db = Db::init(&config);
|
||||
|
||||
// Cleanup
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Independently versioned v0.8 schemas.
|
||||
//!
|
||||
//! Phase 1 creates the frozen slug databases. Live slug allocate/lookup still
|
||||
//! uses `system.db.global_slugs` until Phase 4 moves ownership.
|
||||
|
||||
use super::migrations::Migration;
|
||||
|
||||
/// `slugs/global_urls.db` — globally unique URL slugs.
|
||||
///
|
||||
/// `owner_user_id` remains INTEGER to match v0.7 `users.id`. Phase 3 will
|
||||
/// migrate it to the 12-hex user id.
|
||||
pub const GLOBAL_URLS_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_urls (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_status ON global_urls(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_urls ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_urls_tenant ON global_urls(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/global_landing_pages.db` — globally unique landing-page slugs.
|
||||
pub const GLOBAL_LANDING_PAGES_MIGRATIONS: &[Migration] = &[
|
||||
Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS global_landing_pages (
|
||||
slug TEXT PRIMARY KEY,
|
||||
owner_tenant_id TEXT NOT NULL,
|
||||
target_id TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
retired_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_status ON global_landing_pages(status);
|
||||
"#,
|
||||
},
|
||||
Migration {
|
||||
version: 2,
|
||||
name: "tenant_id_column",
|
||||
sql: r#"
|
||||
ALTER TABLE global_landing_pages ADD COLUMN owner_tenant_id TEXT;
|
||||
CREATE INDEX IF NOT EXISTS idx_global_landing_pages_tenant ON global_landing_pages(owner_tenant_id);
|
||||
"#,
|
||||
},
|
||||
];
|
||||
|
||||
/// `slugs/reserved.db` — system route / reserved names that must never allocate.
|
||||
pub const RESERVED_SLUGS_MIGRATIONS: &[Migration] = &[Migration {
|
||||
version: 1,
|
||||
name: "initial_schema",
|
||||
sql: r#"
|
||||
CREATE TABLE IF NOT EXISTS reserved_slugs (
|
||||
slug TEXT PRIMARY KEY,
|
||||
reason TEXT
|
||||
);
|
||||
"#,
|
||||
}];
|
||||
|
||||
/// Allowed statuses for the v0.8 slug databases.
|
||||
///
|
||||
/// `reserving` is an allocation lock, not a published state.
|
||||
/// Frozen published states: `active`, `disabled`, `retired`.
|
||||
pub const SLUG_STATUS_RESERVING: &str = "reserving";
|
||||
pub const SLUG_STATUS_ACTIVE: &str = "active";
|
||||
pub const SLUG_STATUS_DISABLED: &str = "disabled";
|
||||
pub const SLUG_STATUS_RETIRED: &str = "retired";
|
||||
@@ -0,0 +1,538 @@
|
||||
//! Explicit Phase 4 Global Slug Migration Engine.
|
||||
//!
|
||||
//! Migrates `system.db.global_slugs` and `system.db.reserved_slugs` to:
|
||||
//! - `slugs/global_urls.db` (`global_urls` table)
|
||||
//! - `slugs/global_landing_pages.db` (`global_landing_pages` table)
|
||||
//! - `slugs/reserved.db` (`reserved_slugs` table)
|
||||
//!
|
||||
//! Lifecycle:
|
||||
//! 1. Preflight (inspect system.db, resolve owners against users.db, check for ambiguities)
|
||||
//! 2. Backup (create pre-migration tarball)
|
||||
//! 3. Transactional Migration (insert into target databases with restart safety)
|
||||
//! 4. Validation (row counts, field parity, global uniqueness across databases)
|
||||
//! 5. Completion Marker (record audit event and system setting)
|
||||
|
||||
use rusqlite::Connection;
|
||||
use std::collections::HashMap;
|
||||
use tracing::{info, warn};
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::topology::Topology;
|
||||
use crate::db::Db;
|
||||
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct SlugMigrationReport {
|
||||
pub total_legacy_slugs: usize,
|
||||
pub url_slugs_migrated: usize,
|
||||
pub page_slugs_migrated: usize,
|
||||
pub reserved_slugs_migrated: usize,
|
||||
pub existing_records_verified: usize,
|
||||
pub validation_passed: bool,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SlugPreflightReport {
|
||||
pub total_slugs: usize,
|
||||
pub url_slugs: usize,
|
||||
pub page_slugs: usize,
|
||||
pub reserved_slugs: usize,
|
||||
pub unresolvable_owners: Vec<(String, i64)>,
|
||||
pub unknown_target_types: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Helper struct for legacy slug record
|
||||
struct LegacySlugRecord {
|
||||
slug: String,
|
||||
owner_user_id: i64,
|
||||
target_type: String,
|
||||
target_id: String,
|
||||
created_at: String,
|
||||
updated_at: String,
|
||||
status: String,
|
||||
deleted_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Run the full explicit global slug migration.
|
||||
pub async fn run_global_slug_migration(
|
||||
config: &Config,
|
||||
dry_run: bool,
|
||||
skip_backup: bool,
|
||||
) -> Result<SlugMigrationReport, Box<dyn std::error::Error>> {
|
||||
let _topology = Topology::new(&config.data_dir);
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
// 1. Initialize Db
|
||||
let db = Db::init(config)?;
|
||||
|
||||
// 2. Preflight
|
||||
let preflight = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
inspect_slug_preflight(&system_conn, &users_conn)?
|
||||
};
|
||||
|
||||
info!(
|
||||
"Slug Migration Preflight: total={}, urls={}, pages={}, reserved={}, unresolvable_owners={}, unknown_types={}",
|
||||
preflight.total_slugs,
|
||||
preflight.url_slugs,
|
||||
preflight.page_slugs,
|
||||
preflight.reserved_slugs,
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unknown_target_types.len()
|
||||
);
|
||||
|
||||
if !preflight.unresolvable_owners.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unresolvable owner_user_id in users.db: {:?}",
|
||||
preflight.unresolvable_owners.len(),
|
||||
preflight.unresolvable_owners
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if !preflight.unknown_target_types.is_empty() {
|
||||
let msg = format!(
|
||||
"Fatal: Found {} slugs with unknown target_type: {:?}",
|
||||
preflight.unknown_target_types.len(),
|
||||
preflight.unknown_target_types
|
||||
);
|
||||
return Err(msg.into());
|
||||
}
|
||||
|
||||
if dry_run {
|
||||
info!("Dry run enabled: no slug records will be migrated.");
|
||||
return Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: preflight.url_slugs,
|
||||
page_slugs_migrated: preflight.page_slugs,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: 0,
|
||||
validation_passed: true,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Backup before migration (if needed and not skipped)
|
||||
if preflight.total_slugs > 0 && !skip_backup {
|
||||
info!("Creating pre-migration backup before slug migration...");
|
||||
match crate::jobs::backup::perform_backup(&db, config).await {
|
||||
Ok(path) => info!("Pre-migration backup created at {:?}", path),
|
||||
Err(e) => {
|
||||
warn!(
|
||||
"Pre-migration backup failed: {}. Continuing with caution.",
|
||||
e
|
||||
);
|
||||
warnings.push(format!("Pre-migration backup warning: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Transactional Migration
|
||||
let (migrated_urls, migrated_pages, verified_existing) = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
let mut urls_conn = db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
migrate_slugs_transactional(
|
||||
&system_conn,
|
||||
&users_conn,
|
||||
&mut urls_conn,
|
||||
&mut pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 5. Validation
|
||||
let validation_passed = {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let urls_conn = db.global_urls.lock().unwrap();
|
||||
let pages_conn = db.global_landing_pages.lock().unwrap();
|
||||
let reserved_conn = db.reserved.lock().unwrap();
|
||||
|
||||
validate_slug_migration(
|
||||
&system_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&reserved_conn,
|
||||
&mut warnings,
|
||||
)?
|
||||
};
|
||||
|
||||
// 6. Completion Marker in system.db
|
||||
if validation_passed {
|
||||
let system_conn = db.system.lock().unwrap();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let details = format!(
|
||||
"Global slug migration completed: {} URLs migrated, {} landing pages migrated, {} verified existing",
|
||||
migrated_urls, migrated_pages, verified_existing
|
||||
);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
"system",
|
||||
"GLOBAL_SLUG_MIGRATION_COMPLETED",
|
||||
"slugs",
|
||||
"slugs/*.db",
|
||||
Some(&details),
|
||||
);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT OR REPLACE INTO settings (key, value) VALUES ('v08_global_slug_migration_completed', ?1);",
|
||||
[&now],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(SlugMigrationReport {
|
||||
total_legacy_slugs: preflight.total_slugs,
|
||||
url_slugs_migrated: migrated_urls,
|
||||
page_slugs_migrated: migrated_pages,
|
||||
reserved_slugs_migrated: preflight.reserved_slugs,
|
||||
existing_records_verified: verified_existing,
|
||||
validation_passed,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
/// Inspect system.db.global_slugs and reserved_slugs to build preflight plan.
|
||||
pub fn inspect_slug_preflight(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
) -> Result<SlugPreflightReport, Box<dyn std::error::Error>> {
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(SlugPreflightReport {
|
||||
total_slugs: 0,
|
||||
url_slugs: 0,
|
||||
page_slugs: 0,
|
||||
reserved_slugs: 0,
|
||||
unresolvable_owners: Vec::new(),
|
||||
unknown_target_types: Vec::new(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records = stmt.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
// Build owner map from users.db: user_id -> TenantId/legacy_admin
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
let mut total_slugs = 0;
|
||||
let mut url_slugs = 0;
|
||||
let mut page_slugs = 0;
|
||||
let mut unresolvable_owners = Vec::new();
|
||||
let mut unknown_target_types = Vec::new();
|
||||
|
||||
for r in records {
|
||||
let rec = r?;
|
||||
total_slugs += 1;
|
||||
|
||||
if !owner_map.contains_key(&rec.owner_user_id) {
|
||||
unresolvable_owners.push((rec.slug.clone(), rec.owner_user_id));
|
||||
}
|
||||
|
||||
match rec.target_type.as_str() {
|
||||
"url" => url_slugs += 1,
|
||||
"page" => page_slugs += 1,
|
||||
other => unknown_target_types.push((rec.slug.clone(), other.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
let reserved_slugs: usize = system_conn
|
||||
.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
Ok(SlugPreflightReport {
|
||||
total_slugs,
|
||||
url_slugs,
|
||||
page_slugs,
|
||||
reserved_slugs,
|
||||
unresolvable_owners,
|
||||
unknown_target_types,
|
||||
})
|
||||
}
|
||||
|
||||
/// Transactional migration of slugs from system.db to global_urls.db and global_landing_pages.db.
|
||||
pub fn migrate_slugs_transactional(
|
||||
system_conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
urls_conn: &mut Connection,
|
||||
pages_conn: &mut Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<(usize, usize, usize), Box<dyn std::error::Error>> {
|
||||
// 1. Build owner map: user_id -> TenantId
|
||||
let mut owner_map = HashMap::new();
|
||||
let users = crate::db::users::list_users(users_conn)?;
|
||||
for u in users {
|
||||
if let Some(tid) = u.tenant_id {
|
||||
owner_map.insert(u.id, tid.as_str().to_string());
|
||||
} else if u.account_type == "admin"
|
||||
|| u.account_type == "system"
|
||||
|| u.username == "legacy_admin"
|
||||
{
|
||||
owner_map.insert(u.id, "legacy_admin".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch all legacy slugs
|
||||
let mut stmt = system_conn.prepare(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, updated_at, status, deleted_at
|
||||
FROM global_slugs;",
|
||||
)?;
|
||||
|
||||
let records: Vec<LegacySlugRecord> = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(LegacySlugRecord {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: row.get(1)?,
|
||||
target_type: row.get(2)?,
|
||||
target_id: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
updated_at: row.get(5)?,
|
||||
status: row.get(6)?,
|
||||
deleted_at: row.get(7)?,
|
||||
})
|
||||
})?
|
||||
.collect::<Result<_, _>>()?;
|
||||
|
||||
let mut migrated_urls = 0;
|
||||
let mut migrated_pages = 0;
|
||||
let mut verified_existing = 0;
|
||||
|
||||
let tx_urls = urls_conn.transaction()?;
|
||||
let tx_pages = pages_conn.transaction()?;
|
||||
|
||||
for rec in records {
|
||||
let owner_tenant_id = match owner_map.get(&rec.owner_user_id) {
|
||||
Some(t) => t.clone(),
|
||||
None => {
|
||||
warnings.push(format!(
|
||||
"Unresolvable owner_user_id {} for slug '{}'; skipping",
|
||||
rec.owner_user_id, rec.slug
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let retired_at = rec.deleted_at;
|
||||
|
||||
if rec.target_type == "url" {
|
||||
// Check if record already exists in global_urls.db
|
||||
let existing: Option<(String, String)> = tx_urls
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_urls WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_urls with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_urls.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_urls += 1;
|
||||
}
|
||||
} else if rec.target_type == "page" {
|
||||
// Check if record already exists in global_landing_pages.db
|
||||
let existing: Option<(String, String)> = tx_pages
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id, target_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&rec.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((existing_owner, existing_target)) = existing {
|
||||
if existing_owner == owner_tenant_id && existing_target == rec.target_id {
|
||||
verified_existing += 1;
|
||||
} else {
|
||||
warnings.push(format!(
|
||||
"Conflict: Slug '{}' already exists in global_landing_pages with different owner/target",
|
||||
rec.slug
|
||||
));
|
||||
}
|
||||
} else {
|
||||
tx_pages.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);",
|
||||
rusqlite::params![
|
||||
rec.slug,
|
||||
owner_tenant_id,
|
||||
rec.target_id,
|
||||
rec.created_at,
|
||||
rec.updated_at,
|
||||
rec.status,
|
||||
retired_at
|
||||
],
|
||||
)?;
|
||||
migrated_pages += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tx_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
// Seed reserved slugs
|
||||
let _ = crate::db::slugs::seed_reserved_slugs(reserved_conn);
|
||||
|
||||
Ok((migrated_urls, migrated_pages, verified_existing))
|
||||
}
|
||||
|
||||
/// Validate that every legacy slug was migrated correctly and global uniqueness holds.
|
||||
pub fn validate_slug_migration(
|
||||
system_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
warnings: &mut Vec<String>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let mut valid = true;
|
||||
|
||||
let has_global_slugs: bool = system_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
if !has_global_slugs {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let legacy_url_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'url';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let legacy_page_count: usize = system_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_slugs WHERE target_type = 'page';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
|
||||
let target_url_count: usize =
|
||||
urls_conn.query_row("SELECT COUNT(*) FROM global_urls;", [], |r| r.get(0))?;
|
||||
|
||||
let target_page_count: usize =
|
||||
pages_conn.query_row("SELECT COUNT(*) FROM global_landing_pages;", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
|
||||
if target_url_count < legacy_url_count {
|
||||
warnings.push(format!(
|
||||
"URL slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_url_count, target_url_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
if target_page_count < legacy_page_count {
|
||||
warnings.push(format!(
|
||||
"Page slug count mismatch: legacy has {}, target has {}",
|
||||
legacy_page_count, target_page_count
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
// Global Uniqueness Invariant Check: 0 intersection between reserved, urls, and pages
|
||||
let collisions_urls_pages: usize = urls_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM global_landing_pages);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_urls_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and global_landing_pages",
|
||||
collisions_urls_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_urls: usize = urls_conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
|
||||
if collisions_reserved_urls > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_urls and reserved_slugs",
|
||||
collisions_reserved_urls
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let collisions_reserved_pages: usize = pages_conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE slug IN (SELECT slug FROM reserved_slugs);",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
).unwrap_or(0);
|
||||
|
||||
if collisions_reserved_pages > 0 {
|
||||
warnings.push(format!(
|
||||
"Invariant Violation: {} slugs appear in both global_landing_pages and reserved_slugs",
|
||||
collisions_reserved_pages
|
||||
));
|
||||
valid = false;
|
||||
}
|
||||
|
||||
let _ = reserved_conn;
|
||||
|
||||
Ok(valid)
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
//! Frozen v0.8 Slug Registry Layer.
|
||||
//!
|
||||
//! Owns `slugs/global_urls.db`, `slugs/global_landing_pages.db`, and `slugs/reserved.db`.
|
||||
//!
|
||||
//! Guarantees:
|
||||
//! - Exact TenantId ownership (no integer ID primitives in v0.8 API).
|
||||
//! - Cross-database global uniqueness invariant: a slug exists in AT MOST ONE of
|
||||
//! `reserved.db`, `global_urls.db`, `global_landing_pages.db`.
|
||||
//! - Retired slugs remain permanently unavailable for reuse across both URLs and landing pages.
|
||||
//! - Concurrency-safe global allocations.
|
||||
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::db::schema_v08::{
|
||||
SLUG_STATUS_ACTIVE, SLUG_STATUS_DISABLED, SLUG_STATUS_RESERVING, SLUG_STATUS_RETIRED,
|
||||
};
|
||||
use crate::identity::TenantId;
|
||||
|
||||
/// Core reserved slugs, matching the v0.7 `system.db` seed list.
|
||||
pub const CORE_RESERVED_SLUGS: &[(&str, &str)] = &[
|
||||
("admin", "System route"),
|
||||
("login", "System route"),
|
||||
("logout", "System route"),
|
||||
("dashboard", "System route"),
|
||||
("api", "System route"),
|
||||
("docs", "System route"),
|
||||
("assets", "System route"),
|
||||
("static", "System route"),
|
||||
("favicon.ico", "System route"),
|
||||
("robots.txt", "System route"),
|
||||
("health", "System route"),
|
||||
("metrics", "System route"),
|
||||
("install", "System route"),
|
||||
("setup", "System route"),
|
||||
("support", "System route"),
|
||||
("help", "System route"),
|
||||
("security", "System route"),
|
||||
("abuse", "System route"),
|
||||
("billing", "System route"),
|
||||
("status", "System route"),
|
||||
("legacy_admin", "System reserved"),
|
||||
("administrator", "System reserved"),
|
||||
("system", "System reserved"),
|
||||
("root", "System reserved"),
|
||||
("www", "System reserved"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum SlugTargetType {
|
||||
Url,
|
||||
LandingPage,
|
||||
}
|
||||
|
||||
impl SlugTargetType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Url => "url",
|
||||
Self::LandingPage => "page",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ResolvedSlugInfo {
|
||||
pub slug: String,
|
||||
pub owner_tenant_id: String,
|
||||
pub target_type: SlugTargetType,
|
||||
pub target_id: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub status: String,
|
||||
pub retired_at: Option<String>,
|
||||
}
|
||||
|
||||
/// Insert the core reserved set. Idempotent (`INSERT OR IGNORE`).
|
||||
pub fn seed_reserved_slugs(conn: &Connection) -> rusqlite::Result<usize> {
|
||||
let mut inserted = 0usize;
|
||||
for (slug, reason) in CORE_RESERVED_SLUGS {
|
||||
let n = conn.execute(
|
||||
"INSERT OR IGNORE INTO reserved_slugs (slug, reason) VALUES (?1, ?2);",
|
||||
params![slug, reason],
|
||||
)?;
|
||||
inserted += n;
|
||||
}
|
||||
Ok(inserted)
|
||||
}
|
||||
|
||||
pub fn reserved_slug_count(conn: &Connection) -> rusqlite::Result<i64> {
|
||||
conn.query_row("SELECT COUNT(*) FROM reserved_slugs;", [], |row| row.get(0))
|
||||
}
|
||||
|
||||
/// Check whether a slug is reserved in `slugs/reserved.db`.
|
||||
pub fn is_slug_reserved(reserved_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
reserved_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Check whether a slug is available for a new registration.
|
||||
/// Returns false if reserved or if present in `global_urls.db` or `global_landing_pages.db`
|
||||
/// in any state (including `retired`).
|
||||
pub fn is_slug_available(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
if is_slug_reserved(reserved_conn, slug)? {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_urls: bool = urls_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_urls WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_urls {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let in_pages: bool = pages_conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM global_landing_pages WHERE slug = ?1);",
|
||||
[slug],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if in_pages {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_urls.db`.
|
||||
pub fn lookup_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
urls_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Lookup a slug in `slugs/global_landing_pages.db`.
|
||||
pub fn lookup_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
pages_conn
|
||||
.query_row(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
/// Unified slug lookup: checks `global_urls.db`, then `global_landing_pages.db`.
|
||||
pub fn lookup_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<Option<ResolvedSlugInfo>> {
|
||||
if let Some(info) = lookup_url_slug(urls_conn, slug)? {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
lookup_landing_page_slug(pages_conn, slug)
|
||||
}
|
||||
|
||||
/// Register a URL slug in `slugs/global_urls.db`.
|
||||
pub fn register_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Register a landing page slug in `slugs/global_landing_pages.db`.
|
||||
pub fn register_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
target_id: &str,
|
||||
status: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), target_id, now, now, status],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a URL slug in `slugs/global_urls.db` (status = 'reserving').
|
||||
pub fn reserve_url_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"INSERT INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomic reservation for a landing page slug in `slugs/global_landing_pages.db` (status = 'reserving').
|
||||
pub fn reserve_landing_page_slug(
|
||||
reserved_conn: &Connection,
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
if !is_slug_available(reserved_conn, urls_conn, pages_conn, slug)? {
|
||||
return Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("Slug is unavailable or reserved".into()),
|
||||
));
|
||||
}
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"INSERT INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, NULL);",
|
||||
params![slug, owner_tenant_id.as_str(), now, now, SLUG_STATUS_RESERVING],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving URL slug (e.g. if content creation fails).
|
||||
pub fn release_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Release a reserving Landing Page slug (e.g. if content creation fails).
|
||||
pub fn release_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<()> {
|
||||
pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1 AND owner_tenant_id = ?2 AND status = 'reserving';",
|
||||
params![slug, owner_tenant_id.as_str()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update URL slug target and activate after reservation.
|
||||
pub fn activate_url_slug(
|
||||
urls_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
urls_conn.execute(
|
||||
"UPDATE global_urls SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update Landing Page slug target and activate after reservation.
|
||||
pub fn activate_landing_page_slug(
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
target_id: &str,
|
||||
) -> rusqlite::Result<()> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET target_id = ?1, status = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![target_id, SLUG_STATUS_ACTIVE, now, slug],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Retire a slug permanently so it cannot be reused.
|
||||
pub fn retire_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, retired_at = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![SLUG_STATUS_RETIRED, now, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Disable a slug (returns 410 Gone on redirect, but still owned by tenant).
|
||||
pub fn disable_slug(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
params![SLUG_STATUS_DISABLED, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// Transfer slug ownership to a new tenant.
|
||||
pub fn transfer_slug_owner(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
slug: &str,
|
||||
new_owner_tenant_id: &TenantId,
|
||||
new_target_id: &str,
|
||||
) -> rusqlite::Result<bool> {
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let n_urls = urls_conn.execute(
|
||||
"UPDATE global_urls SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
if n_urls > 0 {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let n_pages = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET owner_tenant_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
params![new_owner_tenant_id.as_str(), new_target_id, now, slug],
|
||||
)?;
|
||||
Ok(n_pages > 0)
|
||||
}
|
||||
|
||||
/// List all slugs owned by a specific tenant.
|
||||
pub fn list_slugs_by_tenant(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
owner_tenant_id: &TenantId,
|
||||
) -> rusqlite::Result<Vec<ResolvedSlugInfo>> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
let mut stmt = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_urls WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::Url,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
let mut stmt = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at
|
||||
FROM global_landing_pages WHERE owner_tenant_id = ?1 ORDER BY created_at ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([owner_tenant_id.as_str()], |row| {
|
||||
Ok(ResolvedSlugInfo {
|
||||
slug: row.get(0)?,
|
||||
owner_tenant_id: row.get(1)?,
|
||||
target_type: SlugTargetType::LandingPage,
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
retired_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
for r in rows {
|
||||
results.push(r?);
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Clean up stale reservations older than threshold seconds.
|
||||
pub fn cleanup_stale_reservations(
|
||||
urls_conn: &Connection,
|
||||
pages_conn: &Connection,
|
||||
older_than_seconds: i64,
|
||||
) -> rusqlite::Result<usize> {
|
||||
let threshold = (Utc::now() - chrono::Duration::seconds(older_than_seconds)).to_rfc3339();
|
||||
let n1 = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
let n2 = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE status = 'reserving' AND created_at < ?1;",
|
||||
[&threshold],
|
||||
)?;
|
||||
Ok(n1 + n2)
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Tenant database access boundary.
|
||||
//!
|
||||
//! New tenant opens go through [`TenantId`]. Legacy integer row ids are used
|
||||
//! only to look up a registered Core user, then resolved to a [`TenantLocation`].
|
||||
//! Unknown ids must not create filesystem databases.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::topology::Topology;
|
||||
use crate::error::AppError;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::TenantUser;
|
||||
|
||||
/// Open tenant SQLite connections (content, analytics, profile).
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
/// How a tenant database may be opened.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum TenantOpenMode {
|
||||
/// Authenticated tenant user / API actor. Status must be `active`.
|
||||
Ordinary,
|
||||
/// Public slug/QR/gate resolution. User must exist and not be deleted.
|
||||
PublicContent,
|
||||
/// Core jobs / admin inspection. User must exist and not be deleted.
|
||||
/// Existing files only — will not create a database.
|
||||
CoreJob,
|
||||
/// Explicit provisioning after a Core user row was inserted.
|
||||
Provision,
|
||||
}
|
||||
|
||||
/// Resolved tenant filesystem location.
|
||||
///
|
||||
/// `Id` is the frozen v0.8 path. `Legacy` is unmigrated v0.7 `users/<integer>/`
|
||||
/// and exists only until Phase 3 directory migration.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum TenantLocation {
|
||||
Id(TenantId),
|
||||
Legacy(i64),
|
||||
}
|
||||
|
||||
impl TenantLocation {
|
||||
pub fn cache_key(&self) -> String {
|
||||
match self {
|
||||
Self::Id(id) => id.as_str().to_string(),
|
||||
Self::Legacy(row_id) => format!("legacy:{row_id}"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn dir(&self, topology: &Topology) -> Result<PathBuf, crate::db::topology::TopologyError> {
|
||||
match self {
|
||||
Self::Id(id) => Ok(topology.tenant_dir(*id)),
|
||||
Self::Legacy(row_id) => topology.user_dir_i64(*row_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn location_for_user(user: &TenantUser) -> Result<TenantLocation, AppError> {
|
||||
if let Some(id) = user.tenant_id {
|
||||
return Ok(TenantLocation::Id(id));
|
||||
}
|
||||
if user.id <= 0 {
|
||||
return Err(AppError::NotFound("invalid user id".into()));
|
||||
}
|
||||
Ok(TenantLocation::Legacy(user.id))
|
||||
}
|
||||
|
||||
pub fn status_allows_open(status: &str, mode: TenantOpenMode) -> bool {
|
||||
match mode {
|
||||
TenantOpenMode::Ordinary => status == "active",
|
||||
TenantOpenMode::PublicContent | TenantOpenMode::CoreJob | TenantOpenMode::Provision => {
|
||||
status != "deleted"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn assert_may_open(user: &TenantUser, mode: TenantOpenMode) -> Result<(), AppError> {
|
||||
if !status_allows_open(&user.status, mode) {
|
||||
return Err(AppError::Unauthorized(format!(
|
||||
"tenant access denied for status '{}'",
|
||||
user.status
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open tenant DBs for a registered Core user (legacy row id compatibility).
|
||||
pub fn open_for_row_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
user_id: i64,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("user {user_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
/// Open tenant DBs by frozen TenantId. Unknown ids never create files.
|
||||
pub fn open_for_tenant_id(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
tenant_id: TenantId,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let user = crate::db::users::get_user_by_tenant_id(users_conn, tenant_id)?
|
||||
.ok_or_else(|| AppError::NotFound(format!("tenant {tenant_id} not found")))?;
|
||||
assert_may_open(&user, mode)?;
|
||||
let location = location_for_user(&user)?;
|
||||
open_location(topology, system_db, pool, &location, mode)
|
||||
}
|
||||
|
||||
pub fn open_location(
|
||||
topology: &Topology,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
pool: &mut std::collections::HashMap<String, UserDbs>,
|
||||
location: &TenantLocation,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
let key = location.cache_key();
|
||||
if let Some(dbs) = pool.get(&key) {
|
||||
return Ok(dbs.clone());
|
||||
}
|
||||
|
||||
let user_dir = location
|
||||
.dir(topology)
|
||||
.map_err(|e| AppError::BadRequest(e.to_string()))?;
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
|
||||
let create = matches!(
|
||||
mode,
|
||||
TenantOpenMode::Provision | TenantOpenMode::Ordinary | TenantOpenMode::PublicContent
|
||||
);
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound(format!(
|
||||
"tenant database missing at {}",
|
||||
content_path.display()
|
||||
)));
|
||||
}
|
||||
if create {
|
||||
std::fs::create_dir_all(user_dir.join("extensions"))?;
|
||||
}
|
||||
|
||||
let dbs = open_files(
|
||||
&content_path,
|
||||
&analytics_path,
|
||||
&profile_path,
|
||||
system_db,
|
||||
create,
|
||||
)?;
|
||||
pool.insert(key, dbs.clone());
|
||||
Ok(dbs)
|
||||
}
|
||||
|
||||
fn open_files(
|
||||
content_path: &Path,
|
||||
analytics_path: &Path,
|
||||
profile_path: &Path,
|
||||
system_db: &Arc<Mutex<Connection>>,
|
||||
create: bool,
|
||||
) -> Result<UserDbs, AppError> {
|
||||
if !create && !content_path.exists() {
|
||||
return Err(AppError::NotFound("content.db missing".into()));
|
||||
}
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(system_db),
|
||||
)
|
||||
.map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
|
||||
Ok(UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Job/helper path: registered user, existing content.db only, never create.
|
||||
pub fn existing_content_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("content.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn existing_analytics_path(
|
||||
users_conn: &Connection,
|
||||
topology: &Topology,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, rusqlite::Error> {
|
||||
let user = crate::db::users::get_user_by_id(users_conn, user_id)?.ok_or_else(|| {
|
||||
rusqlite::Error::InvalidPath(PathBuf::from(format!("unknown-user-{user_id}")))
|
||||
})?;
|
||||
if user.status == "deleted" {
|
||||
return Err(rusqlite::Error::InvalidPath(PathBuf::from("deleted-user")));
|
||||
}
|
||||
let loc = location_for_user(&user)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let dir = loc
|
||||
.dir(topology)
|
||||
.map_err(|e| rusqlite::Error::InvalidPath(PathBuf::from(e.to_string())))?;
|
||||
let path = dir.join("analytics.db");
|
||||
if !path.exists() {
|
||||
return Err(rusqlite::Error::InvalidPath(path));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Frozen v0.8.0 database topology under a configurable physical root.
|
||||
//!
|
||||
//! The logical layout is:
|
||||
//!
|
||||
//! ```text
|
||||
//! <data_dir>/
|
||||
//! ├── admin/{admin,system,users}.db
|
||||
//! ├── slugs/{global_urls,global_landing_pages,reserved}.db
|
||||
//! └── users/<user-key>/{profile,content,analytics}.db
|
||||
//! └── extensions/<extension>/<extension>.db
|
||||
//! ```
|
||||
//!
|
||||
//! `<data_dir>` is `Config.data_dir` (CLI `--data-dir`, env `NX9_BZOD_DATA_DIR`, or config file).
|
||||
//! It is not renamed to `database/`. Production Docker, CasaOS, and
|
||||
//! `deploy.sh` bind this physical root.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::identity::TenantId;
|
||||
|
||||
/// Directory name of the migration-era `legacy_admin` tenant (`users.db` id = 1).
|
||||
pub const LEGACY_ADMIN_USER_KEY: &str = "1";
|
||||
|
||||
/// Frozen first-party extension names. There is no runtime plugin loader.
|
||||
pub const FIRST_PARTY_EXTENSIONS: &[&str] = &["cv", "certificates", "documents", "portfolio"];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TopologyError {
|
||||
InvalidUserDir { name: String },
|
||||
InvalidExtension { name: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TopologyError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::InvalidUserDir { name } => {
|
||||
write!(f, "invalid tenant directory name: {name:?}")
|
||||
}
|
||||
Self::InvalidExtension { name } => {
|
||||
write!(f, "invalid extension name: {name:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TopologyError {}
|
||||
|
||||
/// Authoritative resolver for every BZOD database path.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Topology {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl Topology {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn admin_dir(&self) -> PathBuf {
|
||||
self.root.join("admin")
|
||||
}
|
||||
|
||||
pub fn slugs_dir(&self) -> PathBuf {
|
||||
self.root.join("slugs")
|
||||
}
|
||||
|
||||
pub fn users_dir(&self) -> PathBuf {
|
||||
self.root.join("users")
|
||||
}
|
||||
|
||||
pub fn admin_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("admin.db")
|
||||
}
|
||||
|
||||
pub fn system_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("system.db")
|
||||
}
|
||||
|
||||
pub fn users_registry_db(&self) -> PathBuf {
|
||||
self.admin_dir().join("users.db")
|
||||
}
|
||||
|
||||
pub fn global_urls_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("global_urls.db")
|
||||
}
|
||||
|
||||
pub fn global_landing_pages_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("global_landing_pages.db")
|
||||
}
|
||||
|
||||
pub fn reserved_db(&self) -> PathBuf {
|
||||
self.slugs_dir().join("reserved.db")
|
||||
}
|
||||
|
||||
/// Pre-multi-tenant files that may still exist at the physical root.
|
||||
pub fn legacy_flat_admin_db(&self) -> PathBuf {
|
||||
self.root.join("admin.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_system_db(&self) -> PathBuf {
|
||||
self.root.join("system.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_users_db(&self) -> PathBuf {
|
||||
self.root.join("users.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_content_db(&self) -> PathBuf {
|
||||
self.root.join("content.db")
|
||||
}
|
||||
|
||||
pub fn legacy_flat_analytics_db(&self) -> PathBuf {
|
||||
self.root.join("analytics.db")
|
||||
}
|
||||
|
||||
pub fn legacy_admin_dir(&self) -> PathBuf {
|
||||
self.users_dir().join(LEGACY_ADMIN_USER_KEY)
|
||||
}
|
||||
|
||||
/// Frozen tenant directory: `users/<12-hex-TenantId>/`.
|
||||
pub fn tenant_dir(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.users_dir().join(tenant_id.as_str())
|
||||
}
|
||||
|
||||
pub fn tenant_content_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("content.db")
|
||||
}
|
||||
|
||||
pub fn tenant_analytics_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("analytics.db")
|
||||
}
|
||||
|
||||
pub fn tenant_profile_db(&self, tenant_id: TenantId) -> PathBuf {
|
||||
self.tenant_dir(tenant_id).join("profile.db")
|
||||
}
|
||||
|
||||
pub fn user_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
if !is_valid_user_dir_name(user_key) {
|
||||
return Err(TopologyError::InvalidUserDir {
|
||||
name: user_key.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self.users_dir().join(user_key))
|
||||
}
|
||||
|
||||
pub fn user_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.user_dir(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn content_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("content.db"))
|
||||
}
|
||||
|
||||
pub fn analytics_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("analytics.db"))
|
||||
}
|
||||
|
||||
pub fn profile_db(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("profile.db"))
|
||||
}
|
||||
|
||||
pub fn content_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.content_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn analytics_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.analytics_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn profile_db_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.profile_db(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn extensions_dir(&self, user_key: &str) -> Result<PathBuf, TopologyError> {
|
||||
Ok(self.user_dir(user_key)?.join("extensions"))
|
||||
}
|
||||
|
||||
pub fn extensions_dir_i64(&self, user_id: i64) -> Result<PathBuf, TopologyError> {
|
||||
self.extensions_dir(&user_id.to_string())
|
||||
}
|
||||
|
||||
pub fn extension_db(&self, user_key: &str, extension: &str) -> Result<PathBuf, TopologyError> {
|
||||
if !is_valid_extension_name(extension) {
|
||||
return Err(TopologyError::InvalidExtension {
|
||||
name: extension.to_string(),
|
||||
});
|
||||
}
|
||||
Ok(self
|
||||
.extensions_dir(user_key)?
|
||||
.join(extension)
|
||||
.join(format!("{extension}.db")))
|
||||
}
|
||||
|
||||
/// Create `admin/`, `slugs/`, and `users/` under the physical root.
|
||||
pub fn ensure_core_dirs(&self) -> std::io::Result<()> {
|
||||
std::fs::create_dir_all(self.admin_dir())?;
|
||||
std::fs::create_dir_all(self.slugs_dir())?;
|
||||
std::fs::create_dir_all(self.users_dir())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a tenant directory and its `extensions/` folder.
|
||||
pub fn ensure_user_dirs(&self, user_key: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let dir = self.user_dir(user_key)?;
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
std::fs::create_dir_all(dir.join("extensions"))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
pub fn ensure_user_dirs_i64(
|
||||
&self,
|
||||
user_id: i64,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
self.ensure_user_dirs(&user_id.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Tenant directory names: 12 lowercase hex (v0.8) or a positive decimal id (v0.7).
|
||||
pub fn is_valid_user_dir_name(name: &str) -> bool {
|
||||
if name.is_empty() || name == "." || name == ".." {
|
||||
return false;
|
||||
}
|
||||
if name
|
||||
.as_bytes()
|
||||
.iter()
|
||||
.any(|b| *b == b'/' || *b == b'\\' || *b == 0 || *b == b'.')
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if is_v08_user_id(name) {
|
||||
return true;
|
||||
}
|
||||
is_legacy_integer_user_id(name)
|
||||
}
|
||||
|
||||
pub fn is_v08_user_id(name: &str) -> bool {
|
||||
name.len() == 12 && name.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
|
||||
}
|
||||
|
||||
pub fn is_legacy_integer_user_id(name: &str) -> bool {
|
||||
if name.is_empty() || name.as_bytes()[0] == b'0' {
|
||||
return false;
|
||||
}
|
||||
name.bytes().all(|b| b.is_ascii_digit()) && name.parse::<i64>().map(|n| n > 0).unwrap_or(false)
|
||||
}
|
||||
|
||||
/// First-party extension directory names: `^[a-z][a-z0-9_]{0,31}$`.
|
||||
pub fn is_valid_extension_name(name: &str) -> bool {
|
||||
let mut chars = name.chars();
|
||||
match chars.next() {
|
||||
Some(c) if c.is_ascii_lowercase() => {}
|
||||
_ => return false,
|
||||
}
|
||||
name.len() <= 32
|
||||
&& name
|
||||
.bytes()
|
||||
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'_'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn physical_root_is_not_renamed_to_database() {
|
||||
let t = Topology::new("/var/lib/bzod/data");
|
||||
assert_eq!(t.root(), Path::new("/var/lib/bzod/data"));
|
||||
assert!(t.admin_dir().ends_with("data/admin"));
|
||||
assert!(t.slugs_dir().ends_with("data/slugs"));
|
||||
assert!(t.users_dir().ends_with("data/users"));
|
||||
assert!(!t.root().ends_with("database"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frozen_core_paths() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert_eq!(t.admin_db(), PathBuf::from("/app/data/admin/admin.db"));
|
||||
assert_eq!(t.system_db(), PathBuf::from("/app/data/admin/system.db"));
|
||||
assert_eq!(
|
||||
t.users_registry_db(),
|
||||
PathBuf::from("/app/data/admin/users.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.global_urls_db(),
|
||||
PathBuf::from("/app/data/slugs/global_urls.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.global_landing_pages_db(),
|
||||
PathBuf::from("/app/data/slugs/global_landing_pages.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.reserved_db(),
|
||||
PathBuf::from("/app/data/slugs/reserved.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tenant_paths_legacy_integer_and_v08_hex() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert_eq!(
|
||||
t.content_db_i64(2).unwrap(),
|
||||
PathBuf::from("/app/data/users/2/content.db")
|
||||
);
|
||||
let tid = crate::identity::TenantId::parse("a1b2c3d4e5f6").unwrap();
|
||||
assert_eq!(
|
||||
t.tenant_content_db(tid),
|
||||
PathBuf::from("/app/data/users/a1b2c3d4e5f6/content.db")
|
||||
);
|
||||
assert_eq!(
|
||||
t.extension_db("a1b2c3d4e5f6", "cv").unwrap(),
|
||||
PathBuf::from("/app/data/users/a1b2c3d4e5f6/extensions/cv/cv.db")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_path_traversal_and_forged_names() {
|
||||
let t = Topology::new("/app/data");
|
||||
assert!(t.user_dir("..").is_err());
|
||||
assert!(t.user_dir("../2").is_err());
|
||||
assert!(t.user_dir("2/../3").is_err());
|
||||
assert!(t.user_dir("2/foo").is_err());
|
||||
assert!(t.user_dir("-1").is_err());
|
||||
assert!(t.user_dir("0").is_err());
|
||||
assert!(t.user_dir("01").is_err());
|
||||
assert!(t.user_dir("").is_err());
|
||||
assert!(t.user_dir("ABCDEFABCDEF").is_err());
|
||||
assert!(t.content_db_i64(-5).is_err());
|
||||
assert!(t.content_db_i64(0).is_err());
|
||||
assert!(t.extension_db("2", "../cv").is_err());
|
||||
assert!(t.extension_db("2", "cv/db").is_err());
|
||||
assert!(t.extension_db("2", "").is_err());
|
||||
assert!(t.extension_db("2", "CV").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_party_extension_names_are_valid() {
|
||||
for name in FIRST_PARTY_EXTENSIONS {
|
||||
assert!(is_valid_extension_name(name), "{name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,70 @@
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::{TenantUser, UserApiToken, UserQuotas, UserSession};
|
||||
use chrono::Utc;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
const USER_COLUMNS: &str = "id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata, tenant_id, uuid";
|
||||
|
||||
fn map_user(row: &rusqlite::Row<'_>) -> rusqlite::Result<TenantUser> {
|
||||
let tenant_raw: Option<String> = row.get(9)?;
|
||||
let tenant_id = match tenant_raw {
|
||||
Some(s) => Some(TenantId::parse(&s).map_err(|e| {
|
||||
rusqlite::Error::FromSqlConversionFailure(9, rusqlite::types::Type::Text, Box::new(e))
|
||||
})?),
|
||||
None => None,
|
||||
};
|
||||
let uuid: Option<String> = row.get(10)?;
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
tenant_id,
|
||||
uuid,
|
||||
})
|
||||
}
|
||||
|
||||
fn allocate_unique_tenant_id(conn: &Connection) -> rusqlite::Result<TenantId> {
|
||||
for _ in 0..16 {
|
||||
let candidate = TenantId::generate();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE tenant_id = ?1);",
|
||||
[candidate.as_str()],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("failed to allocate unique TenantId".into()),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn allocate_unique_uuid(conn: &Connection) -> rusqlite::Result<String> {
|
||||
for _ in 0..16 {
|
||||
let candidate = uuid::Uuid::new_v4().to_string();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE uuid = ?1);",
|
||||
[&candidate],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(
|
||||
rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_CONSTRAINT),
|
||||
Some("failed to allocate unique UUID".into()),
|
||||
))
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
|
||||
pub fn is_reserved_username(username: &str) -> bool {
|
||||
@@ -17,11 +80,19 @@ pub fn create_admin_user(
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
let account_type = "admin";
|
||||
let user_uuid = allocate_unique_uuid(conn)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL);",
|
||||
params![username, password_hash, status, created_at, account_type],
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, NULL, NULL, ?6);",
|
||||
params![
|
||||
username,
|
||||
password_hash,
|
||||
status,
|
||||
created_at,
|
||||
account_type,
|
||||
user_uuid,
|
||||
],
|
||||
)?;
|
||||
|
||||
let id = conn.last_insert_rowid();
|
||||
@@ -39,6 +110,8 @@ pub fn create_admin_user(
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: None,
|
||||
tenant_id: None,
|
||||
uuid: Some(user_uuid),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -58,17 +131,21 @@ pub fn create_user(
|
||||
|
||||
let created_at = Utc::now().to_rfc3339();
|
||||
let status = "active";
|
||||
let tenant_id = allocate_unique_tenant_id(conn)?;
|
||||
let user_uuid = allocate_unique_uuid(conn)?;
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6);",
|
||||
"INSERT INTO users (username, password_hash, status, created_at, account_type, metadata, tenant_id, uuid)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);",
|
||||
params![
|
||||
username,
|
||||
password_hash,
|
||||
status,
|
||||
created_at,
|
||||
account_type,
|
||||
metadata
|
||||
metadata,
|
||||
tenant_id.as_str(),
|
||||
user_uuid,
|
||||
],
|
||||
)?;
|
||||
|
||||
@@ -87,27 +164,37 @@ pub fn create_user(
|
||||
account_type: account_type.to_string(),
|
||||
organization_id: None,
|
||||
metadata: metadata.map(|s| s.to_string()),
|
||||
tenant_id: Some(tenant_id),
|
||||
uuid: Some(user_uuid),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_user_by_id(conn: &Connection, id: i64) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE id = ?1;",
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE id = ?1;"),
|
||||
params![id],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn get_user_by_tenant_id(
|
||||
conn: &Connection,
|
||||
tenant_id: TenantId,
|
||||
) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE tenant_id = ?1;"),
|
||||
params![tenant_id.as_str()],
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
|
||||
pub fn get_user_by_uuid(conn: &Connection, uuid: &str) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE uuid = ?1;"),
|
||||
params![uuid],
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
@@ -117,22 +204,9 @@ pub fn get_user_by_username(
|
||||
username: &str,
|
||||
) -> rusqlite::Result<Option<TenantUser>> {
|
||||
conn.query_row(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users WHERE username = ?1;",
|
||||
&format!("SELECT {USER_COLUMNS} FROM users WHERE username = ?1;"),
|
||||
params![username],
|
||||
|row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
},
|
||||
map_user,
|
||||
)
|
||||
.optional()
|
||||
}
|
||||
@@ -184,23 +258,10 @@ pub fn update_user_last_login(conn: &Connection, id: i64) -> rusqlite::Result<()
|
||||
}
|
||||
|
||||
pub fn list_users(conn: &Connection) -> rusqlite::Result<Vec<TenantUser>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, username, password_hash, status, created_at, last_login, account_type, organization_id, metadata
|
||||
FROM users ORDER BY username ASC;",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok(TenantUser {
|
||||
id: row.get(0)?,
|
||||
username: row.get(1)?,
|
||||
password_hash: row.get(2)?,
|
||||
status: row.get(3)?,
|
||||
created_at: row.get(4)?,
|
||||
last_login: row.get(5)?,
|
||||
account_type: row.get(6)?,
|
||||
organization_id: row.get(7)?,
|
||||
metadata: row.get(8)?,
|
||||
})
|
||||
})?;
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT {USER_COLUMNS} FROM users ORDER BY username ASC;"
|
||||
))?;
|
||||
let rows = stmt.query_map([], map_user)?;
|
||||
|
||||
let mut users = Vec::new();
|
||||
for u in rows {
|
||||
@@ -439,6 +500,9 @@ pub fn delete_user_api_token(conn: &Connection, id: i64, user_id: i64) -> rusqli
|
||||
|
||||
// --- Global Slug & Quota Reconciliation Helpers ---
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::is_slug_available instead"
|
||||
)]
|
||||
pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Result<bool> {
|
||||
// 1. Check reserved list
|
||||
let reserved: bool = system_conn
|
||||
@@ -465,6 +529,9 @@ pub fn is_slug_available(system_conn: &Connection, slug: &str) -> rusqlite::Resu
|
||||
Ok(!exists)
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::reserve_*_slug instead"
|
||||
)]
|
||||
pub fn register_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -490,6 +557,9 @@ pub fn register_global_slug(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::release_*_slug instead"
|
||||
)]
|
||||
pub fn release_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -508,6 +578,7 @@ pub fn release_global_slug(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[deprecated(note = "Legacy v0.7 global_slugs function; use crate::db::slugs APIs instead")]
|
||||
pub fn soft_delete_global_slug(
|
||||
system_conn: &Connection,
|
||||
slug: &str,
|
||||
@@ -544,10 +615,11 @@ pub fn audit_slug_namespace(
|
||||
let mut invalid_entries = Vec::new();
|
||||
let warnings = Vec::new();
|
||||
|
||||
let mut slug_owners: HashMap<String, Vec<i64>> = HashMap::new();
|
||||
let mut slug_owners: HashMap<String, Vec<String>> = HashMap::new();
|
||||
|
||||
// 1. Scan legacy content.db if it exists
|
||||
let legacy_content_path = config.data_dir.join("content.db");
|
||||
let legacy_content_path =
|
||||
crate::db::topology::Topology::new(&config.data_dir).legacy_flat_content_db();
|
||||
if legacy_content_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&legacy_content_path) {
|
||||
// URLs
|
||||
@@ -555,7 +627,7 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1); // 1 = legacy admin
|
||||
slug_owners.entry(code).or_default().push("1".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -565,7 +637,7 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(1);
|
||||
slug_owners.entry(code).or_default().push("1".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -574,14 +646,14 @@ pub fn audit_slug_namespace(
|
||||
}
|
||||
|
||||
// 2. Scan all tenant databases in data_dir/users/<id>/content.db
|
||||
let users_dir = config.data_dir.join("users");
|
||||
let users_dir = crate::db::topology::Topology::new(&config.data_dir).users_dir();
|
||||
if users_dir.exists() {
|
||||
for entry in std::fs::read_dir(users_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name_str) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if let Ok(user_id) = name_str.parse::<i64>() {
|
||||
if crate::db::topology::is_valid_user_dir_name(name_str) {
|
||||
let content_db_path = path.join("content.db");
|
||||
if content_db_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&content_db_path) {
|
||||
@@ -590,7 +662,10 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
slug_owners
|
||||
.entry(code)
|
||||
.or_default()
|
||||
.push(name_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -602,7 +677,10 @@ pub fn audit_slug_namespace(
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
while let Some(row) = rows.next().unwrap_or(None) {
|
||||
if let Ok(code) = row.get::<_, String>(0) {
|
||||
slug_owners.entry(code).or_default().push(user_id);
|
||||
slug_owners
|
||||
.entry(code)
|
||||
.or_default()
|
||||
.push(name_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,6 +716,9 @@ pub fn audit_slug_namespace(
|
||||
})
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use crate::db::slugs::cleanup_stale_reservations instead"
|
||||
)]
|
||||
pub fn cleanup_stale_reservations(
|
||||
system_conn: &Connection,
|
||||
data_dir: &std::path::Path,
|
||||
@@ -661,18 +742,31 @@ pub fn cleanup_stale_reservations(
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::minutes(15) {
|
||||
// Check if target record exists by looking up code = slug in owner's content.db
|
||||
let content_db_path = if owner_user_id == 1 {
|
||||
let p1 = data_dir.join("users").join("1").join("content.db");
|
||||
if p1.exists() {
|
||||
p1
|
||||
let topology = crate::db::topology::Topology::new(data_dir);
|
||||
let content_db_path = {
|
||||
let users_path = topology.users_registry_db();
|
||||
if let Ok(users_conn) = Connection::open(&users_path) {
|
||||
crate::db::tenant::existing_content_path(
|
||||
&users_conn,
|
||||
&topology,
|
||||
owner_user_id,
|
||||
)
|
||||
.ok()
|
||||
.or_else(|| {
|
||||
if owner_user_id == 1 {
|
||||
Some(topology.legacy_flat_content_db())
|
||||
} else {
|
||||
data_dir.join("content.db")
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| topology.legacy_flat_content_db())
|
||||
} else if owner_user_id == 1 {
|
||||
topology.legacy_flat_content_db()
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("content.db")
|
||||
topology
|
||||
.content_db_i64(owner_user_id)
|
||||
.unwrap_or_else(|_| topology.legacy_flat_content_db())
|
||||
}
|
||||
};
|
||||
|
||||
let mut target_exists = false;
|
||||
@@ -722,6 +816,9 @@ pub fn cleanup_stale_reservations(
|
||||
Ok(cleaned_count)
|
||||
}
|
||||
|
||||
#[deprecated(
|
||||
note = "Legacy v0.7 global_slugs function; use v0.8 slug databases and TenantId instead"
|
||||
)]
|
||||
pub fn register_restored_user_slugs(
|
||||
system_conn: &Connection,
|
||||
target_user_id: i64,
|
||||
@@ -867,3 +964,29 @@ pub fn reconcile_user_quotas(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Calculate aggregate platform total clicks across all active tenant analytics databases.
|
||||
pub fn get_platform_total_clicks(
|
||||
topology: &crate::db::topology::Topology,
|
||||
users_conn: &Connection,
|
||||
) -> Option<i64> {
|
||||
let mut stmt = users_conn
|
||||
.prepare("SELECT tenant_id FROM users WHERE status != 'deleted' AND tenant_id IS NOT NULL;")
|
||||
.ok()?;
|
||||
let rows = stmt.query_map([], |row| row.get::<_, String>(0)).ok()?;
|
||||
let mut total = 0i64;
|
||||
for tid_str in rows.flatten() {
|
||||
if let Ok(tid) = crate::identity::TenantId::parse(&tid_str) {
|
||||
let analytics_path = topology.tenant_analytics_db(tid);
|
||||
if analytics_path.exists() {
|
||||
if let Ok(conn) = Connection::open(&analytics_path) {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM visits;", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
total += count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Frozen v0.8 tenant identity.
|
||||
//!
|
||||
//! `TenantId` is the filesystem-safe opaque tenant identifier: exactly 12
|
||||
//! lowercase hexadecimal characters, CSPRNG-generated, independent of username
|
||||
//! and of SQLite row ids.
|
||||
|
||||
use rand::{thread_rng, RngCore};
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Opaque tenant identifier: 12 lowercase hex characters (e.g. `fafafa12c3e4`).
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct TenantId {
|
||||
hex: [u8; Self::LEN],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum TenantIdError {
|
||||
InvalidLength { got: usize },
|
||||
InvalidCharacter { found: char },
|
||||
}
|
||||
|
||||
impl fmt::Display for TenantIdError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::InvalidLength { got } => {
|
||||
write!(
|
||||
f,
|
||||
"TenantId must be {} lowercase hex characters, got {got}",
|
||||
TenantId::LEN
|
||||
)
|
||||
}
|
||||
Self::InvalidCharacter { found } => {
|
||||
write!(f, "TenantId must be lowercase hexadecimal, found {found:?}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TenantIdError {}
|
||||
|
||||
impl TenantId {
|
||||
pub const LEN: usize = 12;
|
||||
|
||||
/// Cryptographically secure random TenantId. Never derived from user data.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = [0u8; 6];
|
||||
thread_rng().fill_bytes(&mut bytes);
|
||||
let encoded = hex::encode(bytes);
|
||||
Self::parse(&encoded).expect("CSPRNG hex encoding is 12 lowercase chars")
|
||||
}
|
||||
|
||||
pub fn parse(s: &str) -> Result<Self, TenantIdError> {
|
||||
if s.len() != Self::LEN {
|
||||
return Err(TenantIdError::InvalidLength { got: s.len() });
|
||||
}
|
||||
if let Some(found) = s.chars().find(|c| !matches!(c, '0'..='9' | 'a'..='f')) {
|
||||
return Err(TenantIdError::InvalidCharacter { found });
|
||||
}
|
||||
let mut hex = [0u8; Self::LEN];
|
||||
hex.copy_from_slice(s.as_bytes());
|
||||
Ok(Self { hex })
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &str {
|
||||
std::str::from_utf8(&self.hex).expect("TenantId is validated ASCII hex")
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for TenantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for TenantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_tuple("TenantId").field(&self.as_str()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for TenantId {
|
||||
type Err = TenantIdError;
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
Self::parse(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl serde::Serialize for TenantId {
|
||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(self.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> serde::Deserialize<'de> for TenantId {
|
||||
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Self::parse(&s).map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parse_accepts_lowercase_12_hex() {
|
||||
let id = TenantId::parse("fafafa12c3e4").unwrap();
|
||||
assert_eq!(id.as_str(), "fafafa12c3e4");
|
||||
assert_eq!(id, TenantId::parse("fafafa12c3e4").unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_uppercase_and_wrong_length() {
|
||||
assert!(matches!(
|
||||
TenantId::parse("FAFAFA12C3E4"),
|
||||
Err(TenantIdError::InvalidCharacter { found: 'F' })
|
||||
));
|
||||
assert!(matches!(
|
||||
TenantId::parse("abc"),
|
||||
Err(TenantIdError::InvalidLength { got: 3 })
|
||||
));
|
||||
assert!(TenantId::parse("a1b2c3d4e5f67").is_err());
|
||||
assert!(TenantId::parse("../etc/passwd").is_err());
|
||||
assert!(TenantId::parse("gggggggggggg").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generate_is_opaque_lowercase_hex() {
|
||||
let a = TenantId::generate();
|
||||
let b = TenantId::generate();
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(a.as_str().len(), 12);
|
||||
assert!(a
|
||||
.as_str()
|
||||
.chars()
|
||||
.all(|c| matches!(c, '0'..='9' | 'a'..='f')));
|
||||
}
|
||||
}
|
||||
@@ -65,20 +65,32 @@ pub async fn perform_backup(
|
||||
if let Ok(conn) = db.admin.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.content.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.analytics.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.system.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.users.lock() {
|
||||
if let Ok(conn) = db.global_urls.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.global_landing_pages.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.reserved.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
let user_ids: Vec<i64> = if let Ok(conn) = db.users.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
if let Ok(mut stmt) = conn.prepare("SELECT id FROM users;") {
|
||||
if let Ok(rows) = stmt.query_map([], |row| row.get::<_, i64>(0)) {
|
||||
let user_ids: Vec<i64> = rows.filter_map(|r| r.ok()).collect();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
for user_id in user_ids {
|
||||
if let Ok(u_conn) = crate::jobs::open_user_content_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
@@ -87,8 +99,14 @@ pub async fn perform_backup(
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
}
|
||||
if let Ok(conn) = db.global_urls.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.global_landing_pages.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
if let Ok(conn) = db.reserved.lock() {
|
||||
let _ = conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
|
||||
let date_str = Utc::now().format("%Y-%m-%d-%H%M%S").to_string();
|
||||
@@ -99,12 +117,17 @@ pub async fn perform_backup(
|
||||
let enc = GzEncoder::new(file, Compression::default());
|
||||
let mut tar = Builder::new(enc);
|
||||
|
||||
let admin_dir = config.data_dir.join("admin");
|
||||
let admin_dir = db.topology.admin_dir();
|
||||
if admin_dir.exists() {
|
||||
tar.append_dir_all("admin", &admin_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = config.data_dir.join("users");
|
||||
let slugs_dir = db.topology.slugs_dir();
|
||||
if slugs_dir.exists() {
|
||||
tar.append_dir_all("slugs", &slugs_dir)?;
|
||||
}
|
||||
|
||||
let users_dir = db.topology.users_dir();
|
||||
if users_dir.exists() {
|
||||
tar.append_dir_all("users", &users_dir)?;
|
||||
}
|
||||
|
||||
@@ -46,11 +46,12 @@ pub fn open_user_content_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("content.db");
|
||||
let db_path = {
|
||||
let users = db.users.lock().map_err(|_| {
|
||||
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||
})?;
|
||||
crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?
|
||||
};
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "content")?;
|
||||
@@ -61,11 +62,12 @@ pub fn open_user_analytics_conn(
|
||||
db: &Db,
|
||||
user_id: i64,
|
||||
) -> Result<rusqlite::Connection, rusqlite::Error> {
|
||||
let db_path = db
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user_id.to_string())
|
||||
.join("analytics.db");
|
||||
let db_path = {
|
||||
let users = db.users.lock().map_err(|_| {
|
||||
rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned"))
|
||||
})?;
|
||||
crate::db::tenant::existing_analytics_path(&users, &db.topology, user_id)?
|
||||
};
|
||||
let conn = rusqlite::Connection::open(db_path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&conn, "analytics")?;
|
||||
|
||||
@@ -37,9 +37,9 @@ pub async fn run_quota_reconciliation(
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
};
|
||||
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
for user_id in user_ids {
|
||||
if let Ok(content_conn) = super::open_user_content_conn(&db, user_id) {
|
||||
let users_conn = db.users.lock().unwrap();
|
||||
if let Err(e) =
|
||||
crate::db::users::reconcile_user_quotas(&users_conn, user_id, &content_conn)
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
|
||||
@@ -13,7 +13,13 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let config = Config::load();
|
||||
let config = match Config::load_with_cli(cli.command.data_dir()) {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
eprintln!("Error: {err}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match cli.command {
|
||||
Commands::Serve {
|
||||
@@ -42,7 +48,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
bzod::cli::audit_destinations::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::CreateAdmin { username, data_dir } => {
|
||||
bzod::cli::create_admin::run(username, data_dir, config).await?;
|
||||
bzod::cli::create_admin::run(username, None, data_dir, config).await?;
|
||||
}
|
||||
Commands::InitAdmin { data_dir } => {
|
||||
bzod::cli::init_admin::run(data_dir, config).await?;
|
||||
}
|
||||
Commands::Doctor { data_dir } => {
|
||||
bzod::cli::doctor::run(data_dir, config).await?;
|
||||
|
||||
@@ -27,6 +27,24 @@ pub struct TenantUser {
|
||||
pub account_type: String, // 'system', 'admin', 'standard', 'organization', 'service'
|
||||
pub organization_id: Option<i64>,
|
||||
pub metadata: Option<String>,
|
||||
/// Frozen v0.8 tenant id. None only for unmigrated v0.7 rows (Phase 3).
|
||||
pub tenant_id: Option<crate::identity::TenantId>,
|
||||
/// Frozen v0.8 immutable UUID.
|
||||
pub uuid: Option<String>,
|
||||
}
|
||||
|
||||
impl TenantUser {
|
||||
pub fn require_tenant_id(&self) -> Result<crate::identity::TenantId, crate::error::AppError> {
|
||||
self.tenant_id.ok_or_else(|| {
|
||||
crate::error::AppError::Internal(format!("user {} has unmigrated tenant_id", self.id))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn require_uuid(&self) -> Result<&str, crate::error::AppError> {
|
||||
self.uuid.as_deref().ok_or_else(|| {
|
||||
crate::error::AppError::Internal(format!("user {} has unmigrated uuid", self.id))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::identity::TenantId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
@@ -12,6 +13,9 @@ pub struct VisitRecord {
|
||||
pub accept_language: String,
|
||||
pub country: String,
|
||||
pub status_code: u16,
|
||||
#[serde(default)]
|
||||
pub owner_tenant_id: Option<TenantId>,
|
||||
#[serde(default)]
|
||||
pub owner_user_id: Option<i64>,
|
||||
}
|
||||
|
||||
|
||||
@@ -5,16 +5,21 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use tracing::warn;
|
||||
|
||||
use crate::db::topology::{Topology, LEGACY_ADMIN_USER_KEY};
|
||||
|
||||
/// Move flat legacy DB files into multi-tenant paths after tarball extract.
|
||||
///
|
||||
/// Layout:
|
||||
/// - `admin.db` / `system.db` / `users.db` (+ wal/shm) → `{data_dir}/admin/`
|
||||
/// - `content.db` / `analytics.db` (+ wal/shm) → `{data_dir}/users/1/`
|
||||
/// - `{data_dir}/slugs/` is created empty if missing (v0.8 topology)
|
||||
pub fn normalize_restored_layout(data_dir: &Path) -> std::io::Result<()> {
|
||||
let admin_dir = data_dir.join("admin");
|
||||
let users_1_dir = data_dir.join("users").join("1");
|
||||
let topology = Topology::new(data_dir);
|
||||
let admin_dir = topology.admin_dir();
|
||||
let users_1_dir = topology.legacy_admin_dir();
|
||||
std::fs::create_dir_all(&admin_dir)?;
|
||||
std::fs::create_dir_all(&users_1_dir)?;
|
||||
std::fs::create_dir_all(topology.slugs_dir())?;
|
||||
|
||||
let admin_files = [
|
||||
"admin.db",
|
||||
@@ -80,12 +85,17 @@ pub struct RestoredDbPaths {
|
||||
|
||||
impl RestoredDbPaths {
|
||||
pub fn from_data_dir(data_dir: &Path) -> Self {
|
||||
let topology = Topology::new(data_dir);
|
||||
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"),
|
||||
admin: topology.admin_db(),
|
||||
system: topology.system_db(),
|
||||
users: topology.users_registry_db(),
|
||||
content: topology
|
||||
.content_db(LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
analytics: topology
|
||||
.analytics_db(LEGACY_ADMIN_USER_KEY)
|
||||
.expect("legacy admin user key is valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -113,6 +123,7 @@ mod tests {
|
||||
assert!(dir.join("admin/users.db").exists());
|
||||
assert!(dir.join("users/1/content.db").exists());
|
||||
assert!(dir.join("users/1/analytics.db").exists());
|
||||
assert!(dir.join("slugs").is_dir());
|
||||
assert!(!dir.join("admin.db").exists());
|
||||
assert!(!dir.join("content.db").exists());
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
use crate::auth::generate_token;
|
||||
use crate::auth::password::hash_password;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::Url;
|
||||
use crate::utils::validation::validate_redirect_destination;
|
||||
use rusqlite::{Connection, Transaction};
|
||||
@@ -38,9 +39,9 @@ impl BulkUrlError {
|
||||
}
|
||||
}
|
||||
|
||||
fn release_reserved(system: &Connection, slugs: &[String], owner_user_id: i64) {
|
||||
fn release_reserved(urls_conn: &Connection, slugs: &[String], owner_tenant_id: &TenantId) {
|
||||
for slug in slugs {
|
||||
let _ = crate::db::users::release_global_slug(system, slug, owner_user_id);
|
||||
let _ = crate::db::slugs::release_url_slug(urls_conn, slug, owner_tenant_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,11 +67,15 @@ pub fn ensure_url_quota(
|
||||
}
|
||||
|
||||
/// Create many URLs inside a single content transaction with global slug reservation.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_urls_bulk(
|
||||
content_db: &Mutex<Connection>,
|
||||
system_db: &Mutex<Connection>,
|
||||
reserved_db: &Mutex<Connection>,
|
||||
global_urls_db: &Mutex<Connection>,
|
||||
global_landing_pages_db: &Mutex<Connection>,
|
||||
users_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
owner_tenant_id: TenantId,
|
||||
items: Vec<BulkUrlCreateItem>,
|
||||
) -> Result<Vec<Url>, BulkUrlError> {
|
||||
let mut conn = crate::utils::lock_db(content_db, "content_db")
|
||||
@@ -83,12 +88,20 @@ pub fn create_urls_bulk(
|
||||
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) {
|
||||
match create_one_in_tx(
|
||||
&tx,
|
||||
reserved_db,
|
||||
global_urls_db,
|
||||
global_landing_pages_db,
|
||||
&owner_tenant_id,
|
||||
item,
|
||||
&mut reserved_slugs,
|
||||
) {
|
||||
Ok(url) => created_urls.push(url),
|
||||
Err(e) => {
|
||||
let _ = tx.rollback();
|
||||
if let Ok(system_conn) = crate::utils::lock_db(system_db, "system_db") {
|
||||
release_reserved(&system_conn, &reserved_slugs, owner_user_id);
|
||||
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
|
||||
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
@@ -96,23 +109,20 @@ pub fn create_urls_bulk(
|
||||
}
|
||||
|
||||
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);
|
||||
if let Ok(urls_conn) = crate::utils::lock_db(global_urls_db, "global_urls_db") {
|
||||
release_reserved(&urls_conn, &reserved_slugs, &owner_tenant_id);
|
||||
}
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to commit transaction: {e}"
|
||||
)));
|
||||
}
|
||||
|
||||
// Activate slugs
|
||||
// Activate slugs in v0.8 global_urls.db
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
for url in &created_urls {
|
||||
let _ = 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],
|
||||
);
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &url.code, &url.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,37 +140,47 @@ pub fn create_urls_bulk(
|
||||
|
||||
fn create_one_in_tx(
|
||||
tx: &Transaction<'_>,
|
||||
system_db: &Mutex<Connection>,
|
||||
owner_user_id: i64,
|
||||
reserved_db: &Mutex<Connection>,
|
||||
global_urls_db: &Mutex<Connection>,
|
||||
global_landing_pages_db: &Mutex<Connection>,
|
||||
owner_tenant_id: &TenantId,
|
||||
item: BulkUrlCreateItem,
|
||||
reserved_slugs: &mut Vec<String>,
|
||||
) -> Result<Url, BulkUrlError> {
|
||||
let mut code = item.code.unwrap_or_default().trim().to_lowercase();
|
||||
if code.is_empty() {
|
||||
code = generate_token(3);
|
||||
} else if code.len() != 6 || !code.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
} else if !crate::utils::validation::validate_redirect_code(&code) {
|
||||
return Err(BulkUrlError::BadRequest(format!(
|
||||
"Short code '{code}' must be 6 hex characters"
|
||||
"Short code or slug '{code}' is invalid (must be 6 hex characters or !custom-slug)"
|
||||
)));
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = crate::utils::lock_db(system_db, "system_db")
|
||||
let reserved_conn = crate::utils::lock_db(reserved_db, "reserved_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let available = crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false)
|
||||
let urls_conn = crate::utils::lock_db(global_urls_db, "global_urls_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
let pages_conn = crate::utils::lock_db(global_landing_pages_db, "global_landing_pages_db")
|
||||
.map_err(|e| BulkUrlError::Internal(e.to_string()))?;
|
||||
|
||||
let available =
|
||||
crate::db::slugs::is_slug_available(&reserved_conn, &urls_conn, &pages_conn, &code)
|
||||
.unwrap_or(false)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
|
||||
if !available {
|
||||
return Err(BulkUrlError::Conflict(format!(
|
||||
"Short code '{code}' already exists"
|
||||
)));
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
owner_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
owner_tenant_id,
|
||||
) {
|
||||
return Err(BulkUrlError::Internal(format!(
|
||||
"Failed to reserve slug '{code}': {e}"
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
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.
|
||||
@@ -124,14 +123,12 @@ pub fn audit_content_conn(
|
||||
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));
|
||||
}
|
||||
fn open_user_content(db: &Db, user_id: i64) -> Result<Connection, rusqlite::Error> {
|
||||
let users = db
|
||||
.users
|
||||
.lock()
|
||||
.map_err(|_| rusqlite::Error::InvalidPath(std::path::PathBuf::from("users-db-poisoned")))?;
|
||||
let path = crate::db::tenant::existing_content_path(&users, &db.topology, user_id)?;
|
||||
let conn = Connection::open(path)?;
|
||||
crate::db::sqlite::enable_wal(&conn, "content")?;
|
||||
Ok(conn)
|
||||
@@ -158,7 +155,7 @@ pub fn audit_all_destinations(db: &Db) -> Result<DestinationAuditReport, String>
|
||||
};
|
||||
|
||||
for user_id in user_ids {
|
||||
match open_user_content(&db.data_dir, user_id) {
|
||||
match open_user_content(db, user_id) {
|
||||
Ok(conn) => {
|
||||
report.scanned_users += 1;
|
||||
if let Err(e) = audit_content_conn(&conn, user_id, &mut report) {
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
use crate::db::Db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::LandingPage;
|
||||
|
||||
pub fn create_landing_page(
|
||||
db: &Db,
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
slug: &str,
|
||||
title: &str,
|
||||
html_content: &str,
|
||||
state: &str,
|
||||
) -> Result<LandingPage, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page =
|
||||
crate::db::content::create_landing_page(&conn, code, slug, title, html_content, state)?;
|
||||
crate::db::content::create_landing_page(conn, code, slug, title, html_content, state)?;
|
||||
Ok(page)
|
||||
}
|
||||
|
||||
pub fn get_landing_page_by_code(db: &Db, code: &str) -> Result<Option<LandingPage>, AppError> {
|
||||
let conn = db.content.lock().unwrap();
|
||||
let page = crate::db::content::get_landing_page_by_code(&conn, code)?;
|
||||
pub fn get_landing_page_by_code(
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
) -> Result<Option<LandingPage>, AppError> {
|
||||
let page = crate::db::content::get_landing_page_by_code(conn, code)?;
|
||||
Ok(page)
|
||||
}
|
||||
@@ -1,23 +1,27 @@
|
||||
use crate::db::topology::Topology;
|
||||
use crate::identity::TenantId;
|
||||
use chrono::{DateTime, Utc};
|
||||
use rusqlite::Connection;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RegistryIssueType {
|
||||
DuplicateSlug,
|
||||
InvalidTargetType,
|
||||
InvalidStatus,
|
||||
MissingOwner,
|
||||
MissingDatabase,
|
||||
MissingTenant,
|
||||
MissingTarget,
|
||||
CorruptDatabase,
|
||||
AccessFailure,
|
||||
TrueOrphan,
|
||||
Conflict,
|
||||
StaleReservation,
|
||||
TenantAdminHasIsolatedContent,
|
||||
InvalidStatus,
|
||||
InvalidTargetType,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RegistryIssue {
|
||||
pub slug: String,
|
||||
pub target_type: String,
|
||||
pub owner_user_id: i64,
|
||||
pub owner_tenant_id: String,
|
||||
pub database_path: PathBuf,
|
||||
pub target_id: String,
|
||||
pub issue_type: RegistryIssueType,
|
||||
@@ -27,137 +31,237 @@ pub struct RegistryIssue {
|
||||
pub struct RegistryValidator;
|
||||
|
||||
impl RegistryValidator {
|
||||
/// Scans the global_slugs registry and returns a list of detected issues.
|
||||
/// Scans the v0.8 slug registries (`global_urls.db`, `global_landing_pages.db`, `reserved.db`)
|
||||
/// and returns a list of detected issues categorized per safety policies.
|
||||
pub fn scan(
|
||||
system_conn: &Connection,
|
||||
_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 topology = Topology::new(data_dir);
|
||||
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 {
|
||||
let urls_path = topology.global_urls_db();
|
||||
let pages_path = topology.global_landing_pages_db();
|
||||
let reserved_path = topology.reserved_db();
|
||||
|
||||
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: 0,
|
||||
database_path: data_dir.join("admin/system.db"),
|
||||
owner_tenant_id: "".to_string(),
|
||||
database_path: urls_path,
|
||||
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
|
||||
),
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: "One or more v0.8 slug databases are missing from disk".to_string(),
|
||||
});
|
||||
}
|
||||
return Ok(issues);
|
||||
}
|
||||
|
||||
// 2. Scan global slugs
|
||||
let (query, params_string) = if let Some(slug) = slug_filter {
|
||||
let urls_conn = Connection::open(&urls_path)?;
|
||||
let pages_conn = Connection::open(&pages_path)?;
|
||||
let reserved_conn = Connection::open(&reserved_path)?;
|
||||
|
||||
// 1. Scan global_urls.db
|
||||
Self::scan_table(
|
||||
&urls_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&pages_conn,
|
||||
&topology,
|
||||
"url",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
// 2. Scan global_landing_pages.db
|
||||
Self::scan_table(
|
||||
&pages_conn,
|
||||
users_conn,
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&topology,
|
||||
"page",
|
||||
slug_filter,
|
||||
&mut issues,
|
||||
)?;
|
||||
|
||||
Ok(issues)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn scan_table(
|
||||
conn: &Connection,
|
||||
users_conn: &Connection,
|
||||
reserved_conn: &Connection,
|
||||
other_conn: &Connection,
|
||||
topology: &Topology,
|
||||
target_type: &str,
|
||||
slug_filter: Option<&str>,
|
||||
issues: &mut Vec<RegistryIssue>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (query, params_vec) = if let Some(slug) = slug_filter {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs WHERE slug = ?1;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s WHERE slug = ?1;",
|
||||
if target_type == "url" { "url" } else { "landing_page" }
|
||||
),
|
||||
vec![slug.to_string()],
|
||||
)
|
||||
} else {
|
||||
(
|
||||
"SELECT slug, owner_user_id, target_type, target_id, created_at, status FROM global_slugs;",
|
||||
format!(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, status FROM global_{}s;",
|
||||
if target_type == "url" {
|
||||
"url"
|
||||
} else {
|
||||
"landing_page"
|
||||
}
|
||||
),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let mut stmt = system_conn.prepare(query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_string))?;
|
||||
let mut stmt = conn.prepare(&query)?;
|
||||
let mut rows = stmt.query(rusqlite::params_from_iter(params_vec))?;
|
||||
|
||||
while let Some(row) = rows.next()? {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_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 owner_tenant_id_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at_str: String = row.get(3)?;
|
||||
let status: String = row.get(4)?;
|
||||
|
||||
let 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")
|
||||
let tenant_id_res = TenantId::parse(&owner_tenant_id_str);
|
||||
let content_db_path = match tenant_id_res {
|
||||
Ok(tid) => topology.tenant_dir(tid).join("content.db"),
|
||||
Err(_) => topology.users_dir().join("_invalid").join("content.db"),
|
||||
};
|
||||
|
||||
// Target type check
|
||||
if target_type != "url" && target_type != "page" {
|
||||
// 1. Conflict with reserved.db
|
||||
let is_reserved: bool = reserved_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM reserved_slugs WHERE slug = ?1);",
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_reserved {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: topology.reserved_db(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidTargetType,
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!("Slug '{}' conflicts with a reserved system route", slug),
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Conflict with the other slug database
|
||||
let other_table = if target_type == "url" {
|
||||
"global_landing_pages"
|
||||
} else {
|
||||
"global_urls"
|
||||
};
|
||||
let in_other: bool = other_conn
|
||||
.query_row(
|
||||
&format!("SELECT EXISTS(SELECT 1 FROM {other_table} WHERE slug = ?1);"),
|
||||
[&slug],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if in_other {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::Conflict,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid target_type '{}'",
|
||||
slug, target_type
|
||||
"Slug '{}' exists in both global_urls.db and global_landing_pages.db",
|
||||
slug
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Status check
|
||||
if status != "active" && status != "disabled" && status != "reserving" {
|
||||
// 3. Status check
|
||||
if status != "active"
|
||||
&& status != "disabled"
|
||||
&& status != "reserving"
|
||||
&& status != "retired"
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
database_path: content_db_path.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: conn.path().map(PathBuf::from).unwrap_or_default(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::InvalidStatus,
|
||||
description: format!("Slug '{}' has invalid status '{}'", slug, status),
|
||||
});
|
||||
}
|
||||
|
||||
// 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 retired, no active target check is needed
|
||||
if status == "retired" {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Owner tenant check in users.db
|
||||
let tid = match tenant_id_res {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path,
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' has invalid TenantId '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let owner_opt = crate::db::users::get_user_by_tenant_id(users_conn, tid)?;
|
||||
let owner_exists = match owner_opt {
|
||||
Some(ref u) => u.status != "deleted",
|
||||
None => false,
|
||||
};
|
||||
|
||||
if !owner_exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingOwner,
|
||||
issue_type: RegistryIssueType::MissingTenant,
|
||||
description: format!(
|
||||
"Slug '{}' references missing owner user ID {}",
|
||||
slug, owner_user_id
|
||||
"Slug '{}' references missing or deleted owner tenant '{}'",
|
||||
slug, owner_tenant_id_str
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Stale warning check
|
||||
// 5. Stale reservation check
|
||||
if status == "reserving" {
|
||||
if let Ok(created_at) = DateTime::parse_from_rfc3339(&created_at_str) {
|
||||
let age = Utc::now().signed_duration_since(created_at.with_timezone(&Utc));
|
||||
if age > chrono::Duration::try_minutes(15).unwrap_or_default() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::StaleReservation,
|
||||
@@ -170,19 +274,16 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// Check target record exists for active / disabled (and reserving with target_id)
|
||||
if status == "active"
|
||||
|| status == "disabled"
|
||||
|| (status == "reserving" && !target_id.is_empty())
|
||||
{
|
||||
// 6. Target record check in tenant content DB
|
||||
if status == "active" || status == "disabled" {
|
||||
if !content_db_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::TrueOrphan,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database does not exist at {:?}",
|
||||
slug, content_db_path
|
||||
@@ -190,47 +291,66 @@ impl RegistryValidator {
|
||||
});
|
||||
} else {
|
||||
match Connection::open(&content_db_path) {
|
||||
Ok(conn) => {
|
||||
Ok(tenant_conn) => {
|
||||
let exists = if target_type == "url" {
|
||||
conn.query_row(
|
||||
tenant_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(
|
||||
} else {
|
||||
tenant_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,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingTarget,
|
||||
description: format!("Slug '{}' (type: '{}', id: '{}') references missing target record in owner's content database", slug, target_type, target_id),
|
||||
description: format!(
|
||||
"Slug '{}' (type: '{}', id: '{}') references missing target record in tenant content database",
|
||||
slug, target_type, target_id
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Err(rusqlite::Error::SqliteFailure(err, _))
|
||||
if err.code == rusqlite::ErrorCode::DatabaseCorrupt =>
|
||||
{
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::CorruptDatabase,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database is corrupt at {:?}",
|
||||
slug, content_db_path
|
||||
),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.clone(),
|
||||
owner_user_id,
|
||||
target_type: target_type.to_string(),
|
||||
owner_tenant_id: owner_tenant_id_str.clone(),
|
||||
database_path: content_db_path.clone(),
|
||||
target_id: target_id.clone(),
|
||||
issue_type: RegistryIssueType::MissingDatabase,
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: format!(
|
||||
"Slug '{}' owner content database could not be opened: {}",
|
||||
"Slug '{}' owner content database could not be accessed: {}",
|
||||
slug, e
|
||||
),
|
||||
});
|
||||
@@ -240,46 +360,6 @@ impl RegistryValidator {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
use crate::db::Db;
|
||||
use crate::error::AppError;
|
||||
use crate::models::Url;
|
||||
|
||||
pub fn create_url(
|
||||
db: &Db,
|
||||
conn: &rusqlite::Connection,
|
||||
code: &str,
|
||||
destination: &str,
|
||||
title: Option<&str>,
|
||||
@@ -15,19 +14,11 @@ pub fn create_url(
|
||||
"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)?;
|
||||
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()
|
||||
.map_err(|e| AppError::Internal(format!("content_db mutex poisoned: {}", e)))?;
|
||||
let url = crate::db::content::get_url_by_code(&conn, code)?;
|
||||
pub fn get_url_by_code(conn: &rusqlite::Connection, code: &str) -> Result<Option<Url>, AppError> {
|
||||
let url = crate::db::content::get_url_by_code(conn, code)?;
|
||||
Ok(url)
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
//! 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.
|
||||
//! Copies URL/page content between tenant content DBs, then updates v0.8 slug
|
||||
//! databases ownership. Handlers own admin auth and HTTP mapping.
|
||||
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::identity::TenantId;
|
||||
use crate::state::{AppState, UserDbs};
|
||||
use crate::utils::lock_db;
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TransferError {
|
||||
@@ -34,31 +35,21 @@ pub struct SlugTransferRequest {
|
||||
pub struct SlugTransferResult {
|
||||
pub old_owner_user_id: i64,
|
||||
pub new_owner_user_id: i64,
|
||||
pub old_owner_tenant_id: TenantId,
|
||||
pub new_owner_tenant_id: TenantId,
|
||||
pub target_type: String,
|
||||
pub new_target_id: String,
|
||||
}
|
||||
|
||||
/// Look up slug ownership in `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")),
|
||||
/// Look up slug ownership in v0.8 slug databases (`global_urls.db` / `global_landing_pages.db`).
|
||||
pub fn lookup_slug(
|
||||
state: &AppState,
|
||||
slug: &str,
|
||||
) -> Result<crate::db::slugs::ResolvedSlugInfo, TransferError> {
|
||||
match state.lookup_slug(slug) {
|
||||
Ok(Some(info)) => Ok(info),
|
||||
Ok(None) => Err(TransferError::NotFound("Slug not found")),
|
||||
Err(e) => Err(TransferError::Internal(e.to_string())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,25 +149,68 @@ fn copy_content(
|
||||
}
|
||||
}
|
||||
|
||||
/// Perform a full slug transfer (content + registry + quotas + history).
|
||||
/// Perform a full slug transfer (content + v0.8 slug registry + quotas + history).
|
||||
pub fn transfer_slug(
|
||||
state: &AppState,
|
||||
req: &SlugTransferRequest,
|
||||
admin_username: &str,
|
||||
) -> Result<SlugTransferResult, TransferError> {
|
||||
let (old_owner_user_id, target_type, _target_id) = lookup_slug(state, &req.slug)?;
|
||||
let slug_info = lookup_slug(state, &req.slug)?;
|
||||
let target_type = slug_info.target_type.as_str().to_string();
|
||||
|
||||
if old_owner_user_id == req.new_owner_user_id {
|
||||
let old_owner_tenant_id = TenantId::parse(&slug_info.owner_tenant_id).map_err(|_| {
|
||||
TransferError::Internal(format!(
|
||||
"Invalid owner tenant ID '{}' on slug '{}'",
|
||||
slug_info.owner_tenant_id, req.slug
|
||||
))
|
||||
})?;
|
||||
|
||||
// Look up old owner user row in users.db
|
||||
let (old_owner_user_id, new_owner_tenant_id) = {
|
||||
let users_conn = lock_db(&state.users_db, "users_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
let old_user = crate::db::users::get_user_by_tenant_id(&users_conn, old_owner_tenant_id)
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
TransferError::Internal(format!(
|
||||
"Current owner user for tenant {old_owner_tenant_id} not found"
|
||||
))
|
||||
})?;
|
||||
|
||||
let new_user = crate::db::users::get_user_by_id(&users_conn, req.new_owner_user_id)
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?
|
||||
.ok_or_else(|| {
|
||||
TransferError::BadRequest(format!(
|
||||
"Target user ID {} not found",
|
||||
req.new_owner_user_id
|
||||
))
|
||||
})?;
|
||||
|
||||
if new_user.account_type == "admin" {
|
||||
return Err(TransferError::BadRequest(
|
||||
"Target user cannot be an Admin account (must be a tenant account)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let new_tid = new_user.tenant_id.ok_or_else(|| {
|
||||
TransferError::BadRequest("Target user has no TenantId allocated".into())
|
||||
})?;
|
||||
|
||||
(old_user.id, new_tid)
|
||||
};
|
||||
|
||||
if old_owner_tenant_id == new_owner_tenant_id {
|
||||
return Err(TransferError::BadRequest(
|
||||
"New owner must be different from the current owner".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let old_dbs = state
|
||||
.get_user_dbs(old_owner_user_id)
|
||||
.open_tenant(old_owner_tenant_id, TenantOpenMode::CoreJob)
|
||||
.map_err(|_| TransferError::Internal("Failed to load current owner's database".into()))?;
|
||||
let new_dbs = state
|
||||
.get_user_dbs(req.new_owner_user_id)
|
||||
.open_tenant(new_owner_tenant_id, TenantOpenMode::Provision)
|
||||
.map_err(|_| TransferError::Internal("Failed to load new owner's database".into()))?;
|
||||
|
||||
let new_target_id = copy_content(
|
||||
@@ -188,16 +222,29 @@ pub fn transfer_slug(
|
||||
req.new_owner_user_id,
|
||||
)?;
|
||||
|
||||
// Update authoritative v0.8 slug databases
|
||||
{
|
||||
let urls_conn = lock_db(&state.db.global_urls, "global_urls")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let pages_conn = lock_db(&state.db.global_landing_pages, "global_landing_pages")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
|
||||
crate::db::slugs::transfer_slug_owner(
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&req.slug,
|
||||
&new_owner_tenant_id,
|
||||
&new_target_id,
|
||||
)
|
||||
.map_err(|e| TransferError::Internal(format!("Failed to update slug registry: {e}")))?;
|
||||
}
|
||||
|
||||
// Write audit event and history
|
||||
{
|
||||
let system_conn = lock_db(&state.system_db, "system_db")
|
||||
.map_err(|e| TransferError::Internal(e.to_string()))?;
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"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);",
|
||||
@@ -228,8 +275,8 @@ pub fn transfer_slug(
|
||||
"slug",
|
||||
&req.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, req.new_owner_user_id
|
||||
"From tenant {} (user {}) to tenant {} (user {})",
|
||||
old_owner_tenant_id, old_owner_user_id, new_owner_tenant_id, req.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
@@ -237,6 +284,8 @@ pub fn transfer_slug(
|
||||
Ok(SlugTransferResult {
|
||||
old_owner_user_id,
|
||||
new_owner_user_id: req.new_owner_user_id,
|
||||
old_owner_tenant_id,
|
||||
new_owner_tenant_id,
|
||||
target_type,
|
||||
new_target_id,
|
||||
})
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
use crate::analytics::queue::AnalyticsQueue;
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use rusqlite::Connection;
|
||||
use crate::identity::TenantId;
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct UserDbs {
|
||||
pub content: Arc<Mutex<Connection>>,
|
||||
pub analytics: Arc<Mutex<Connection>>,
|
||||
pub profile: Arc<Mutex<Connection>>,
|
||||
}
|
||||
pub use crate::db::tenant::{TenantOpenMode, UserDbs};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub admin_db: Arc<Mutex<Connection>>,
|
||||
pub content_db: Arc<Mutex<Connection>>,
|
||||
pub analytics_db: Arc<Mutex<Connection>>,
|
||||
pub system_db: Arc<Mutex<Connection>>,
|
||||
pub users_db: Arc<Mutex<Connection>>,
|
||||
pub user_dbs: Arc<Mutex<HashMap<i64, UserDbs>>>,
|
||||
pub user_dbs: Arc<Mutex<HashMap<String, UserDbs>>>,
|
||||
pub db: Db,
|
||||
pub config: Config,
|
||||
pub analytics_queue: AnalyticsQueue,
|
||||
@@ -28,71 +22,126 @@ pub struct AppState {
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
/// Compatibility opener: Core `users.id` must refer to a registered,
|
||||
/// non-deleted user. Unknown ids never create a database.
|
||||
pub fn get_user_dbs(&self, user_id: i64) -> Result<UserDbs, crate::error::AppError> {
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
if let Some(dbs) = pool.get(&user_id) {
|
||||
return Ok(dbs.clone());
|
||||
self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
|
||||
}
|
||||
|
||||
// Open connection and run migrations
|
||||
let user_dir = self.config.data_dir.join("users").join(user_id.to_string());
|
||||
std::fs::create_dir_all(&user_dir)?;
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
// Run migrations
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
pub fn get_user_dbs_with_mode(
|
||||
&self,
|
||||
user_id: i64,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
crate::db::tenant::open_for_row_id(
|
||||
&users,
|
||||
&self.db.topology,
|
||||
&self.system_db,
|
||||
&mut pool,
|
||||
user_id,
|
||||
mode,
|
||||
)
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
}
|
||||
|
||||
/// Frozen tenant opener. Unknown TenantId never creates a database.
|
||||
pub fn open_tenant(
|
||||
&self,
|
||||
tenant_id: TenantId,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
let users = crate::utils::lock_db(&self.users_db, "users_db")?;
|
||||
let mut pool = crate::utils::lock_db(&self.user_dbs, "user_dbs")?;
|
||||
crate::db::tenant::open_for_tenant_id(
|
||||
&users,
|
||||
&self.db.topology,
|
||||
&self.system_db,
|
||||
&mut pool,
|
||||
tenant_id,
|
||||
mode,
|
||||
)
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
profile_conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
pub fn lookup_slug(
|
||||
&self,
|
||||
slug: &str,
|
||||
) -> Result<Option<crate::db::slugs::ResolvedSlugInfo>, crate::error::AppError> {
|
||||
let urls_conn = crate::utils::lock_db(&self.db.global_urls, "global_urls")?;
|
||||
let pages_conn =
|
||||
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?;
|
||||
if let Some(info) = crate::db::slugs::lookup_slug(&urls_conn, &pages_conn, slug)? {
|
||||
return Ok(Some(info));
|
||||
}
|
||||
|
||||
let dbs = UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
// Fallback to system.db.global_slugs if table exists (backward compatibility during transition)
|
||||
let system_conn = crate::utils::lock_db(&self.system_db, "system_db")?;
|
||||
let has_table: bool = system_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type='table' AND name='global_slugs');",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
if has_table {
|
||||
let row_opt: Option<(i64, String, String, String)> = system_conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
if let Some((owner_id, target_type, target_id, status)) = row_opt {
|
||||
let tt = match target_type.as_str() {
|
||||
"page" => crate::db::slugs::SlugTargetType::LandingPage,
|
||||
_ => crate::db::slugs::SlugTargetType::Url,
|
||||
};
|
||||
return Ok(Some(crate::db::slugs::ResolvedSlugInfo {
|
||||
slug: slug.to_string(),
|
||||
owner_tenant_id: owner_id.to_string(),
|
||||
target_type: tt,
|
||||
target_id,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
status,
|
||||
retired_at: None,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
pool.insert(user_id, dbs.clone());
|
||||
Ok(dbs)
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn open_slug_owner(
|
||||
&self,
|
||||
owner_tenant_id: &str,
|
||||
mode: TenantOpenMode,
|
||||
) -> Result<UserDbs, crate::error::AppError> {
|
||||
if owner_tenant_id == "legacy_admin" || owner_tenant_id == "1" {
|
||||
return self.get_user_dbs_with_mode(1, mode);
|
||||
}
|
||||
if let Ok(tid) = TenantId::parse(owner_tenant_id) {
|
||||
return self.open_tenant(tid, mode);
|
||||
}
|
||||
if let Ok(uid) = owner_tenant_id.parse::<i64>() {
|
||||
return self.get_user_dbs_with_mode(uid, mode);
|
||||
}
|
||||
Err(crate::error::AppError::NotFound(format!(
|
||||
"invalid owner identity: {}",
|
||||
owner_tenant_id
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn db_compact(&self) -> Result<(), crate::error::AppError> {
|
||||
crate::utils::lock_db(&self.admin_db, "admin_db")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.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;", [])?;
|
||||
crate::utils::lock_db(&self.db.global_urls, "global_urls")?.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.db.global_landing_pages, "global_landing_pages")?
|
||||
.execute("VACUUM;", [])?;
|
||||
crate::utils::lock_db(&self.db.reserved, "reserved")?.execute("VACUUM;", [])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,17 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
pub struct AdminPageRow {
|
||||
pub page: LandingPage,
|
||||
pub owner_tenant_id: String,
|
||||
pub owner_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "pages.html")]
|
||||
pub struct PagesTemplate {
|
||||
pub admin_username: String,
|
||||
pub pages: Vec<LandingPage>,
|
||||
pub pages: Vec<AdminPageRow>,
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub current_page: usize,
|
||||
|
||||
@@ -5,11 +5,17 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
|
||||
pub struct AdminUrlRow {
|
||||
pub url: Url,
|
||||
pub owner_tenant_id: String,
|
||||
pub owner_username: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "urls.html")]
|
||||
pub struct UrlsTemplate {
|
||||
pub admin_username: String,
|
||||
pub urls: Vec<Url>,
|
||||
pub urls: Vec<AdminUrlRow>,
|
||||
pub csrf_token: String,
|
||||
pub error: Option<String>,
|
||||
pub tag_filter: Option<String>,
|
||||
|
||||
@@ -22,6 +22,9 @@ pub fn get_db_file_info(data_dir: &Path) -> String {
|
||||
("admin/admin.db", "Admin DB"),
|
||||
("admin/system.db", "System DB"),
|
||||
("admin/users.db", "Users DB"),
|
||||
("slugs/global_urls.db", "Global URLs DB"),
|
||||
("slugs/global_landing_pages.db", "Global Landing Pages DB"),
|
||||
("slugs/reserved.db", "Reserved Slugs DB"),
|
||||
("users/1/content.db", "Legacy Content DB"),
|
||||
("users/1/analytics.db", "Legacy Analytics DB"),
|
||||
];
|
||||
|
||||
@@ -12,8 +12,36 @@ pub async fn url_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &id) {
|
||||
Ok(Some(u)) => u,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
@@ -21,7 +49,7 @@ pub async fn url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -184,8 +212,36 @@ pub async fn page_analytics_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let page = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &id) {
|
||||
Ok(Some(p)) => p,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Landing page not found").into_response(),
|
||||
@@ -193,7 +249,7 @@ pub async fn page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
|
||||
let schema_cols = get_visits_schema_columns(&conn).unwrap_or_default();
|
||||
let has_utm_source = schema_cols.contains("utm_source");
|
||||
@@ -439,6 +495,7 @@ pub async fn user_analytics_get(
|
||||
accept_language: row.get(7)?,
|
||||
country: row.get(8)?,
|
||||
status_code: row.get(9)?,
|
||||
owner_tenant_id: user.tenant_id,
|
||||
owner_user_id: Some(user.id),
|
||||
})
|
||||
})
|
||||
@@ -478,12 +535,12 @@ pub async fn user_url_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -493,7 +550,12 @@ pub async fn user_url_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -674,12 +736,12 @@ pub async fn user_page_analytics_get(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -689,7 +751,12 @@ pub async fn user_page_analytics_get(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -862,12 +929,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -877,7 +944,12 @@ pub async fn user_url_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -901,12 +973,12 @@ pub async fn user_url_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -916,7 +988,12 @@ pub async fn user_url_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "url" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -940,12 +1017,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -955,7 +1032,12 @@ pub async fn user_page_analytics_csv_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
@@ -979,12 +1061,12 @@ pub async fn user_page_analytics_json_export(
|
||||
};
|
||||
|
||||
// Ownership check
|
||||
let (owner_user_id, target_type) = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let owner_tid_str = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
match conn.query_row(
|
||||
"SELECT owner_user_id, target_type FROM global_slugs WHERE target_id = ?1",
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE target_id = ?1",
|
||||
[&id],
|
||||
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
|
||||
|row| row.get::<_, String>(0),
|
||||
) {
|
||||
Ok(val) => val,
|
||||
Err(rusqlite::Error::QueryReturnedNoRows) => {
|
||||
@@ -994,7 +1076,12 @@ pub async fn user_page_analytics_json_export(
|
||||
}
|
||||
};
|
||||
|
||||
if owner_user_id != user.id || target_type != "page" {
|
||||
let user_tid = match user.tenant_id {
|
||||
Some(t) => t.to_string(),
|
||||
None => return StatusCode::FORBIDDEN.into_response(),
|
||||
};
|
||||
|
||||
if owner_tid_str != user_tid && owner_tid_str != format!("{}", user.id) {
|
||||
return StatusCode::FORBIDDEN.into_response();
|
||||
}
|
||||
|
||||
|
||||
@@ -96,25 +96,7 @@ 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()
|
||||
crate::db::users::get_user_by_username(conn, username)
|
||||
}
|
||||
|
||||
fn tenant_user_to_admin_user(u: crate::models::TenantUser) -> User {
|
||||
@@ -134,6 +116,31 @@ fn audit_meta(ip: &str, headers: &HeaderMap) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_admin_cookie(jar: CookieJar) -> CookieJar {
|
||||
let cookie = Cookie::build("bzod_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
jar.add(cookie)
|
||||
}
|
||||
|
||||
pub(crate) fn clear_user_cookie(jar: CookieJar) -> CookieJar {
|
||||
let cookie = Cookie::build("bzod_user_session")
|
||||
.path("/")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
jar.add(cookie)
|
||||
}
|
||||
|
||||
pub(crate) fn invalidate_session_in_db(state: &AppState, session_id: &str) {
|
||||
if session_id.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Ok(conn) = state.users_db.lock() {
|
||||
let _ = conn.execute("DELETE FROM sessions WHERE id = ?1;", [session_id]);
|
||||
}
|
||||
}
|
||||
|
||||
// POST /admin/login
|
||||
pub async fn login_post(
|
||||
State(state): State<AppState>,
|
||||
@@ -180,21 +187,18 @@ pub async fn login_post(
|
||||
}
|
||||
};
|
||||
|
||||
let created_user_res = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
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
|
||||
);
|
||||
}
|
||||
crate::db::users::create_admin_user(&conn, &form.username, &hash)
|
||||
};
|
||||
|
||||
match created_user_res {
|
||||
Ok(u) => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
@@ -254,6 +258,14 @@ pub async fn login_post(
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
// Invalidate any existing sessions from the jar
|
||||
if let Some(old_admin_cookie) = jar.get("bzod_session") {
|
||||
invalidate_session_in_db(&state, old_admin_cookie.value());
|
||||
}
|
||||
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
|
||||
invalidate_session_in_db(&state, old_user_cookie.value());
|
||||
}
|
||||
|
||||
{
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
@@ -300,6 +312,7 @@ pub async fn login_post(
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = clear_user_cookie(response_jar);
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
@@ -322,14 +335,17 @@ pub async fn login_post(
|
||||
}
|
||||
|
||||
// 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();
|
||||
pub async fn public_logout(State(state): State<AppState>, jar: CookieJar) -> Response {
|
||||
if let Some(user_cookie) = jar.get("bzod_user_session") {
|
||||
invalidate_session_in_db(&state, user_cookie.value());
|
||||
}
|
||||
if let Some(admin_cookie) = jar.get("bzod_session") {
|
||||
invalidate_session_in_db(&state, admin_cookie.value());
|
||||
}
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie);
|
||||
response_jar = clear_user_cookie(response_jar);
|
||||
response_jar = clear_admin_cookie(response_jar);
|
||||
|
||||
(response_jar, Redirect::to("/login")).into_response()
|
||||
}
|
||||
@@ -385,26 +401,7 @@ pub async fn public_login_post(
|
||||
|
||||
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 {
|
||||
match crate::db::users::get_user_by_username(&conn, &form.username) {
|
||||
Ok(Some(u)) => {
|
||||
if u.status != "active" {
|
||||
None
|
||||
@@ -423,6 +420,14 @@ pub async fn public_login_post(
|
||||
let session_token = generate_token(32);
|
||||
let expires = (Utc::now() + chrono::Duration::days(30)).to_rfc3339();
|
||||
|
||||
// Invalidate any existing sessions from the jar
|
||||
if let Some(old_admin_cookie) = jar.get("bzod_session") {
|
||||
invalidate_session_in_db(&state, old_admin_cookie.value());
|
||||
}
|
||||
if let Some(old_user_cookie) = jar.get("bzod_user_session") {
|
||||
invalidate_session_in_db(&state, old_user_cookie.value());
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
let _ =
|
||||
@@ -446,6 +451,32 @@ pub async fn public_login_post(
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
|
||||
if user.account_type == "admin" {
|
||||
let cookie = Cookie::build(("bzod_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = clear_user_cookie(response_jar);
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
} else {
|
||||
if user.tenant_id.is_none() {
|
||||
return Redirect::to("/login?error=Invalid tenant configuration")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
@@ -460,10 +491,12 @@ pub async fn public_login_post(
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = clear_admin_cookie(response_jar);
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let metadata = format!(
|
||||
@@ -486,18 +519,16 @@ pub async fn public_login_post(
|
||||
|
||||
// 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]);
|
||||
if let Some(admin_cookie) = jar.get("bzod_session") {
|
||||
invalidate_session_in_db(&state, admin_cookie.value());
|
||||
}
|
||||
if let Some(user_cookie) = jar.get("bzod_user_session") {
|
||||
invalidate_session_in_db(&state, user_cookie.value());
|
||||
}
|
||||
|
||||
let 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 = clear_admin_cookie(response_jar);
|
||||
response_jar = clear_user_cookie(response_jar);
|
||||
|
||||
(response_jar, Redirect::to("/admin/login")).into_response()
|
||||
}
|
||||
|
||||
@@ -95,25 +95,44 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
};
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
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 users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
|
||||
let mut trend_map = std::collections::BTreeMap::new();
|
||||
@@ -123,35 +142,12 @@ pub async fn dashboard_get(State(state): State<AppState>, jar: CookieJar) -> Res
|
||||
.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 countries_chart = generate_bar_chart(&[]);
|
||||
let referrers_chart = generate_bar_chart(&[]);
|
||||
let browsers_chart = generate_bar_chart(&[]);
|
||||
|
||||
let template = crate::templates::DashboardTemplate {
|
||||
admin_username: user.username,
|
||||
|
||||
@@ -49,9 +49,27 @@ pub async fn health_get(
|
||||
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");
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_urls.lock().unwrap(),
|
||||
"global_urls",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) = crate::db::sqlite::collect_health_report(
|
||||
&state.db.global_landing_pages.lock().unwrap(),
|
||||
"global_landing_pages",
|
||||
) {
|
||||
db_reports.push(r);
|
||||
}
|
||||
if let Ok(r) =
|
||||
crate::db::sqlite::collect_health_report(&state.db.reserved.lock().unwrap(), "reserved")
|
||||
{
|
||||
db_reports.push(r);
|
||||
}
|
||||
|
||||
let system_db_path = state.db.topology.system_db();
|
||||
let users_db_path = state.db.topology.users_registry_db();
|
||||
let admin_db_path = state.db.topology.admin_db();
|
||||
|
||||
let system_db_size = format_size(
|
||||
std::fs::metadata(&system_db_path)
|
||||
@@ -69,7 +87,7 @@ pub async fn health_get(
|
||||
.unwrap_or(0),
|
||||
);
|
||||
|
||||
let users_dir = state.config.data_dir.join("users");
|
||||
let users_dir = state.db.topology.users_dir();
|
||||
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));
|
||||
|
||||
@@ -127,8 +145,7 @@ pub async fn health_get(
|
||||
for issue in issues {
|
||||
use crate::services::registry_validator::RegistryIssueType;
|
||||
match issue.issue_type {
|
||||
RegistryIssueType::StaleReservation
|
||||
| RegistryIssueType::TenantAdminHasIsolatedContent => {
|
||||
RegistryIssueType::StaleReservation => {
|
||||
warnings.push(format!(
|
||||
"Warning for slug {}: {}",
|
||||
issue.slug, issue.description
|
||||
|
||||
@@ -86,32 +86,82 @@ pub(crate) fn write_audit_log(
|
||||
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
|
||||
// Helper: Verify admin session and return user or error response (403 Forbidden / login redirect)
|
||||
pub(crate) async fn require_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(User, String), Redirect> {
|
||||
) -> Result<(User, String), Box<Response>> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/admin/login")),
|
||||
Err(_) => return Err(Box::new(Redirect::to("/admin/login").into_response())),
|
||||
};
|
||||
|
||||
match authenticate_admin_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/admin/login")),
|
||||
Ok(None) => {
|
||||
// Check if user is logged in as a normal tenant user trying to access admin route
|
||||
if let Ok(Some((tenant_user, _))) = authenticate_user_session(&conn, jar) {
|
||||
if tenant_user.account_type != "admin" {
|
||||
return Err(Box::new(
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden: Standard users cannot access Admin routes",
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Helper: Verify tenant user session and return user or redirect to login
|
||||
Err(Box::new(Redirect::to("/admin/login").into_response()))
|
||||
}
|
||||
Err(_) => Err(Box::new(Redirect::to("/admin/login").into_response())),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper: Verify tenant user session and return tenant user or error response (403 Forbidden / login redirect)
|
||||
pub(crate) async fn require_user_auth(
|
||||
state: &AppState,
|
||||
jar: &CookieJar,
|
||||
) -> Result<(crate::models::TenantUser, String), Redirect> {
|
||||
) -> Result<(crate::models::TenantUser, String), Box<Response>> {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => return Err(Redirect::to("/login")),
|
||||
Err(_) => return Err(Box::new(Redirect::to("/login").into_response())),
|
||||
};
|
||||
|
||||
// If an Admin session is present, Core Admin is trying to access /user/* routes -> Reject with 403 Forbidden
|
||||
if let Ok(Some((_admin_user, _))) = authenticate_admin_session(&conn, jar) {
|
||||
return Err(Box::new(
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
|
||||
// Now check tenant user session
|
||||
match authenticate_user_session(&conn, jar) {
|
||||
Ok(Some((user, session_id))) => Ok((user, session_id)),
|
||||
_ => Err(Redirect::to("/login")),
|
||||
Ok(Some((user, session_id))) => {
|
||||
if user.account_type == "admin" {
|
||||
return Err(Box::new(
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden: Core Admin cannot access tenant application routes; use /admin/...",
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
if user.tenant_id.is_none() {
|
||||
return Err(Box::new(
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Forbidden: User has no assigned TenantId",
|
||||
)
|
||||
.into_response(),
|
||||
));
|
||||
}
|
||||
Ok((user, session_id))
|
||||
}
|
||||
_ => Err(Box::new(Redirect::to("/login").into_response())),
|
||||
}
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
@@ -186,8 +236,48 @@ pub(crate) async fn perform_csv_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -203,7 +293,7 @@ pub(crate) async fn perform_csv_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -215,14 +305,14 @@ pub(crate) async fn perform_csv_export(
|
||||
};
|
||||
|
||||
let (has_utm_source, has_utm_campaign) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
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 = state.analytics_db.clone();
|
||||
let analytics_db = user_dbs.analytics.clone();
|
||||
let target_id = id.clone();
|
||||
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -378,8 +468,48 @@ pub(crate) async fn perform_json_export(
|
||||
Err(status) => return status.into_response(),
|
||||
};
|
||||
|
||||
let (_slug, owner_tid) = {
|
||||
let conn = if target_type == "url" {
|
||||
state.db.global_urls.lock().unwrap()
|
||||
} else {
|
||||
state.db.global_landing_pages.lock().unwrap()
|
||||
};
|
||||
let table = if target_type == "url" {
|
||||
"global_urls"
|
||||
} else {
|
||||
"global_landing_pages"
|
||||
};
|
||||
match conn.query_row(
|
||||
&format!(
|
||||
"SELECT slug, owner_tenant_id FROM {} WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
table
|
||||
),
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
) {
|
||||
Ok((s, t)) => (s, t),
|
||||
Err(_) => return (StatusCode::NOT_FOUND, "Target not found").into_response(),
|
||||
}
|
||||
};
|
||||
let tid = match owner_tid.parse::<crate::identity::TenantId>() {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Invalid tenant identity").into_response()
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
Ok(d) => d,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to open tenant database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let target_exists = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
if target_type == "url" {
|
||||
get_url_by_id(&conn, &id)
|
||||
.map(|u| u.is_some())
|
||||
@@ -395,7 +525,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let count = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
get_target_visit_total_filtered(
|
||||
&conn,
|
||||
target_type,
|
||||
@@ -411,7 +541,7 @@ pub(crate) async fn perform_json_export(
|
||||
}
|
||||
|
||||
let visits_raw = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
match get_target_visits_all_in_memory(
|
||||
&conn,
|
||||
target_type,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use crate::identity::TenantId;
|
||||
|
||||
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
|
||||
pub struct GlobalSlugRow {
|
||||
@@ -48,28 +49,83 @@ pub async fn moderation_get(
|
||||
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| {
|
||||
let mut flagged_items: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = urls_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_urls WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
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)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let query_res = pages_conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at \
|
||||
FROM global_landing_pages WHERE status = 'flagged' OR status = 'disabled' ORDER BY updated_at DESC;"
|
||||
).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
if let Ok(items) = query_res {
|
||||
flagged_items.extend(items);
|
||||
}
|
||||
}
|
||||
|
||||
flagged_items.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
|
||||
|
||||
let logs = {
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
@@ -139,46 +195,91 @@ pub async fn moderation_post(
|
||||
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);
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/moderation?error=Slug not found").into_response(),
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/moderation?error=Invalid tenant on slug").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let owner_username = {
|
||||
let (owner_user_id, 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)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
// 1. Update v0.8 slug database
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
);
|
||||
}
|
||||
} else {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Sync to tenant content DB if accessible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" || action == "deleted" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, form.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" || action == "deleted" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, form.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Record moderation event and audit log in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
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)
|
||||
"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,
|
||||
@@ -186,7 +287,7 @@ pub async fn moderation_post(
|
||||
user.username,
|
||||
owner_user_id,
|
||||
owner_username,
|
||||
target_type,
|
||||
slug_info.target_type.as_str(),
|
||||
form.slug,
|
||||
action,
|
||||
form.severity,
|
||||
@@ -231,20 +332,21 @@ pub async fn slugs_get(
|
||||
Err(redir) => return redir.into_response(),
|
||||
};
|
||||
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut slugs: Vec<GlobalSlugRow> = Vec::new();
|
||||
|
||||
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();
|
||||
// Query global_urls.db
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_urls 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 = ?");
|
||||
@@ -253,26 +355,107 @@ pub async fn slugs_get(
|
||||
}
|
||||
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| {
|
||||
let items_res = urls_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
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)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "url".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})
|
||||
.unwrap();
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Query global_landing_pages.db
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let mut sql = "SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at FROM global_landing_pages 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(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 items_res = pages_conn.prepare(&sql).and_then(|mut stmt| {
|
||||
let rows = stmt.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
let tid_str: String = row.get(1)?;
|
||||
let uid = if let Ok(tid) = TenantId::parse(&tid_str) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
.unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
Ok(GlobalSlugRow {
|
||||
slug: row.get(0)?,
|
||||
owner_user_id: uid,
|
||||
target_type: "page".to_string(),
|
||||
target_id: row.get(2)?,
|
||||
created_at: row.get(3)?,
|
||||
updated_at: row.get(4)?,
|
||||
status: row.get(5)?,
|
||||
deleted_at: row.get(6)?,
|
||||
})
|
||||
})?;
|
||||
Ok(rows.filter_map(|r| r.ok()).collect::<Vec<_>>())
|
||||
});
|
||||
|
||||
if let Ok(items) = items_res {
|
||||
for r in items {
|
||||
if let Some(o) = query.owner {
|
||||
if r.owner_user_id != o {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
slugs.push(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slugs.sort_by(|a, b| b.created_at.cmp(&a.created_at));
|
||||
|
||||
let history = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
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;"
|
||||
@@ -332,198 +515,19 @@ pub async fn slugs_transfer_post(
|
||||
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)
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: form.slug.clone(),
|
||||
new_owner_user_id: form.new_owner_user_id,
|
||||
};
|
||||
|
||||
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!(
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &user.username) {
|
||||
Ok(_) => Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
.into_response(),
|
||||
Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -553,17 +557,30 @@ pub async fn slugs_status_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid status").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![status, now, form.slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_STATUS_UPDATE",
|
||||
"slug",
|
||||
@@ -600,29 +617,46 @@ pub async fn slugs_delete_post(
|
||||
return Redirect::to("/admin/slugs?error=Invalid CSRF token").into_response();
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let slug_info = match state.lookup_slug(&form.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
_ => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
};
|
||||
|
||||
let old_owner_user_id = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
if let Ok(tid) = TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
crate::db::users::get_user_by_tenant_id(&users_conn, tid)
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|u| u.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
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;",
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&form.slug]);
|
||||
} else {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages 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(
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![form.slug, old_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&system_conn,
|
||||
&user.username,
|
||||
"SLUG_RELEASE",
|
||||
"slug",
|
||||
|
||||
@@ -118,21 +118,26 @@ pub async fn user_pages_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = match user.tenant_id {
|
||||
Some(tid) => tid,
|
||||
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").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,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/pages?error=Failed to reserve slug: {}", e))
|
||||
return Redirect::to(&format!(
|
||||
"/user/pages?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -152,16 +157,8 @@ pub async fn user_pages_create(
|
||||
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 pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -181,8 +178,8 @@ pub async fn user_pages_create(
|
||||
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);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/pages?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -221,11 +218,13 @@ pub async fn user_pages_delete(
|
||||
);
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&page.code,
|
||||
user.id,
|
||||
{
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&page.code],
|
||||
);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -265,8 +264,14 @@ pub async fn pages_get(
|
||||
};
|
||||
|
||||
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 conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.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);
|
||||
@@ -278,7 +283,73 @@ pub async fn pages_get(
|
||||
.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 mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_landing_pages WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut pages = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_page = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(p)) =
|
||||
crate::db::content::get_landing_page_by_id(&u_conn, &target_id)
|
||||
{
|
||||
resolved_page = Some(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
let page = if let Some(p) = resolved_page {
|
||||
p
|
||||
} else {
|
||||
crate::models::LandingPage {
|
||||
id: target_id,
|
||||
code: slug.clone(),
|
||||
slug,
|
||||
title: String::new(),
|
||||
html_content: String::new(),
|
||||
state: status,
|
||||
created_at,
|
||||
updated_at,
|
||||
}
|
||||
};
|
||||
let owner_username = {
|
||||
let u_conn = state.users_db.lock().unwrap();
|
||||
u_conn
|
||||
.query_row(
|
||||
"SELECT username FROM users WHERE tenant_id = ?1;",
|
||||
[&owner_tid_str],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
};
|
||||
pages.push(crate::templates::pages::AdminPageRow {
|
||||
page,
|
||||
owner_tenant_id: owner_tid_str,
|
||||
owner_username,
|
||||
});
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -302,7 +373,8 @@ pub async fn pages_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreatePageForm {
|
||||
pub title: String,
|
||||
pub slug: String,
|
||||
@@ -317,126 +389,20 @@ pub struct CreatePageForm {
|
||||
pub async fn pages_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreatePageForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreatePageForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
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",
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account",
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
@@ -460,9 +426,30 @@ pub async fn pages_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_landing_page(&conn, &id) {
|
||||
Ok(_) => {
|
||||
// Look up owner tenant in global_landing_pages
|
||||
let owner_info = {
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_landing_pages WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_landing_page(&conn, &id);
|
||||
}
|
||||
}
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_landing_pages SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
@@ -478,7 +465,3 @@ pub async fn pages_delete(
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
@@ -106,11 +106,13 @@ pub async fn user_download_backup(
|
||||
};
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let mut buffer = Vec::new();
|
||||
let res = {
|
||||
@@ -224,11 +226,15 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(user.id.to_string());
|
||||
let user_dir = match crate::db::tenant::location_for_user(&user).and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
}) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
return Redirect::to("/user/settings?error=Invalid user directory").into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let temp_unpack_dir =
|
||||
std::env::temp_dir().join(format!("bzod_restore_unpack_{}", uuid::Uuid::new_v4()));
|
||||
@@ -241,42 +247,122 @@ pub async fn user_restore_backup_post(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let restore_res = {
|
||||
let file = match File::open(&temp_file_path) {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
let _ = std::fs::remove_dir_all(&temp_unpack_dir);
|
||||
return Redirect::to(&format!(
|
||||
"/user/settings?error=Failed to open upload: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let restore_res: Result<(), Box<dyn std::error::Error>> = (|| {
|
||||
let file = File::open(&temp_file_path)?;
|
||||
let tar_gz = GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(tar_gz);
|
||||
if let Err(e) = archive.unpack(&temp_unpack_dir) {
|
||||
Err(Box::new(e) as Box<dyn std::error::Error>)
|
||||
} else {
|
||||
// Check for collision using temp content.db
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
archive.unpack(&temp_unpack_dir)?;
|
||||
|
||||
let target_tenant_id = user.tenant_id.ok_or("User is missing assigned TenantId")?;
|
||||
let temp_content_db = temp_unpack_dir.join("content.db");
|
||||
if temp_content_db.exists() {
|
||||
if let Err(e) = crate::db::users::register_restored_user_slugs(
|
||||
&system_conn,
|
||||
user.id,
|
||||
&temp_content_db,
|
||||
) {
|
||||
Err(e)
|
||||
if !temp_content_db.exists() {
|
||||
return Err("Backup is missing content.db".into());
|
||||
}
|
||||
|
||||
let content_conn = rusqlite::Connection::open(&temp_content_db)?;
|
||||
|
||||
let mut urls = Vec::new();
|
||||
if let Ok(mut stmt) = content_conn.prepare("SELECT code, id, created_at, status FROM urls;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
urls = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let mut pages = Vec::new();
|
||||
if let Ok(mut stmt) =
|
||||
content_conn.prepare("SELECT code, id, created_at, state FROM landing_pages;")
|
||||
{
|
||||
if let Ok(rows) = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
))
|
||||
}) {
|
||||
pages = rows.filter_map(|r| r.ok()).collect();
|
||||
}
|
||||
}
|
||||
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
|
||||
for (slug, _, _, _) in &urls {
|
||||
if let Ok(Some(existing_owner)) = urls_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_urls WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on URL /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (slug, _, _, _) in &pages {
|
||||
if let Ok(Some(existing_owner)) = pages_conn
|
||||
.query_row(
|
||||
"SELECT owner_tenant_id FROM global_landing_pages WHERE slug = ?1;",
|
||||
[slug],
|
||||
|r| r.get::<_, String>(0),
|
||||
)
|
||||
.optional()
|
||||
{
|
||||
if existing_owner != target_tenant_id.as_str() {
|
||||
return Err(format!("Slug collision on Landing Page /{}", slug).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = urls_conn.execute(
|
||||
"DELETE FROM global_urls WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
let _ = pages_conn.execute(
|
||||
"DELETE FROM global_landing_pages WHERE owner_tenant_id = ?1;",
|
||||
[target_tenant_id.as_str()],
|
||||
);
|
||||
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
for (slug, target_id, created_at, status) in urls {
|
||||
let global_status = if status == "dead" {
|
||||
"disabled"
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
Err("Backup is missing content.db".into())
|
||||
}
|
||||
}
|
||||
"active"
|
||||
};
|
||||
let _ = urls_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_urls (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
for (slug, target_id, created_at, state) in pages {
|
||||
let global_status = if state == "published" {
|
||||
"active"
|
||||
} else {
|
||||
"disabled"
|
||||
};
|
||||
let _ = pages_conn.execute(
|
||||
"INSERT OR REPLACE INTO global_landing_pages (slug, owner_tenant_id, target_id, created_at, updated_at, status, retired_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL);",
|
||||
rusqlite::params![slug, target_tenant_id.as_str(), target_id, created_at, now, global_status],
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})();
|
||||
|
||||
let _ = std::fs::remove_file(&temp_file_path);
|
||||
|
||||
@@ -316,7 +402,9 @@ pub async fn user_restore_backup_post(
|
||||
}
|
||||
|
||||
let mut pool = state.user_dbs.lock().unwrap();
|
||||
pool.remove(&user.id);
|
||||
if let Ok(loc) = crate::db::tenant::location_for_user(&user) {
|
||||
pool.remove(&loc.cache_key());
|
||||
}
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
&state,
|
||||
@@ -678,8 +766,43 @@ pub async fn bulk_qr_export_post(
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 500, 0, None).unwrap_or_default()
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let mut stmt = match conn
|
||||
.prepare("SELECT slug, target_id FROM global_urls WHERE status = 'active' LIMIT 500;")
|
||||
{
|
||||
Ok(s) => s,
|
||||
Err(_) => return Redirect::to("/admin/settings?error=Database error").into_response(),
|
||||
};
|
||||
let rows = stmt.query_map([], |row| {
|
||||
let code: String = row.get(0)?;
|
||||
let target_id: String = row.get(1)?;
|
||||
Ok(crate::models::Url {
|
||||
id: target_id,
|
||||
code: code.clone(),
|
||||
destination: format!(
|
||||
"{}/{}",
|
||||
state.config.base_url.clone().unwrap_or_default(),
|
||||
code
|
||||
),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at: String::new(),
|
||||
updated_at: String::new(),
|
||||
status: "active".to_string(),
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
})
|
||||
});
|
||||
match rows {
|
||||
Ok(r) => r.filter_map(|x| x.ok()).collect(),
|
||||
Err(_) => vec![],
|
||||
}
|
||||
};
|
||||
|
||||
if urls.is_empty() {
|
||||
@@ -761,10 +884,7 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
let uptime_duration = state.start_time.elapsed();
|
||||
let uptime = crate::utils::format_duration(uptime_duration);
|
||||
|
||||
let urls = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::list_urls(&conn, 50, 0, None).unwrap_or_default()
|
||||
};
|
||||
let urls = vec![];
|
||||
|
||||
let template = crate::templates::StatusTemplate {
|
||||
admin_username: user.username,
|
||||
@@ -911,93 +1031,72 @@ pub async fn restore_backup_post(
|
||||
let restore_res = {
|
||||
// Temporarily suspend access to active SQLite connections
|
||||
let mut admin_conn = state.admin_db.lock().unwrap();
|
||||
let mut content_conn = state.content_db.lock().unwrap();
|
||||
let mut analytics_conn = state.analytics_db.lock().unwrap();
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let mut users_conn = state.users_db.lock().unwrap();
|
||||
let mut urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let mut pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let mut reserved_conn = state.db.reserved.lock().unwrap();
|
||||
|
||||
// 1. Close current connections by replacing them with dummy in-memory DBs
|
||||
*admin_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*content_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*analytics_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*system_conn = match rusqlite::Connection::open_in_memory() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/settings?error=Failed to open temp in-memory DB: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
*admin_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*system_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*users_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*urls_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*pages_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
*reserved_conn = rusqlite::Connection::open_in_memory()
|
||||
.unwrap_or_else(|_| rusqlite::Connection::open(":memory:").unwrap());
|
||||
|
||||
// 2. Perform restore unpacking/validation
|
||||
// perform_restore() handles legacy layout normalization internally
|
||||
// before validation, so no post-restore normalization is needed.
|
||||
// 2. Clear tenant pool cache
|
||||
if let Ok(mut pool) = state.user_dbs.lock() {
|
||||
pool.clear();
|
||||
}
|
||||
|
||||
// 3. Perform restore unpacking/validation
|
||||
let res = crate::cli::restore::perform_restore(&temp_file_path, &state.config.data_dir);
|
||||
|
||||
// 3. Reinitialize database connections using correct multi-tenant paths
|
||||
let paths =
|
||||
crate::services::backup_layout::RestoredDbPaths::from_data_dir(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(&paths.admin);
|
||||
let new_system = rusqlite::Connection::open(&paths.system);
|
||||
let new_users = rusqlite::Connection::open(&paths.users);
|
||||
let new_content = rusqlite::Connection::open(&paths.content);
|
||||
let new_analytics = rusqlite::Connection::open(&paths.analytics);
|
||||
// 4. Reinitialize Core database connections
|
||||
let topology = crate::db::topology::Topology::new(&state.config.data_dir);
|
||||
let new_admin = rusqlite::Connection::open(topology.admin_db());
|
||||
let new_system = rusqlite::Connection::open(topology.system_db());
|
||||
let new_users = rusqlite::Connection::open(topology.users_registry_db());
|
||||
let new_urls = rusqlite::Connection::open(topology.global_urls_db());
|
||||
let new_pages = rusqlite::Connection::open(topology.global_landing_pages_db());
|
||||
let new_reserved = rusqlite::Connection::open(topology.reserved_db());
|
||||
|
||||
match (new_admin, new_content, new_analytics, new_system, new_users) {
|
||||
(Ok(adm), Ok(cnt), Ok(any), Ok(sys), Ok(usr)) => {
|
||||
match (
|
||||
new_admin,
|
||||
new_system,
|
||||
new_users,
|
||||
new_urls,
|
||||
new_pages,
|
||||
new_reserved,
|
||||
) {
|
||||
(Ok(adm), Ok(sys), Ok(usr), Ok(urls), Ok(pages), Ok(resv)) => {
|
||||
let _ = crate::db::sqlite::enable_wal(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_wal(&cnt, "content");
|
||||
let _ = crate::db::sqlite::enable_wal(&any, "analytics");
|
||||
let _ = crate::db::sqlite::enable_wal(&sys, "system");
|
||||
let _ = crate::db::sqlite::enable_wal(&usr, "users");
|
||||
let _ = crate::db::sqlite::enable_wal(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_wal(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_wal(&resv, "reserved");
|
||||
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&adm, "admin");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&cnt, "content");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&any, "analytics");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&sys, "system");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&usr, "users");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&urls, "global_urls");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&pages, "global_landing_pages");
|
||||
let _ = crate::db::sqlite::enable_foreign_keys(&resv, "reserved");
|
||||
|
||||
*admin_conn = adm;
|
||||
*content_conn = cnt;
|
||||
*analytics_conn = any;
|
||||
*system_conn = sys;
|
||||
match state.db.users.lock() {
|
||||
Ok(mut u) => *u = usr,
|
||||
Err(_) => {
|
||||
return Redirect::to(
|
||||
"/admin/settings?error=Failed to reopen restored databases",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
*users_conn = usr;
|
||||
*urls_conn = urls;
|
||||
*pages_conn = pages;
|
||||
*reserved_conn = resv;
|
||||
}
|
||||
_ => {
|
||||
return Redirect::to("/admin/settings?error=Failed to reopen restored databases")
|
||||
|
||||
@@ -153,20 +153,26 @@ pub async fn user_urls_create(
|
||||
}
|
||||
}
|
||||
|
||||
let owner_tid = match user.tenant_id {
|
||||
Some(tid) => tid,
|
||||
None => return (StatusCode::FORBIDDEN, "Missing tenant identity").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,
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
user.id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
&owner_tid,
|
||||
) {
|
||||
return Redirect::to(&format!("/user/urls?error=Failed to reserve slug: {}", e))
|
||||
return Redirect::to(&format!(
|
||||
"/user/urls?error=Short code/slug unavailable: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
@@ -181,6 +187,8 @@ pub async fn user_urls_create(
|
||||
) {
|
||||
Ok(d) => d,
|
||||
Err(msg) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to(&format!("/user/urls?error={}", msg)).into_response();
|
||||
}
|
||||
};
|
||||
@@ -192,7 +200,11 @@ pub async fn user_urls_create(
|
||||
} else {
|
||||
match hash_password(&form.password) {
|
||||
Ok(h) => Some(h),
|
||||
Err(_) => return Redirect::to("/user/urls?error=Hashing error").into_response(),
|
||||
Err(_) => {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Hashing error").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -202,7 +214,9 @@ pub async fn user_urls_create(
|
||||
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 urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
return Redirect::to("/user/urls?error=Invalid max access count").into_response();
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -243,11 +257,8 @@ pub async fn user_urls_create(
|
||||
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 urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
{
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
@@ -266,8 +277,8 @@ pub async fn user_urls_create(
|
||||
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);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &owner_tid);
|
||||
Redirect::to(&format!("/user/urls?error=Database error: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
@@ -306,11 +317,11 @@ pub async fn user_urls_delete(
|
||||
);
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
let _ = crate::db::users::release_global_slug(
|
||||
&state.system_db.lock().unwrap(),
|
||||
&url.code,
|
||||
user.id,
|
||||
);
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&url.code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let _ = write_audit_log(
|
||||
&state.admin_db.lock().unwrap(),
|
||||
@@ -351,12 +362,14 @@ pub async fn urls_get(
|
||||
};
|
||||
|
||||
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 conn = state.db.global_urls.lock().unwrap();
|
||||
let total_records: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.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);
|
||||
@@ -368,8 +381,79 @@ pub async fn urls_get(
|
||||
.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 mut stmt = conn.prepare(
|
||||
"SELECT slug, owner_tenant_id, target_id, created_at, updated_at, status FROM global_urls WHERE status != 'retired' ORDER BY created_at DESC LIMIT ?1 OFFSET ?2;"
|
||||
).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params![PAGE_SIZE as i64, offset as i64], |row| {
|
||||
let slug: String = row.get(0)?;
|
||||
let owner_tid_str: String = row.get(1)?;
|
||||
let target_id: String = row.get(2)?;
|
||||
let created_at: String = row.get(3)?;
|
||||
let updated_at: String = row.get(4)?;
|
||||
let status: String = row.get(5)?;
|
||||
Ok((
|
||||
slug,
|
||||
owner_tid_str,
|
||||
target_id,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
))
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let mut urls = Vec::new();
|
||||
for item in rows.flatten() {
|
||||
let (slug, owner_tid_str, target_id, created_at, updated_at, status) = item;
|
||||
let mut resolved_url = None;
|
||||
if let Ok(tid) = owner_tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob)
|
||||
{
|
||||
let u_conn = user_dbs.content.lock().unwrap();
|
||||
if let Ok(Some(u)) = crate::db::content::get_url_by_id(&u_conn, &target_id) {
|
||||
resolved_url = Some(u);
|
||||
}
|
||||
}
|
||||
}
|
||||
let url = if let Some(u) = resolved_url {
|
||||
u
|
||||
} else {
|
||||
crate::models::Url {
|
||||
id: target_id,
|
||||
code: slug,
|
||||
destination: String::new(),
|
||||
title: None,
|
||||
description: None,
|
||||
created_at,
|
||||
updated_at,
|
||||
status,
|
||||
tags: vec![],
|
||||
expires_at: None,
|
||||
password_hash: None,
|
||||
max_access_count: None,
|
||||
access_count: 0,
|
||||
expired: false,
|
||||
last_latency_ms: None,
|
||||
last_status: None,
|
||||
}
|
||||
};
|
||||
let owner_username = {
|
||||
let u_conn = state.users_db.lock().unwrap();
|
||||
u_conn
|
||||
.query_row(
|
||||
"SELECT username FROM users WHERE tenant_id = ?1;",
|
||||
[&owner_tid_str],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.ok()
|
||||
};
|
||||
urls.push(crate::templates::urls::AdminUrlRow {
|
||||
url,
|
||||
owner_tenant_id: owner_tid_str,
|
||||
owner_username,
|
||||
});
|
||||
}
|
||||
|
||||
let start_page = current_page.saturating_sub(3).max(1);
|
||||
let end_page = std::cmp::min(total_pages, current_page + 3);
|
||||
@@ -406,7 +490,8 @@ pub async fn urls_get(
|
||||
template.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[derive(Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
pub struct CreateUrlForm {
|
||||
pub destination: String,
|
||||
pub code: String,
|
||||
@@ -427,170 +512,20 @@ pub struct CreateUrlForm {
|
||||
pub async fn urls_create(
|
||||
State(state): State<AppState>,
|
||||
jar: CookieJar,
|
||||
headers: HeaderMap,
|
||||
connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(form): Form<CreateUrlForm>,
|
||||
_headers: HeaderMap,
|
||||
_connect_info: Option<ConnectInfo<SocketAddr>>,
|
||||
Form(_form): Form<CreateUrlForm>,
|
||||
) -> Response {
|
||||
let (user, session_id) = match require_auth(&state, &jar).await {
|
||||
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",
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account",
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
@@ -614,9 +549,30 @@ pub async fn urls_delete(
|
||||
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
match delete_url(&conn, &id) {
|
||||
Ok(_) => {
|
||||
// Look up owner tenant in global_urls
|
||||
let owner_info = {
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT slug, owner_tenant_id FROM global_urls WHERE target_id = ?1 OR slug = ?1 LIMIT 1;",
|
||||
rusqlite::params![id],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
).ok()
|
||||
};
|
||||
|
||||
if let Some((slug, tid_str)) = owner_info {
|
||||
if let Ok(tid) = tid_str.parse::<crate::identity::TenantId>() {
|
||||
if let Ok(user_dbs) = state.open_tenant(tid, crate::state::TenantOpenMode::CoreJob) {
|
||||
let conn = user_dbs.content.lock().unwrap();
|
||||
let _ = delete_url(&conn, &id);
|
||||
}
|
||||
}
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_urls SET status = 'retired', updated_at = ?1 WHERE slug = ?2;",
|
||||
rusqlite::params![chrono::Utc::now().to_rfc3339(), slug],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
@@ -632,8 +588,3 @@ pub async fn urls_delete(
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,7 @@ pub async fn users_create_post(
|
||||
}
|
||||
};
|
||||
|
||||
if new_user.account_type == "standard" {
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
@@ -140,6 +141,7 @@ pub async fn users_create_post(
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.admin_db.lock().unwrap();
|
||||
@@ -481,25 +483,9 @@ pub async fn user_detail_get(
|
||||
|
||||
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(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
@@ -576,25 +562,9 @@ pub async fn user_edit_get(
|
||||
|
||||
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(),
|
||||
match crate::db::users::get_user_by_id(&conn, id) {
|
||||
Ok(Some(u)) => u,
|
||||
_ => return Redirect::to("/admin/users?error=User not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -12,12 +12,10 @@ use crate::auth::ApiUser;
|
||||
use crate::db::admin::write_audit_log;
|
||||
use crate::db::analytics::{
|
||||
get_clicks_trend, get_clicks_trend_raw, get_metric_rankings, get_metric_rankings_raw,
|
||||
get_total_clicks, get_total_page_views,
|
||||
};
|
||||
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_counts, list_landing_pages, list_urls,
|
||||
update_landing_page, update_url,
|
||||
create_landing_page, delete_landing_page, delete_url, get_landing_page_by_id, get_url_by_id,
|
||||
list_landing_pages, list_urls, update_landing_page, update_url,
|
||||
};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -73,6 +71,30 @@ pub struct ApiError {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[allow(clippy::result_large_err)]
|
||||
fn get_api_tenant_dbs(state: &AppState, user: &ApiUser) -> Result<crate::state::UserDbs, Response> {
|
||||
match user.0 {
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
state.get_user_dbs(u.id).map_err(|_| {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()
|
||||
})
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot access application content directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response()),
|
||||
}
|
||||
}
|
||||
|
||||
// --- URL Endpoints ---
|
||||
|
||||
// POST /api/v1/urls
|
||||
@@ -138,9 +160,17 @@ pub async fn api_create_url(
|
||||
None
|
||||
};
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -154,7 +184,19 @@ pub async fn api_create_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = match u.tenant_id {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "User has no assigned TenantId".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -174,30 +216,22 @@ pub async fn api_create_url(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_url_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"url",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -222,13 +256,10 @@ pub async fn api_create_url(
|
||||
|
||||
match res {
|
||||
Ok(url) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_urls.db
|
||||
{
|
||||
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 urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_url_slug(&urls_conn, &code, &url.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -264,8 +295,8 @@ pub async fn api_create_url(
|
||||
(StatusCode::CREATED, Json(url)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = crate::db::slugs::release_url_slug(&urls_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -287,13 +318,17 @@ pub struct ListQuery {
|
||||
|
||||
pub async fn api_list_urls(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_urls(&conn, limit, offset, query.tag.as_deref()) {
|
||||
Ok(urls) => Json(urls).into_response(),
|
||||
Err(e) => (
|
||||
@@ -309,10 +344,14 @@ pub async fn api_list_urls(
|
||||
// GET /api/v1/urls/:uuid
|
||||
pub async fn api_get_url(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(url)) => Json(url).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -351,7 +390,11 @@ pub async fn api_update_url(
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_url(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -438,9 +481,21 @@ pub async fn api_delete_url(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_url_by_id(&conn, &uuid) {
|
||||
Ok(Some(u)) => Some(u.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_url(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -510,9 +565,17 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, tenant ID, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "Admin is a platform operator and cannot create application landing pages directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
@@ -526,7 +589,19 @@ pub async fn api_create_page(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
let tid = match u.tenant_id {
|
||||
Some(t) => t,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(ApiError {
|
||||
error: "User has no assigned TenantId".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
(u.id, tid, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -546,30 +621,22 @@ pub async fn api_create_page(
|
||||
}
|
||||
}
|
||||
|
||||
// Check availability
|
||||
// Check availability and reserve in v0.8 slug registry
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
if !crate::db::users::is_slug_available(&system_conn, &code).unwrap_or(false) {
|
||||
let reserved_conn = state.db.reserved.lock().unwrap();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
if let Err(e) = crate::db::slugs::reserve_landing_page_slug(
|
||||
&reserved_conn,
|
||||
&urls_conn,
|
||||
&pages_conn,
|
||||
&code,
|
||||
&target_tenant_id,
|
||||
) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
Json(ApiError {
|
||||
error: "Short code already exists".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
if let Err(e) = crate::db::users::register_global_slug(
|
||||
&system_conn,
|
||||
&code,
|
||||
target_user_id,
|
||||
"page",
|
||||
"",
|
||||
"reserving",
|
||||
) {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
error: format!("Failed to reserve slug: {}", e),
|
||||
error: format!("Short code unavailable: {}", e),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
@@ -590,18 +657,10 @@ pub async fn api_create_page(
|
||||
|
||||
match res {
|
||||
Ok(page) => {
|
||||
// Activate slug
|
||||
// Activate slug in v0.8 global_landing_pages.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let global_status = if payload.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 pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = crate::db::slugs::activate_landing_page_slug(&pages_conn, &code, &page.id);
|
||||
}
|
||||
// Increment quota
|
||||
{
|
||||
@@ -627,8 +686,9 @@ pub async fn api_create_page(
|
||||
(StatusCode::CREATED, Json(page)).into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let _ = crate::db::users::release_global_slug(&system_conn, &code, target_user_id);
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ =
|
||||
crate::db::slugs::release_landing_page_slug(&pages_conn, &code, &target_tenant_id);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -643,13 +703,17 @@ pub async fn api_create_page(
|
||||
// GET /api/v1/pages
|
||||
pub async fn api_list_pages(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Query(query): Query<ListQuery>,
|
||||
) -> Response {
|
||||
let limit = query.limit.unwrap_or(100);
|
||||
let offset = query.offset.unwrap_or(0);
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match list_landing_pages(&conn, limit, offset) {
|
||||
Ok(pages) => Json(pages).into_response(),
|
||||
Err(e) => (
|
||||
@@ -665,10 +729,14 @@ pub async fn api_list_pages(
|
||||
// GET /api/v1/pages/:uuid
|
||||
pub async fn api_get_page(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(page)) => Json(page).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -697,7 +765,11 @@ pub async fn api_update_page(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<UpdatePageRequest>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match update_landing_page(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -745,9 +817,22 @@ pub async fn api_delete_page(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
let code_opt = match get_landing_page_by_id(&conn, &uuid) {
|
||||
Ok(Some(p)) => Some(p.code),
|
||||
_ => None,
|
||||
};
|
||||
match delete_landing_page(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
if let Some(code) = code_opt {
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let _ = pages_conn
|
||||
.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&code]);
|
||||
}
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let user_agent = headers.get("user-agent").and_then(|h| h.to_str().ok());
|
||||
let _ = write_audit_log(
|
||||
@@ -797,20 +882,44 @@ pub async fn api_overall_stats(State(state): State<AppState>, user: ApiUser) ->
|
||||
}
|
||||
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
get_landing_page_count(&conn).unwrap_or(0)
|
||||
};
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
get_total_clicks(&conn).unwrap_or(0),
|
||||
get_total_page_views(&conn).unwrap_or(0),
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
Json(OverallStatsResponse {
|
||||
total_urls,
|
||||
@@ -835,12 +944,17 @@ pub struct DetailStatsResponse {
|
||||
// GET /api/v1/stats/url/:uuid
|
||||
pub async fn api_url_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -852,7 +966,7 @@ pub async fn api_url_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "url", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "url", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -879,12 +993,17 @@ pub async fn api_url_stats(
|
||||
// GET /api/v1/stats/page/:uuid
|
||||
pub async fn api_page_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify Page exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_landing_page_by_id(&conn, &uuid)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -899,7 +1018,7 @@ pub async fn api_page_stats(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let clicks = get_clicks_trend(&conn, "page", &uuid, 30)
|
||||
.or_else(|_| get_clicks_trend_raw(&conn, "page", &uuid, 30))
|
||||
.unwrap_or_default();
|
||||
@@ -935,11 +1054,16 @@ pub struct QrStatsResponse {
|
||||
// GET /api/v1/qr/:code
|
||||
pub async fn api_get_qr_stats(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(code): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
let url_opt = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
crate::db::content::get_url_by_code(&conn, &code).unwrap_or(None)
|
||||
};
|
||||
|
||||
@@ -957,7 +1081,7 @@ pub async fn api_get_qr_stats(
|
||||
};
|
||||
|
||||
let (scan_count, scans) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
let conn = dbs.analytics.lock().unwrap();
|
||||
let count = crate::db::qr::get_qr_scan_count(&conn, &url.id).unwrap_or(0);
|
||||
let scans_list = crate::db::qr::get_qr_stats_for_url(&conn, &url.id).unwrap_or_default();
|
||||
(count, scans_list)
|
||||
@@ -1030,9 +1154,14 @@ pub async fn api_set_preview(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPreviewRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1044,7 +1173,7 @@ pub async fn api_set_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::upsert_preview(
|
||||
&conn,
|
||||
&uuid,
|
||||
@@ -1081,12 +1210,17 @@ pub async fn api_set_preview(
|
||||
// GET /api/v1/urls/:uuid/preview
|
||||
pub async fn api_get_preview(
|
||||
State(state): State<AppState>,
|
||||
_user: ApiUser,
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1098,7 +1232,7 @@ pub async fn api_get_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::get_preview(&conn, &uuid) {
|
||||
Ok(Some(preview)) => Json(preview).into_response(),
|
||||
Ok(None) => (
|
||||
@@ -1124,9 +1258,14 @@ pub async fn api_delete_preview(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1138,7 +1277,7 @@ pub async fn api_delete_preview(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::preview::delete_preview(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1186,9 +1325,14 @@ pub async fn api_set_password(
|
||||
Path(uuid): Path<String>,
|
||||
Json(payload): Json<SetPasswordRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1213,7 +1357,7 @@ pub async fn api_set_password(
|
||||
}
|
||||
};
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::set_url_password(&conn, &uuid, &hash) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1257,9 +1401,14 @@ pub async fn api_remove_password(
|
||||
user: ApiUser,
|
||||
Path(uuid): Path<String>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &uuid).unwrap_or(None).is_none() {
|
||||
return (
|
||||
StatusCode::NOT_FOUND,
|
||||
@@ -1271,7 +1420,7 @@ pub async fn api_remove_password(
|
||||
}
|
||||
}
|
||||
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::content::remove_url_password(&conn, &uuid) {
|
||||
Ok(true) => {
|
||||
// Audit Log
|
||||
@@ -1321,9 +1470,14 @@ pub async fn api_create_qr(
|
||||
user: ApiUser,
|
||||
Json(payload): Json<CreateQrRequest>,
|
||||
) -> Response {
|
||||
let dbs = match get_api_tenant_dbs(&state, &user) {
|
||||
Ok(d) => d,
|
||||
Err(r) => return r,
|
||||
};
|
||||
|
||||
// Verify URL exists in content.db
|
||||
{
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
if get_url_by_id(&conn, &payload.url_id)
|
||||
.unwrap_or(None)
|
||||
.is_none()
|
||||
@@ -1339,7 +1493,7 @@ pub async fn api_create_qr(
|
||||
}
|
||||
|
||||
let style = payload.style.unwrap_or_else(|| "default".to_string());
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
let conn = dbs.content.lock().unwrap();
|
||||
match crate::db::qr::upsert_qr_code(&conn, &payload.url_id, &style) {
|
||||
Ok(_) => {
|
||||
// Write Audit Event
|
||||
|
||||
@@ -67,9 +67,36 @@ pub async fn api_bulk_qr(
|
||||
}
|
||||
|
||||
// Retrieve URLs from database
|
||||
let content_db = match user.0 {
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Database error".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
user_dbs.content.clone()
|
||||
}
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot export application URLs without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let mut urls = Vec::new();
|
||||
{
|
||||
let conn = match lock_db(&state.content_db, "content_db") {
|
||||
let conn = match lock_db(&content_db, "content_db") {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
return (
|
||||
@@ -186,10 +213,30 @@ pub async fn api_bulk_url(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
// Dynamically resolve target user ID and content DB
|
||||
let (target_user_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => (1, state.content_db.clone()),
|
||||
// Dynamically resolve target user ID, TenantId, and content DB
|
||||
let (target_user_id, target_tenant_id, content_db) = match user.0 {
|
||||
crate::models::ApiActor::Admin(_) => {
|
||||
return (
|
||||
StatusCode::FORBIDDEN,
|
||||
Json(BulkErrorResponse {
|
||||
error: "Admin is a platform operator and cannot create application URLs directly without tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
crate::models::ApiActor::User(ref u) => {
|
||||
let tenant_id = match u.tenant_id {
|
||||
Some(tid) => tid,
|
||||
None => {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(BulkErrorResponse {
|
||||
error: "User has no tenant context".to_string(),
|
||||
}),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
let user_dbs = match state.get_user_dbs(u.id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
@@ -202,7 +249,7 @@ pub async fn api_bulk_url(
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
(u.id, user_dbs.content.clone())
|
||||
(u.id, tenant_id, user_dbs.content.clone())
|
||||
}
|
||||
};
|
||||
|
||||
@@ -226,9 +273,12 @@ pub async fn api_bulk_url(
|
||||
|
||||
let created_urls = match create_urls_bulk(
|
||||
&content_db,
|
||||
&state.system_db,
|
||||
&state.db.reserved,
|
||||
&state.db.global_urls,
|
||||
&state.db.global_landing_pages,
|
||||
&state.users_db,
|
||||
target_user_id,
|
||||
target_tenant_id,
|
||||
items,
|
||||
) {
|
||||
Ok(urls) => urls,
|
||||
|
||||
@@ -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;
|
||||
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{IntoResponse, Json, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::ApiUser;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::ApiActor;
|
||||
use crate::state::AppState;
|
||||
|
||||
@@ -354,40 +354,68 @@ pub fn delete_user_resources(
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Transactional clean up on system.db (deleting their global slug mappings)
|
||||
{
|
||||
let mut system_conn = state.system_db.lock().unwrap();
|
||||
let tx = system_conn.transaction().map_err(|e| e.to_string())?;
|
||||
// 1. Transactional clean up on v0.8 slug databases
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if let Some(ref tid) = user_details.tenant_id {
|
||||
let tid_str = tid.to_string();
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
|
||||
let slugs: Vec<String> = {
|
||||
let mut stmt = tx
|
||||
.prepare("SELECT slug FROM global_slugs WHERE owner_user_id = ?1;")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([target_id], |row| row.get(0))
|
||||
.map_err(|e| e.to_string())?;
|
||||
rows.filter_map(|r| r.ok()).collect()
|
||||
// Get all URL slugs owned by tenant
|
||||
let url_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
urls_conn.prepare("SELECT slug FROM global_urls WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
for slug in url_slugs {
|
||||
let _ = urls_conn.execute("DELETE FROM global_urls WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
|
||||
tx.commit()
|
||||
.map_err(|e| format!("Failed to release slugs: {}", e))?;
|
||||
// Get all page slugs owned by tenant
|
||||
let page_slugs: Vec<String> = if let Ok(mut stmt) =
|
||||
pages_conn.prepare("SELECT slug FROM global_landing_pages WHERE owner_tenant_id = ?1;")
|
||||
{
|
||||
stmt.query_map([&tid_str], |row| row.get::<_, String>(0))
|
||||
.map(|rows| rows.filter_map(|r| r.ok()).collect())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
for slug in page_slugs {
|
||||
let _ =
|
||||
pages_conn.execute("DELETE FROM global_landing_pages WHERE slug = ?1;", [&slug]);
|
||||
let _ = system_conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username)
|
||||
VALUES (?1, ?2, NULL, 'deleted', ?3, ?4);",
|
||||
rusqlite::params![slug, target_id, now, admin_username],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let user_dir = state
|
||||
.config
|
||||
.data_dir
|
||||
.join("users")
|
||||
.join(target_id.to_string());
|
||||
let user_dir = {
|
||||
let conn = state.users_db.lock().unwrap();
|
||||
match crate::db::users::get_user_by_id(&conn, target_id) {
|
||||
Ok(Some(u)) => crate::db::tenant::location_for_user(&u)
|
||||
.and_then(|loc| {
|
||||
loc.dir(&state.db.topology)
|
||||
.map_err(|e| crate::error::AppError::BadRequest(e.to_string()))
|
||||
})
|
||||
.map_err(|e| e.to_string())?,
|
||||
_ => return Err("User not found".into()),
|
||||
}
|
||||
};
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
}
|
||||
@@ -444,231 +472,23 @@ pub async fn admin_transfer_slug(
|
||||
Err(err_resp) => return err_resp,
|
||||
};
|
||||
|
||||
// 1. Check if the slug exists and get details
|
||||
let (old_owner_user_id, target_type, _target_id) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
let row_opt = stmt
|
||||
.query_row([&payload.slug], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional();
|
||||
|
||||
match row_opt {
|
||||
Ok(Some(r)) => r,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
}
|
||||
let req = crate::services::slug_transfer::SlugTransferRequest {
|
||||
slug: payload.slug,
|
||||
new_owner_user_id: payload.new_owner_user_id,
|
||||
};
|
||||
|
||||
if old_owner_user_id == payload.new_owner_user_id {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner must be different from the current owner",
|
||||
)
|
||||
.into_response();
|
||||
match crate::services::slug_transfer::transfer_slug(&state, &req, &admin.username) {
|
||||
Ok(_) => StatusCode::OK.into_response(),
|
||||
Err(crate::services::slug_transfer::TransferError::NotFound(m)) => {
|
||||
(StatusCode::NOT_FOUND, m.to_string()).into_response()
|
||||
}
|
||||
|
||||
// 2. Fetch destination databases
|
||||
let old_dbs = match state.get_user_dbs(old_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load current owner's database",
|
||||
)
|
||||
.into_response()
|
||||
Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => {
|
||||
(StatusCode::BAD_REQUEST, m).into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(payload.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Failed to load new owner's database",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
// 3. Perform transfer: copy record from old owner's content.db to new owner's content.db
|
||||
let mut new_target_id = String::new();
|
||||
let transfer_success = {
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
// Get URL record
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &payload.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
Err(crate::services::slug_transfer::TransferError::Internal(m)) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, m).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = 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,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_url) => {
|
||||
new_target_id = new_url.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy URL to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
// Get page record
|
||||
let page_opt =
|
||||
match crate::db::content::get_landing_page_by_code(&old_conn, &payload.slug) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check new owner quotas
|
||||
let new_users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&new_users_conn, payload.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return (
|
||||
StatusCode::BAD_REQUEST,
|
||||
"New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Insert into new owner database
|
||||
let ins_res = crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
);
|
||||
|
||||
match ins_res {
|
||||
Ok(new_page) => {
|
||||
new_target_id = new_page.id;
|
||||
// Delete from old owner database
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
true
|
||||
}
|
||||
Err(e) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("Failed to copy Page to new owner: {}", e),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !transfer_success {
|
||||
return (StatusCode::NOT_FOUND, "Content not found in owner database").into_response();
|
||||
}
|
||||
|
||||
// 4. Update system global_slugs, slug_history and adjust quotas
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
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![payload.new_owner_user_id, new_target_id, now, payload.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![payload.slug, old_owner_user_id, payload.new_owner_user_id, now, admin.username],
|
||||
);
|
||||
|
||||
// Adjust quotas
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
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,
|
||||
payload.new_owner_user_id,
|
||||
field,
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&system_conn,
|
||||
&admin.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&payload.slug,
|
||||
Some(&format!(
|
||||
"From owner {} to owner {}",
|
||||
old_owner_user_id, payload.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
StatusCode::OK.into_response()
|
||||
}
|
||||
|
||||
// --- Admin: Content Moderation ---
|
||||
@@ -689,44 +509,88 @@ pub async fn admin_moderate_slug(
|
||||
return (StatusCode::BAD_REQUEST, "Invalid moderation action").into_response();
|
||||
}
|
||||
|
||||
// 1. Verify slug and get owner user ID
|
||||
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;",
|
||||
[&payload.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
// 1. Verify slug using v0.8 slug lookup
|
||||
let slug_info = match state.lookup_slug(&payload.slug) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
};
|
||||
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return (StatusCode::NOT_FOUND, "Slug not found").into_response(),
|
||||
let target_type = slug_info.target_type.as_str().to_string();
|
||||
let owner_tenant_id = match TenantId::parse(&slug_info.owner_tenant_id) {
|
||||
Ok(tid) => tid,
|
||||
Err(_) => {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Invalid owner tenant ID on slug",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
};
|
||||
|
||||
// Resolve owner username for log snapshot
|
||||
let owner_username = {
|
||||
// Resolve owner details from users.db for moderation event history
|
||||
let (owner_user_id, 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)
|
||||
match crate::db::users::get_user_by_tenant_id(&users_conn, owner_tenant_id) {
|
||||
Ok(Some(u)) => (u.id, Some(u.username)),
|
||||
_ => (0, None),
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Perform moderation update in global_slugs
|
||||
// 2. Perform moderation update in authoritative v0.8 slug databases
|
||||
{
|
||||
let urls_conn = state.db.global_urls.lock().unwrap();
|
||||
let pages_conn = state.db.global_landing_pages.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let _ = urls_conn.execute(
|
||||
"UPDATE global_urls SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let _ = pages_conn.execute(
|
||||
"UPDATE global_landing_pages SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sync status to owner tenant's content DB if possible
|
||||
if let Ok(tenant_dbs) =
|
||||
state.open_tenant(owner_tenant_id, crate::db::tenant::TenantOpenMode::CoreJob)
|
||||
{
|
||||
if let Ok(conn) = tenant_dbs.content.lock() {
|
||||
if slug_info.target_type == crate::db::slugs::SlugTargetType::Url {
|
||||
let content_status = if action == "disabled" {
|
||||
"dead"
|
||||
} else {
|
||||
"active"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE urls SET status = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_status, payload.slug],
|
||||
);
|
||||
} else {
|
||||
let content_state = if action == "disabled" {
|
||||
"archived"
|
||||
} else {
|
||||
"published"
|
||||
};
|
||||
let _ = conn.execute(
|
||||
"UPDATE landing_pages SET state = ?1 WHERE code = ?2;",
|
||||
rusqlite::params![content_state, payload.slug],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Record moderation event in system.db
|
||||
{
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = system_conn.execute(
|
||||
"UPDATE global_slugs SET status = ?1, updated_at = ?2 WHERE slug = ?3;",
|
||||
rusqlite::params![action, now, payload.slug],
|
||||
);
|
||||
|
||||
// Record moderation event
|
||||
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);",
|
||||
|
||||
@@ -4,11 +4,11 @@ use axum::{
|
||||
response::{Html, IntoResponse, Response},
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rusqlite::OptionalExtension;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::analytics::get_client_country;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::models::VisitRecord;
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -25,37 +25,15 @@ pub async fn resolve_page(
|
||||
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 (owner_user_id, _target_type, _target_id, slug_status) = match slug_info {
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let info = match state.lookup_slug(&code) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => {
|
||||
// Fallback to legacy_admin's DB (user_id = 1) if not found in global_slugs
|
||||
(1, "page".to_string(), "".to_string(), "active".to_string())
|
||||
}
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
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" {
|
||||
if info.status != "active" {
|
||||
return (
|
||||
StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
@@ -64,7 +42,8 @@ pub async fn resolve_page(
|
||||
}
|
||||
|
||||
// 2. Get content database connection via tenant DB resolution
|
||||
let content_conn = match state.get_user_dbs(owner_user_id) {
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
@@ -103,6 +82,9 @@ pub async fn resolve_page(
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
let owner_tenant_id = crate::identity::TenantId::parse(&info.owner_tenant_id).ok();
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().ok();
|
||||
|
||||
let record = VisitRecord {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
target_type: "page".to_string(),
|
||||
@@ -114,7 +96,8 @@ pub async fn resolve_page(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: 200,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
};
|
||||
|
||||
state.analytics_queue.push(record);
|
||||
|
||||
@@ -7,7 +7,7 @@ use axum_extra::extract::{cookie::Cookie, CookieJar};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::auth::password::verify_password;
|
||||
use crate::services::shortener::get_url_by_code;
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::state::AppState;
|
||||
use crate::templates::GateTemplate;
|
||||
|
||||
@@ -21,6 +21,43 @@ pub async fn gate_get(Path(code): Path<String>) -> impl IntoResponse {
|
||||
GateTemplate { code, error: None }
|
||||
}
|
||||
|
||||
enum GateLookup {
|
||||
Missing,
|
||||
Gone,
|
||||
Unprotected,
|
||||
Protected(String),
|
||||
}
|
||||
|
||||
fn lookup_gate(state: &AppState, code: &str) -> Result<GateLookup, axum::http::StatusCode> {
|
||||
let info = match state
|
||||
.lookup_slug(code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(info) if info.status == "active" => info,
|
||||
Some(_) => return Ok(GateLookup::Gone),
|
||||
None => return Ok(GateLookup::Missing),
|
||||
};
|
||||
|
||||
let user_dbs = match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return Ok(GateLookup::Missing),
|
||||
};
|
||||
let conn = user_dbs
|
||||
.content
|
||||
.lock()
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
match crate::db::content::get_url_by_code(&conn, code)
|
||||
.map_err(|_| axum::http::StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
{
|
||||
Some(u) => match u.password_hash {
|
||||
Some(h) => Ok(GateLookup::Protected(h)),
|
||||
None => Ok(GateLookup::Unprotected),
|
||||
},
|
||||
None => Ok(GateLookup::Missing),
|
||||
}
|
||||
}
|
||||
|
||||
// POST /gate/:code
|
||||
pub async fn gate_post(
|
||||
State(state): State<AppState>,
|
||||
@@ -29,32 +66,25 @@ pub async fn gate_post(
|
||||
headers: axum::http::HeaderMap,
|
||||
Form(form): Form<PasswordGateForm>,
|
||||
) -> Response {
|
||||
let url_opt = match get_url_by_code(&state.db, &code) {
|
||||
Ok(url) => url,
|
||||
Err(_) => {
|
||||
return (
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Database error",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
let url = match url_opt {
|
||||
Some(u) => u,
|
||||
None => return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response(),
|
||||
};
|
||||
|
||||
let password_hash = match url.password_hash {
|
||||
Some(ref h) => h,
|
||||
None => {
|
||||
// Not password protected, redirect to resolution
|
||||
let password_hash = match lookup_gate(&state, &code) {
|
||||
Ok(GateLookup::Protected(h)) => h,
|
||||
Ok(GateLookup::Unprotected) => {
|
||||
return Redirect::temporary(&format!("/{}", code)).into_response();
|
||||
}
|
||||
Ok(GateLookup::Missing) => {
|
||||
return (axum::http::StatusCode::NOT_FOUND, "Url not found").into_response();
|
||||
}
|
||||
Ok(GateLookup::Gone) => {
|
||||
return (
|
||||
axum::http::StatusCode::GONE,
|
||||
"This content has been disabled or moderated",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
Err(status) => return (status, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if verify_password(&form.password, password_hash) {
|
||||
// Correct password - set 15 min temporary cookie
|
||||
if verify_password(&form.password, &password_hash) {
|
||||
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"))
|
||||
@@ -67,7 +97,6 @@ pub async fn gate_post(
|
||||
let updated_jar = jar.add(cookie);
|
||||
(updated_jar, Redirect::temporary(&format!("/{}", code))).into_response()
|
||||
} else {
|
||||
// Invalid password
|
||||
GateTemplate {
|
||||
code,
|
||||
error: Some("Invalid password".to_string()),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::db::tenant::TenantOpenMode;
|
||||
use crate::services::qr::{generate_qr_png, generate_qr_svg};
|
||||
use crate::state::AppState;
|
||||
use crate::utils::get_client_ip;
|
||||
@@ -37,57 +38,36 @@ pub async fn qr_handler(
|
||||
return (StatusCode::UNAUTHORIZED, "Unauthorized").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_id, slug_status) = {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
let mut stmt = match system_conn.prepare(
|
||||
"SELECT owner_user_id, target_id, status FROM global_slugs WHERE slug = ?1;",
|
||||
) {
|
||||
Ok(s) => s,
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
match stmt
|
||||
.query_row(rusqlite::params![&file], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
))
|
||||
})
|
||||
.optional()
|
||||
{
|
||||
Ok(Some((uid, tid, status))) => (uid, tid, status),
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(&file) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
if slug_status == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "URL not found").into_response();
|
||||
}
|
||||
|
||||
let user_dbs = match state.get_user_dbs(owner_user_id) {
|
||||
let user_dbs = match state
|
||||
.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
|
||||
let qr_scans = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
crate::db::qr::get_qr_scan_count(&conn, &target_id).unwrap_or(0)
|
||||
crate::db::qr::get_qr_scan_count(&conn, &info.target_id).unwrap_or(0)
|
||||
};
|
||||
|
||||
let direct_clicks = {
|
||||
let conn = user_dbs.analytics.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM visits WHERE target_id = ?1;",
|
||||
rusqlite::params![target_id],
|
||||
rusqlite::params![info.target_id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
@@ -109,36 +89,16 @@ pub async fn qr_handler(
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
// We need to look up owner_user_id, target_type, target_id, and status from global_slugs
|
||||
let (owner_user_id, target_type, target_id, slug_status) = {
|
||||
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(),
|
||||
};
|
||||
use rusqlite::OptionalExtension;
|
||||
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()
|
||||
{
|
||||
// Look up slug info from v0.8 slug registry (with fallback)
|
||||
let info = match state.lookup_slug(code) {
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if slug_status == "disabled" {
|
||||
if info.status == "disabled" {
|
||||
return (StatusCode::GONE, "This content has been disabled").into_response();
|
||||
} else if slug_status != "active" {
|
||||
} else if info.status != "active" {
|
||||
return (StatusCode::NOT_FOUND, "Not Found").into_response();
|
||||
}
|
||||
|
||||
@@ -159,7 +119,7 @@ pub async fn qr_handler(
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{}://{}", proto, host_header));
|
||||
|
||||
let full_url = if target_type == "page" {
|
||||
let full_url = if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
format!("{}/p/{}", base_url.trim_end_matches('/'), code)
|
||||
} else {
|
||||
format!("{}/{}", base_url.trim_end_matches('/'), code)
|
||||
@@ -204,11 +164,13 @@ pub async fn qr_handler(
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Ok(user_dbs) = state.get_user_dbs(owner_user_id) {
|
||||
if let Ok(user_dbs) =
|
||||
state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent)
|
||||
{
|
||||
if let Ok(analytics_conn) = user_dbs.analytics.lock() {
|
||||
let _ = crate::db::qr::log_qr_access(
|
||||
&analytics_conn,
|
||||
&target_id,
|
||||
&info.target_id,
|
||||
Some(ip.as_str()),
|
||||
user_agent.as_deref(),
|
||||
);
|
||||
|
||||
@@ -15,18 +15,26 @@ use axum::{
|
||||
};
|
||||
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::db::tenant::TenantOpenMode;
|
||||
use crate::identity::TenantId;
|
||||
use crate::models::{LinkPreview, Url, VisitRecord};
|
||||
use crate::state::AppState;
|
||||
use crate::templates::PreviewTemplate;
|
||||
use crate::utils::get_client_ip;
|
||||
|
||||
struct ResolvedUrl {
|
||||
owner_tenant_id: Option<TenantId>,
|
||||
owner_user_id: i64,
|
||||
url: Url,
|
||||
content: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
/// Compact outcome from blocking redirect DB work (avoids large enum / Result variants).
|
||||
enum ResolveOutcome {
|
||||
Ready(Box<ResolvedUrl>),
|
||||
@@ -111,79 +119,40 @@ fn permanent_redirect_to(destination: &str, code: &str) -> 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>>,
|
||||
_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()
|
||||
{
|
||||
// 1. Query global slug namespace across v0.8 slug databases (with fallback)
|
||||
let slug_info = match state.lookup_slug(code) {
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
operation: "lookup_slug",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (owner_user_id, target_type, _target_id, slug_status) = match slug_info {
|
||||
let info = 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())
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::NOT_FOUND,
|
||||
body: "Not Found",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let owner_user_id = info.owner_tenant_id.parse::<i64>().unwrap_or(0);
|
||||
|
||||
// If slug status is disabled, flagged, or soft_deleted, we return 410 Gone
|
||||
if slug_status != "active" {
|
||||
if info.status != "active" {
|
||||
return ResolveOutcome::Early {
|
||||
status: StatusCode::GONE,
|
||||
body: "This content has been disabled or moderated",
|
||||
@@ -191,16 +160,17 @@ fn resolve_url_blocking(
|
||||
}
|
||||
|
||||
// If target type is page, redirect permanently to /p/slug
|
||||
if target_type == "page" {
|
||||
if info.target_type == crate::db::slugs::SlugTargetType::LandingPage {
|
||||
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) {
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
operation: "open_slug_owner",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
@@ -239,7 +209,10 @@ fn resolve_url_blocking(
|
||||
}
|
||||
};
|
||||
|
||||
let owner_tenant_id = TenantId::parse(&info.owner_tenant_id).ok();
|
||||
|
||||
ResolveOutcome::Ready(Box::new(ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content: content_conn.content,
|
||||
@@ -347,6 +320,7 @@ pub async fn resolve_redirect(
|
||||
};
|
||||
|
||||
let ResolvedUrl {
|
||||
owner_tenant_id,
|
||||
owner_user_id,
|
||||
url,
|
||||
content,
|
||||
@@ -424,7 +398,6 @@ pub async fn resolve_redirect(
|
||||
}
|
||||
};
|
||||
|
||||
// Asynchronously record analytics
|
||||
let ip = get_client_ip(&headers, connect_info);
|
||||
let country = get_client_country(&headers);
|
||||
let user_agent = headers
|
||||
@@ -454,6 +427,7 @@ pub async fn resolve_redirect(
|
||||
accept_language,
|
||||
country,
|
||||
status_code: if preview_opt.is_some() { 200 } else { 302 },
|
||||
owner_tenant_id,
|
||||
owner_user_id: Some(owner_user_id),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,7 +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/preview.png", get(pages::social_preview))
|
||||
.route("/images/*path", get(images::image_handler))
|
||||
.route(
|
||||
"/admin/analytics/url/:id/export/csv",
|
||||
get(admin::url_analytics_csv_export),
|
||||
|
||||
@@ -97,22 +97,46 @@ pub async fn metrics_endpoint(
|
||||
|
||||
// 1. Gather stats from DBs
|
||||
let (total_urls, active_links, dead_links) = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_url_counts(&conn).unwrap_or((0, 0, 0))
|
||||
let conn = state.db.global_urls.lock().unwrap();
|
||||
let total: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let active: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'active';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let dead: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM global_urls WHERE status = 'disabled';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
(total, active, dead)
|
||||
};
|
||||
|
||||
let total_pages = {
|
||||
let conn = state.content_db.lock().unwrap();
|
||||
crate::db::content::get_landing_page_count(&conn).unwrap_or(0)
|
||||
let conn = state.db.global_landing_pages.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT COUNT(*) FROM global_landing_pages WHERE status != 'retired';",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
|
||||
let (total_clicks, total_page_views) = {
|
||||
let conn = state.analytics_db.lock().unwrap();
|
||||
(
|
||||
crate::db::analytics::get_total_clicks(&conn).unwrap_or(0),
|
||||
crate::db::analytics::get_total_page_views(&conn).unwrap_or(0),
|
||||
)
|
||||
let total_clicks = {
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
crate::db::users::get_platform_total_clicks(&state.db.topology, &users_conn).unwrap_or(0)
|
||||
};
|
||||
let total_page_views = 0i64;
|
||||
|
||||
// Calculate memory in bytes
|
||||
let mut mem_bytes = 0;
|
||||
|
||||