Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e42d1a5d80 | ||
|
|
c2e138f823 | ||
|
|
d398341f01 | ||
|
|
d7e0ac7679 | ||
|
|
f138a645f8 | ||
|
|
ac66945c93 | ||
|
|
763a17f8dc | ||
|
|
8e0bcbe580 | ||
|
|
25577b4b83 | ||
|
|
2cd3c2d965 | ||
|
|
b66703082f |
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,4 @@ data/
|
||||
.env
|
||||
|
||||
.idea/
|
||||
bzod.env
|
||||
@@ -345,7 +345,7 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
|
||||
|
||||
[[package]]
|
||||
name = "bzod"
|
||||
version = "0.6.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.6.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"]
|
||||
CMD ["serve"]
|
||||
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)
|
||||
@@ -35,6 +35,8 @@ Core capabilities include:
|
||||
- Multi-User Platform
|
||||
- REST API
|
||||
- Role-Based Access Control (RBAC)
|
||||
- Responsive UI (Desktop, Tablet, Mobile)
|
||||
- Dark/Light Theme Support
|
||||
- Global Namespace Registry
|
||||
- Registry Validation
|
||||
- Transaction-safe Registry Repair
|
||||
@@ -44,6 +46,7 @@ Core capabilities include:
|
||||
- User Backup & Restore
|
||||
- Disaster Recovery
|
||||
- Upgrade Validation
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Single Binary Deployment
|
||||
|
||||
The result is a platform that is easy to deploy, lightweight to operate, and entirely controlled by its owner.
|
||||
@@ -90,7 +93,7 @@ No recurring subscription fees.
|
||||
|
||||
---
|
||||
|
||||
## Runtime Efficiency (v0.6.0)
|
||||
## Runtime Efficiency (v0.8.0)
|
||||
|
||||
| Metric | Value |
|
||||
|---------|------:|
|
||||
@@ -165,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
|
||||
```
|
||||
|
||||
@@ -179,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.
|
||||
@@ -199,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:
|
||||
@@ -231,6 +257,7 @@ After=network.target
|
||||
|
||||
[Service]
|
||||
|
||||
Environment=NX9_BZOD_DATA_DIR=/var/lib/bzod/data
|
||||
ExecStart=/usr/local/bin/bzod serve
|
||||
|
||||
Restart=always
|
||||
@@ -1135,16 +1162,37 @@ Highlights include:
|
||||
- Upgrade Validation
|
||||
- Backup Manifest
|
||||
- Transaction-safe Maintenance
|
||||
- Modular Admin Architecture (v0.6.0)
|
||||
- Redirect Handler Hardening (v0.6.0)
|
||||
- Modular Admin Architecture (v0.5.3)
|
||||
- Redirect Handler Hardening (v0.5.3)
|
||||
|
||||
---
|
||||
|
||||
### v0.6
|
||||
|
||||
- Legacy Backup Restore Compatibility
|
||||
- CLI Version Reporting (`--version` / `-V`)
|
||||
- Deploy Script Modernization
|
||||
|
||||
---
|
||||
|
||||
### v0.7
|
||||
|
||||
Responsive UI and theme refactor.
|
||||
|
||||
Highlights include:
|
||||
|
||||
- Responsive Admin & User Registry Panels
|
||||
- Desktop, Laptop, Tablet, and Mobile Layouts
|
||||
- Dark/Light Theme Support with Persistence
|
||||
- Table-to-Card Responsive Behavior
|
||||
- Build Metadata (Version & Git Commit)
|
||||
- Root Landing Page Runtime/Embedded Fallback
|
||||
|
||||
---
|
||||
|
||||
# Future Roadmap
|
||||
|
||||
## v0.6
|
||||
|
||||
Planned features include:
|
||||
Planned features and improvements:
|
||||
|
||||
- Webhooks
|
||||
- OpenAPI Documentation
|
||||
@@ -1155,13 +1203,6 @@ Planned features include:
|
||||
- Bulk Export
|
||||
- Custom Domains
|
||||
- Better QR Styling
|
||||
|
||||
---
|
||||
|
||||
## v0.7
|
||||
|
||||
Planned improvements:
|
||||
|
||||
- Email Notifications
|
||||
- Team Workspaces
|
||||
- Shared Projects
|
||||
@@ -1320,7 +1361,7 @@ Community feedback helps guide future development.
|
||||
|
||||
**Current Version**
|
||||
|
||||
**v0.5.3**
|
||||
**v0.8.0**
|
||||
|
||||
Production Ready
|
||||
|
||||
@@ -1336,12 +1377,15 @@ Production Ready
|
||||
- ✅ Analytics
|
||||
- ✅ REST API
|
||||
- ✅ Role-Based Access Control
|
||||
- ✅ Responsive UI (Desktop, Tablet, Mobile)
|
||||
- ✅ Dark/Light Theme Support
|
||||
- ✅ Registry Validator
|
||||
- ✅ Registry Repair Framework
|
||||
- ✅ Health Diagnostics
|
||||
- ✅ Backup & Restore
|
||||
- ✅ Disaster Recovery
|
||||
- ✅ Upgrade Validation
|
||||
- ✅ Build Metadata (Version & Git Commit)
|
||||
- ✅ Extensive Automated Test Suite
|
||||
|
||||
---
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||
println!("cargo:rerun-if-changed=.git/refs");
|
||||
|
||||
if let Ok(output) = Command::new("git")
|
||||
.args(["rev-parse", "--short=12", "HEAD"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !commit.is_empty() {
|
||||
println!("cargo:rustc-env=BZOD_GIT_COMMIT={commit}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,251 +1,475 @@
|
||||
#!/usr/bin/env bash
|
||||
# BZOD Production Deployment Script
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# BZOD Production Docker Deployment
|
||||
#
|
||||
# Privacy-First URL Shortener & Landing Page Platform
|
||||
#
|
||||
# Usage:
|
||||
# curl -fsSL https://bzo.in/deploy.sh | sudo bash
|
||||
#
|
||||
# Or:
|
||||
# sudo bash deploy.sh
|
||||
#
|
||||
# Environment overrides:
|
||||
# BZOD_VERSION=0.8.0
|
||||
# BZOD_IMAGE=nx9-url-shortener
|
||||
# BZOD_ROOT=/DATA/AppData/nx9-url-shortener
|
||||
# BZOD_PORT=8654
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
BZOD_VERSION="0.6.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}"
|
||||
apt-get update -qq
|
||||
apt-get install -y openssl sqlite3 ca-certificates curl tar gzip
|
||||
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
|
||||
|
||||
# 2. Install Binary (safe atomic download)
|
||||
echo -e "\n${BLUE}[2/8] Installing BZOD binary...${NC}"
|
||||
# ============================================================
|
||||
# 1. Install Docker
|
||||
# ============================================================
|
||||
|
||||
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
|
||||
info "[1/8] Checking Docker..."
|
||||
|
||||
REPO="thakares/nx9-url-shortener"
|
||||
RELEASE_URL="https://github.com/${REPO}/releases/download/v${BZOD_VERSION}/${BINARY_NAME}"
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
info "Docker is not installed. Installing Docker..."
|
||||
|
||||
TMP_BINARY=$(mktemp)
|
||||
apt-get update -qq
|
||||
apt-get install -y \
|
||||
ca-certificates \
|
||||
curl
|
||||
|
||||
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
|
||||
install -m 0755 -d /etc/apt/keyrings
|
||||
|
||||
if [[ ! -f /etc/apt/keyrings/docker.asc ]]; then
|
||||
curl -fsSL \
|
||||
https://download.docker.com/linux/debian/gpg \
|
||||
-o /etc/apt/keyrings/docker.asc
|
||||
|
||||
chmod a+r /etc/apt/keyrings/docker.asc
|
||||
fi
|
||||
|
||||
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
|
||||
|
||||
fi
|
||||
|
||||
# 6. Systemd Service
|
||||
echo -e "\n${BLUE}[6/8] Installing hardened systemd service...${NC}"
|
||||
cat <<EOF > "${SYSTEMD_UNIT}"
|
||||
[Unit]
|
||||
Description=BZOD - Privacy-First URL Shortener & Landing Page Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
# ============================================================
|
||||
# 4. Create Compose definition
|
||||
# ============================================================
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=${SERVICE_USER}
|
||||
Group=${SERVICE_USER}
|
||||
WorkingDirectory=/var/lib/bzod
|
||||
EnvironmentFile=${ENV_FILE}
|
||||
ExecStart=${INSTALL_PATH} serve
|
||||
info "[4/8] Writing Docker Compose configuration..."
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
cat > "${COMPOSE_FILE}" <<'EOF'
|
||||
services:
|
||||
|
||||
# Security Hardening
|
||||
ProtectSystem=strict
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
PrivateDevices=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectHostname=yes
|
||||
RestrictSUIDSGID=yes
|
||||
LockPersonality=yes
|
||||
NoNewPrivileges=yes
|
||||
ReadWritePaths=/var/lib/bzod
|
||||
bzod:
|
||||
image: ${BZOD_IMAGE}:${BZOD_VERSION}
|
||||
container_name: bzod
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
restart: unless-stopped
|
||||
|
||||
ports:
|
||||
- "${PORT:-8654}:8654"
|
||||
|
||||
environment:
|
||||
HOST: "${HOST:-0.0.0.0}"
|
||||
PORT: "${PORT:-8654}"
|
||||
|
||||
NX9_BZOD_DATA_DIR: "/app/data"
|
||||
CONFIG_DIR: "/app/config"
|
||||
IMAGES_DIR: "/app/images"
|
||||
|
||||
COOKIE_SECURE: "${COOKIE_SECURE:-true}"
|
||||
RUST_LOG: "${RUST_LOG:-info}"
|
||||
BASE_URL: "${BASE_URL:-https://bzo.in}"
|
||||
|
||||
volumes:
|
||||
|
||||
# Persistent application databases.
|
||||
- ${BZOD_ROOT}/data:/app/data
|
||||
|
||||
# Persistent application configuration.
|
||||
- ${BZOD_ROOT}/config:/app/config
|
||||
|
||||
# User-uploaded / application images.
|
||||
#
|
||||
# IMPORTANT:
|
||||
# /app/images is required by the image router.
|
||||
- ${BZOD_ROOT}/images:/app/images
|
||||
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"curl",
|
||||
"-fsS",
|
||||
"http://127.0.0.1:8654/status"
|
||||
]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
start_period: 10s
|
||||
retries: 3
|
||||
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
EOF
|
||||
|
||||
chmod 644 "${SYSTEMD_UNIT}"
|
||||
systemctl daemon-reload
|
||||
|
||||
# 7. Initialize & Start
|
||||
echo -e "\n${BLUE}[7/8] Initializing and starting service...${NC}"
|
||||
|
||||
# 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
|
||||
fi
|
||||
|
||||
BACKUP_DIR="/var/lib/bzod/pre-upgrade-backup-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}"
|
||||
# Append BZOD_ROOT because compose needs it.
|
||||
if ! grep -q '^BZOD_ROOT=' "${ENV_FILE}"; then
|
||||
echo "BZOD_ROOT=${BZOD_ROOT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
systemctl enable --now bzod
|
||||
# Port variable expected by compose.
|
||||
if ! grep -q '^PORT=' "${ENV_FILE}"; then
|
||||
echo "PORT=${BZOD_PORT}" >> "${ENV_FILE}"
|
||||
fi
|
||||
|
||||
# 8. Validation + Rollback
|
||||
sleep 3
|
||||
success "✓ Docker Compose configuration written"
|
||||
|
||||
# ============================================================
|
||||
# 5. Backup existing installation
|
||||
# ============================================================
|
||||
|
||||
info "[5/8] Creating pre-upgrade backup..."
|
||||
|
||||
TIMESTAMP="$(date '+%Y%m%d-%H%M%S')"
|
||||
BACKUP_DIR="${BACKUP_ROOT}/pre-upgrade-${TIMESTAMP}-v${BZOD_VERSION}"
|
||||
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
if [[ -d "${NX9_BZOD_DATA_DIR}" ]]; then
|
||||
cp -a "${NX9_BZOD_DATA_DIR}" "${BACKUP_DIR}/data"
|
||||
fi
|
||||
|
||||
if [[ -d "${CONFIG_DIR}" ]]; then
|
||||
cp -a "${CONFIG_DIR}" "${BACKUP_DIR}/config"
|
||||
fi
|
||||
|
||||
if [[ -d "${IMAGES_DIR}" ]]; then
|
||||
cp -a "${IMAGES_DIR}" "${BACKUP_DIR}/images"
|
||||
fi
|
||||
|
||||
cp -a "${COMPOSE_FILE}" "${BACKUP_DIR}/docker-compose.yml"
|
||||
cp -a "${ENV_FILE}" "${BACKUP_DIR}/bzod.env"
|
||||
|
||||
success "✓ Backup created:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
# ============================================================
|
||||
# 6. Pull new image
|
||||
# ============================================================
|
||||
|
||||
info "[6/8] Building BZOD ${BZOD_VERSION} image..."
|
||||
|
||||
# Build locally from the current deployment tree. The package/repository is
|
||||
# nx9-url-shortener; the application binary and container remain named bzod.
|
||||
if ! docker build \
|
||||
--tag "${IMAGE}" \
|
||||
--file "${BZOD_ROOT}/Dockerfile" \
|
||||
"${BZOD_ROOT}"; then
|
||||
die "Unable to build ${IMAGE}"
|
||||
fi
|
||||
|
||||
success "✓ Docker image built locally"
|
||||
|
||||
# ============================================================
|
||||
# 7. Deploy
|
||||
# ============================================================
|
||||
|
||||
info "[7/8] Deploying BZOD..."
|
||||
|
||||
cd "${COMPOSE_DIR}"
|
||||
|
||||
# Stop/remove the existing container through Compose.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
down \
|
||||
--remove-orphans
|
||||
|
||||
# Start the requested image.
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
up -d
|
||||
|
||||
success "✓ BZOD container started"
|
||||
|
||||
# ============================================================
|
||||
# 8. Validation
|
||||
# ============================================================
|
||||
|
||||
info "[8/8] Validating deployment..."
|
||||
|
||||
sleep 5
|
||||
|
||||
if ! docker inspect \
|
||||
--format '{{.State.Running}}' \
|
||||
"${CONTAINER_NAME}" 2>/dev/null | grep -q '^true$'; then
|
||||
|
||||
error "BZOD container failed to start."
|
||||
echo
|
||||
|
||||
docker compose \
|
||||
--env-file "${ENV_FILE}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
logs --tail=100
|
||||
|
||||
error
|
||||
error "Deployment failed. Existing data was not removed."
|
||||
error "Backup: ${BACKUP_DIR}"
|
||||
|
||||
if ! systemctl is-active --quiet bzod; then
|
||||
echo -e "${RED}Service failed to start! Rolling back...${NC}"
|
||||
if [ -f "${INSTALL_PATH}.bak" ]; then
|
||||
install -m 755 "${INSTALL_PATH}.bak" "${INSTALL_PATH}"
|
||||
systemctl restart bzod || true
|
||||
fi
|
||||
journalctl -u bzod -n 50 --no-pager
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Clean up backup on success
|
||||
rm -f "${INSTALL_PATH}.bak" 2>/dev/null || true
|
||||
success "✓ Container is running"
|
||||
|
||||
# Soft health check
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
if curl -fsS http://127.0.0.1:8654/status >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}✓ HTTP health check passed${NC}"
|
||||
else
|
||||
echo -e "${BLUE}✓ Service is running (systemd healthy)${NC}"
|
||||
# ------------------------------------------------------------
|
||||
# Health check
|
||||
# ------------------------------------------------------------
|
||||
|
||||
HEALTH_OK=0
|
||||
|
||||
for _ in {1..12}; do
|
||||
if curl -fsS \
|
||||
"http://127.0.0.1:${BZOD_PORT}/status" \
|
||||
>/dev/null 2>&1; then
|
||||
|
||||
HEALTH_OK=1
|
||||
break
|
||||
fi
|
||||
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "${HEALTH_OK}" -eq 1 ]]; then
|
||||
success "✓ HTTP health check passed"
|
||||
else
|
||||
warning "⚠ HTTP health check did not respond yet"
|
||||
warning "The container is running; inspect logs if necessary:"
|
||||
echo
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs --tail=100"
|
||||
fi
|
||||
|
||||
# Final Message
|
||||
IP=$(hostname -I | awk '{print $1}' | head -n1)
|
||||
echo -e "\n${GREEN}=== BZOD Deployed Successfully! ===${NC}"
|
||||
echo -e "🌐 Web UI: http://${IP}:8654"
|
||||
echo -e "🔑 Admin: http://${IP}:8654/admin"
|
||||
echo -e "🖥 Architecture: ${ARCH}"
|
||||
echo -e "📦 Version: ${VERSION}"
|
||||
echo -e "\nNext step (first install):"
|
||||
echo -e " sudo -u bzod bzod create-admin"
|
||||
echo -e "\nCommands:"
|
||||
echo -e " journalctl -u bzod -f"
|
||||
echo -e " bzod doctor"
|
||||
echo -e " systemctl status bzod"
|
||||
# ============================================================
|
||||
# Verify image and binary
|
||||
# ============================================================
|
||||
|
||||
echo -e "\n${GREEN}Enjoy your lightweight, privacy-first, self-hosted URL shortener!${NC}"
|
||||
echo
|
||||
info "Installed image:"
|
||||
docker image inspect "${IMAGE}" \
|
||||
--format ' {{.RepoTags}} ({{.Id}})' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Container:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format ' {{.Name}} {{.Config.Image}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
echo
|
||||
info "Persistent mounts:"
|
||||
docker inspect "${CONTAINER_NAME}" \
|
||||
--format '{{range .Mounts}} {{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' \
|
||||
2>/dev/null || true
|
||||
|
||||
# ============================================================
|
||||
# Final status
|
||||
# ============================================================
|
||||
|
||||
echo
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo -e "${GREEN} BZOD ${BZOD_VERSION} deployed successfully${NC}"
|
||||
echo -e "${GREEN}============================================================${NC}"
|
||||
echo
|
||||
|
||||
echo "Web UI:"
|
||||
echo " http://<server-ip>:${BZOD_PORT}"
|
||||
|
||||
echo
|
||||
echo "Persistent data:"
|
||||
echo " ${NX9_BZOD_DATA_DIR}"
|
||||
|
||||
echo
|
||||
echo "Persistent images:"
|
||||
echo " ${IMAGES_DIR}"
|
||||
|
||||
echo
|
||||
echo "Docker Compose:"
|
||||
echo " ${COMPOSE_FILE}"
|
||||
|
||||
echo
|
||||
echo "Backup:"
|
||||
echo " ${BACKUP_DIR}"
|
||||
|
||||
echo
|
||||
echo "Useful commands:"
|
||||
echo " docker compose -f ${COMPOSE_FILE} ps"
|
||||
echo " docker compose -f ${COMPOSE_FILE} logs -f bzod"
|
||||
echo " docker compose -f ${COMPOSE_FILE} restart bzod"
|
||||
|
||||
echo
|
||||
success "Deployment complete."
|
||||
@@ -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.6.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
|
||||
@@ -60,4 +92,4 @@ x-casaos:
|
||||
port_map: "8654"
|
||||
scheme: http
|
||||
title:
|
||||
custom: nx9-url-shortener
|
||||
custom: nx9-url-shortener
|
||||
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
if [ "$1" = 'serve' ]; then
|
||||
/usr/local/bin/bzod init-admin
|
||||
exec /usr/local/bin/bzod serve
|
||||
fi
|
||||
|
||||
exec /usr/local/bin/bzod "$@"
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Administrator Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Architecture Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Backup & Restore Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.8.0
|
||||
Applies To: BZOD Multi-User Platform
|
||||
|
||||
---
|
||||
|
||||
@@ -4,6 +4,76 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on Keep a Changelog and this project follows Semantic Versioning.
|
||||
|
||||
# v0.8.0 — Core Admin Separation, Tenant Boundary & Authentication Hardening
|
||||
|
||||
## Added
|
||||
|
||||
* Core Admin is strictly platform-operator-only and has no tenant application storage.
|
||||
* Inspection-only global URL and landing-page registries for Admin.
|
||||
* Strict Admin/tenant route boundary with HTTP 403 enforcement.
|
||||
* TenantId-based active ownership and tenant filesystem topology.
|
||||
* Tenant-aware analytics worker grouping.
|
||||
* Deterministic cross-role session invalidation and cookie clearing.
|
||||
|
||||
## Changed
|
||||
|
||||
* Removed active production dependencies on the legacy `system.db.global_slugs` registry.
|
||||
* Removed request-time TenantId generation and integer tenant filesystem fallbacks from active tenant operations.
|
||||
* Admin resource creation endpoints reject Core Admin actors with `403 Forbidden`.
|
||||
* User login and Admin login now establish role-specific sessions and clear the opposite-role session.
|
||||
|
||||
## Compatibility
|
||||
|
||||
* Historical v0.7.x migration and legacy restore compatibility remains preserved.
|
||||
* Legacy database/schema identifiers are retained only where required for migration and historical restore support.
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
## Added
|
||||
|
||||
### Responsive UI
|
||||
|
||||
* Responsive layouts for admin and user URL registry panels
|
||||
* Responsive layouts for admin and user landing page registry panels
|
||||
* Desktop, laptop, tablet, and mobile layout support
|
||||
* Table-to-card responsive behavior for registry panels
|
||||
* Resolved horizontal scrolling issues in registry panels
|
||||
|
||||
### Theme Support
|
||||
|
||||
* Dark/light theme toggle
|
||||
* Theme persistence across sessions
|
||||
* Responsive theme behavior across device sizes
|
||||
|
||||
### Build Metadata
|
||||
|
||||
* Introduced `build.rs` build script for compile-time metadata
|
||||
* Introduced `src/build_info.rs` module exposing `APP_VERSION` and `GIT_COMMIT`
|
||||
* Application version derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`
|
||||
* Git commit hash (12-char short) embedded at build time via `BZOD_GIT_COMMIT`
|
||||
* Graceful fallback to `"unknown"` when Git metadata is unavailable
|
||||
|
||||
### Public Landing Page
|
||||
|
||||
* Root `/` serves `www/index.html` with runtime file and embedded fallback behavior
|
||||
* Public/runtime www assets supported by the deployment layout
|
||||
|
||||
## Changed
|
||||
|
||||
* Documentation updated to reflect v0.7.0 current state
|
||||
* Version metadata updated across Cargo.toml, deploy.sh, and docker-compose.yml
|
||||
|
||||
## Notes
|
||||
|
||||
* No API behavior changes
|
||||
* No database schema changes
|
||||
* No authentication or security behavior changes
|
||||
* Existing redirect, routing, and tenant isolation behavior preserved
|
||||
|
||||
---
|
||||
|
||||
# v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
@@ -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.6.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.6.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.6.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:
|
||||
@@ -190,13 +204,13 @@ 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 |
|
||||
| COOKIE_SECURE | Secure cookies | false |
|
||||
| RUST_LOG | Logging level | info |
|
||||
| Variable | Description | Default |
|
||||
| ----------------- | ------------------ | ------------- |
|
||||
| HOST | Bind address | 0.0.0.0 |
|
||||
| PORT | Listen port | 8654 |
|
||||
| NX9_BZOD_DATA_DIR | Database directory | (required) |
|
||||
| COOKIE_SECURE | Secure cookies | false |
|
||||
| RUST_LOG | Logging level | info |
|
||||
|
||||
Production recommendation:
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Installation Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
@@ -183,13 +183,13 @@ sudo pacman -S \
|
||||
Example:
|
||||
|
||||
```bash
|
||||
wget https://example.com/bzod-v0.6.0-linux-amd64.tar.gz
|
||||
wget https://example.com/bzod-v0.8.0-linux-amd64.tar.gz
|
||||
```
|
||||
|
||||
Extract:
|
||||
|
||||
```bash
|
||||
tar -xzf bzod-v0.6.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.6.0
|
||||
Version: v0.8.0
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,76 @@
|
||||
# BZOD v0.8.0 — Multi-Tenant Core Separation & Authorization Hardening
|
||||
|
||||
Release Date: 2026-08-21
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Core Admin separation**: Admin is a platform operator, not a tenant and not an application resource owner.
|
||||
- **Global slug registries**: Active URL and landing-page ownership uses `slugs/global_urls.db` and `slugs/global_landing_pages.db`.
|
||||
- **Strict route boundary**: Core Admin is forbidden from `/user/*`; normal tenant users are forbidden from `/admin/*`.
|
||||
- **Capability enforcement**: Admin resource creation through UI and REST/bulk endpoints returns `403 Forbidden`.
|
||||
- **Tenant identity hardening**: Active tenant operations require an immutable `TenantId`; no request-time fallback generation or `users/1` application fallback.
|
||||
- **Session hygiene**: Admin/user session cookies and server-side sessions are invalidated when switching principals or logging out.
|
||||
- **Tenant-aware analytics**: Analytics events are grouped and persisted by `TenantId`.
|
||||
- **Legacy compatibility preserved**: Legacy migration and restore paths remain available without being active production paths.
|
||||
|
||||
## Verification
|
||||
|
||||
- Phase 5 Core Separation tests: **4/4 passed**
|
||||
- Admin capability boundary tests: **11/11 passed**
|
||||
- Admin/user route and session boundary tests: **27/27 passed**
|
||||
- Phase 6A elimination tests: **6/6 passed**
|
||||
- Workspace regression suite: **all tests passed**
|
||||
- `cargo fmt --all -- --check`: **PASS**
|
||||
- `cargo clippy --workspace --all-targets --all-features -- -D warnings`: **PASS**
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.7.0 — Responsive UI, Theme Support & Build Metadata
|
||||
|
||||
Release Date: 2026-08-11
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Responsive UI**: Admin and user registry panels (URLs and landing pages) now adapt across desktop, laptop, tablet, and mobile viewports. Tables switch to card layouts on smaller screens, and horizontal scrolling issues in registry panels have been resolved.
|
||||
|
||||
- **Dark/Light Theme Support**: A dark/light theme toggle has been implemented with theme persistence across sessions and responsive behavior across device sizes.
|
||||
|
||||
- **Build Metadata**: The application now exposes its actual Cargo package version and, when available, the Git commit hash. This is powered by `build.rs` (compile-time Git commit extraction) and `src/build_info.rs` (exposing `APP_VERSION` and `GIT_COMMIT` constants). The version is derived from `Cargo.toml` via `env!("CARGO_PKG_VERSION")`, and the Git commit hash falls back gracefully to `"unknown"` when unavailable.
|
||||
|
||||
- **Public Landing Page Serving**: Root `/` now serves the public `www/index.html` with runtime file detection and embedded fallback behavior. Public/runtime www assets are supported by the deployment layout.
|
||||
|
||||
## User-Visible Changes
|
||||
|
||||
- Admin URL registry panel is responsive across all device sizes
|
||||
- Admin landing page registry panel is responsive across all device sizes
|
||||
- User URL registry panel is responsive across all device sizes
|
||||
- User landing page registry panel is responsive across all device sizes
|
||||
- Dark/light theme toggle available in the UI
|
||||
- Theme preference persists across sessions
|
||||
- Registry tables switch to card layouts on tablet and mobile viewports
|
||||
- Horizontal scrolling eliminated from registry panels
|
||||
|
||||
## Technical Changes
|
||||
|
||||
- Introduced `build.rs` build script for compile-time metadata extraction
|
||||
- Introduced `src/build_info.rs` module with `APP_VERSION` and `GIT_COMMIT` constants
|
||||
- Version and Git commit now available to system status and admin settings endpoints
|
||||
- Root `/` serves `www/index.html` with runtime/embedded fallback
|
||||
|
||||
## Breaking Changes
|
||||
|
||||
None.
|
||||
|
||||
## Upgrade Notes
|
||||
|
||||
- Direct upgrade from v0.6.0 with no migration required
|
||||
- No database schema changes
|
||||
- No API changes
|
||||
- No configuration changes
|
||||
- No breaking changes to existing functionality
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.6.0 — Legacy Restore Compatibility & Version Reporting
|
||||
|
||||
Release Date: 2026-08-09
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# BZOD Security Guide
|
||||
|
||||
Version: v0.6.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.6.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.6.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.6.0 provides:
|
||||
BZOD v0.8.0 provides:
|
||||
|
||||
* Centralized authentication
|
||||
* Secure session management
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
# Upgrade Guide
|
||||
|
||||
Version: v0.6.0
|
||||
Version: v0.8.0
|
||||
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.5.1.
|
||||
This document describes the upgrade process for existing BZOD deployments upgrading to BZOD v0.8.0.
|
||||
|
||||
---
|
||||
|
||||
# BZOD v0.8.0 Upgrade Overview
|
||||
|
||||
BZOD v0.8.0 completes the TenantId-based multi-tenant topology and separates Core Admin from tenant application resources. The active runtime uses the Core databases under `admin/`, global slug registries under `slugs/`, and tenant databases under `users/<TenantId>/`.
|
||||
|
||||
Key upgrade characteristics:
|
||||
|
||||
* Core Admin has no tenant directory, `content.db`, or `analytics.db`.
|
||||
* Active production operations no longer use `system.db.global_slugs`.
|
||||
* Active tenant ownership is represented by immutable `TenantId`.
|
||||
* Legacy integer IDs and legacy slug data remain available only to migration/restore compatibility paths.
|
||||
* Existing legacy deployments should use the repository's migration and restore commands rather than manually copying legacy tenant directories into the v0.8 topology.
|
||||
|
||||
# Overview
|
||||
|
||||
BZOD v0.5.1 is a platform hardening release focused on:
|
||||
|
||||
|
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)));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const GIT_COMMIT: &str = match option_env!("BZOD_GIT_COMMIT") {
|
||||
Some(commit) => commit,
|
||||
None => "unknown",
|
||||
};
|
||||
@@ -44,7 +44,8 @@ pub async fn run(
|
||||
}
|
||||
|
||||
// 2. Open databases
|
||||
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,36 +27,62 @@ 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()
|
||||
};
|
||||
// 1. Retire/cleanup slugs from v0.8 global_urls.db and global_landing_pages.db
|
||||
let now = Utc::now().to_rfc3339();
|
||||
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();
|
||||
|
||||
// Delete from global_slugs and write to history
|
||||
let now = Utc::now().to_rfc3339();
|
||||
for slug in slugs {
|
||||
let _ = tx.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&slug]);
|
||||
let _ = tx.execute(
|
||||
// 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());
|
||||
if user_dir.exists() {
|
||||
let _ = std::fs::remove_dir_all(&user_dir);
|
||||
// 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());
|
||||
|
||||
if !true_orphans.is_empty() {
|
||||
println!("\nThe following orphaned entries would be repaired/removed:");
|
||||
for issue in &true_orphans {
|
||||
println!(
|
||||
" {} [{}] — Tenant: {}",
|
||||
issue.slug,
|
||||
issue.target_type.to_uppercase(),
|
||||
issue.owner_tenant_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("\nNo changes have been made.");
|
||||
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!(
|
||||
"\nRun again with:\n\n bzod repair registry --force{}",
|
||||
"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 &repairable {
|
||||
if issue.target_type == "url" {
|
||||
removed_count += tx_urls
|
||||
.execute("DELETE FROM global_urls WHERE slug = ?1;", [&issue.slug])?;
|
||||
} else {
|
||||
removed_count += tx_pages.execute(
|
||||
"DELETE FROM global_landing_pages WHERE slug = ?1;",
|
||||
[&issue.slug],
|
||||
)?;
|
||||
}
|
||||
}
|
||||
|
||||
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_urls.commit()?;
|
||||
tx_pages.commit()?;
|
||||
|
||||
tx.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
|
||||
)),
|
||||
);
|
||||
|
||||
if slug_filter.is_some() {
|
||||
println!("Removed:\n\nSUCCESS");
|
||||
} else {
|
||||
println!("Repair Complete\n");
|
||||
println!("Removed:\n");
|
||||
println!("Pages:\n {}\n", orphaned_pages);
|
||||
println!("URLs:\n {}\n", orphaned_urls);
|
||||
|
||||
let remaining: i64 =
|
||||
sys_conn
|
||||
.query_row("SELECT COUNT(*) FROM global_slugs;", [], |r| r.get(0))?;
|
||||
println!("Remaining Registry Entries:\n {}\n", remaining);
|
||||
|
||||
let post_issues =
|
||||
RegistryValidator::scan(&sys_conn, &usr_conn, &config.data_dir, None)?;
|
||||
let post_orphaned = post_issues
|
||||
.iter()
|
||||
.filter(|i| {
|
||||
matches!(
|
||||
i.issue_type,
|
||||
RegistryIssueType::MissingTarget
|
||||
| RegistryIssueType::MissingDatabase
|
||||
| RegistryIssueType::MissingOwner
|
||||
)
|
||||
})
|
||||
.count();
|
||||
|
||||
println!(
|
||||
"Integrity:\n {}",
|
||||
if post_orphaned == 0 { "PASS" } else { "FAIL" }
|
||||
);
|
||||
}
|
||||
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,
|
||||
// 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
|
||||
crate::db::users::reconcile_user_quotas(
|
||||
&db.users.lock().unwrap(),
|
||||
target_user_id,
|
||||
&dest_dir.join("content.db"),
|
||||
&restored_content_conn,
|
||||
)?;
|
||||
}
|
||||
|
||||
// 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,71 +114,84 @@ 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) {
|
||||
if let Ok(toml_config) = toml::from_str::<TomlConfig>(&toml_content) {
|
||||
if let Some(h) = toml_config.host {
|
||||
host = h;
|
||||
}
|
||||
if let Some(p) = toml_config.port {
|
||||
port = p;
|
||||
}
|
||||
if let Some(d) = toml_config.data_dir {
|
||||
data_dir = PathBuf::from(d);
|
||||
}
|
||||
if let Some(u) = toml_config.admin_username {
|
||||
admin_username = u;
|
||||
}
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Some(sec) = toml_config.session_secret {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Some(c) = toml_config.cookie_secure {
|
||||
cookie_secure = c;
|
||||
}
|
||||
if let Some(ret) = toml_config.data_retention_days {
|
||||
if ret.eq_ignore_ascii_case("unlimited") {
|
||||
data_retention_days = None;
|
||||
} else if let Ok(parsed) = ret.parse::<i64>() {
|
||||
data_retention_days = Some(parsed);
|
||||
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;
|
||||
}
|
||||
}
|
||||
if let Some(lc) = toml_config.link_check_interval_mins {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
if let Some(b) = toml_config.backup {
|
||||
if let Some(be) = b.enabled {
|
||||
backup_enabled = be;
|
||||
if let Some(p) = toml_config.port {
|
||||
port = p;
|
||||
}
|
||||
if let Some(bi) = b.interval_mins {
|
||||
backup_interval_mins = bi;
|
||||
if let Some(d) = toml_config.data_dir {
|
||||
if !d.trim().is_empty() {
|
||||
resolved_data_dir = Some(PathBuf::from(d));
|
||||
data_dir_source = Some(DataDirSource::ConfigFile);
|
||||
}
|
||||
}
|
||||
if let Some(bo) = b.out_dir {
|
||||
backup_dir = PathBuf::from(bo);
|
||||
if let Some(u) = toml_config.admin_username {
|
||||
admin_username = u;
|
||||
}
|
||||
}
|
||||
if let Some(bu) = toml_config.base_url {
|
||||
base_url = Some(bu);
|
||||
if let Some(s) = toml_config.bootstrap_password_sha256 {
|
||||
bootstrap_password_sha256 = s;
|
||||
}
|
||||
if let Some(sec) = toml_config.session_secret {
|
||||
session_secret = sec;
|
||||
}
|
||||
if let Some(c) = toml_config.cookie_secure {
|
||||
cookie_secure = c;
|
||||
}
|
||||
if let Some(ret) = toml_config.data_retention_days {
|
||||
if ret.eq_ignore_ascii_case("unlimited") {
|
||||
data_retention_days = None;
|
||||
} else if let Ok(parsed) = ret.parse::<i64>() {
|
||||
data_retention_days = Some(parsed);
|
||||
}
|
||||
}
|
||||
if let Some(lc) = toml_config.link_check_interval_mins {
|
||||
link_check_interval_mins = lc;
|
||||
}
|
||||
if let Some(ag) = toml_config.aggregation_interval_mins {
|
||||
aggregation_interval_mins = ag;
|
||||
}
|
||||
if let Some(b) = toml_config.backup {
|
||||
if let Some(be) = b.enabled {
|
||||
backup_enabled = be;
|
||||
}
|
||||
if let Some(bi) = b.interval_mins {
|
||||
backup_interval_mins = bi;
|
||||
}
|
||||
if let Some(bo) = b.out_dir {
|
||||
backup_dir = PathBuf::from(bo);
|
||||
}
|
||||
}
|
||||
if let Some(bu) = toml_config.base_url {
|
||||
base_url = Some(bu);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Load .env if present
|
||||
let _ = dotenvy::dotenv();
|
||||
if fs::metadata("config/.env").is_ok() {
|
||||
let _ = dotenvy::from_path("config/.env");
|
||||
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,212 +174,60 @@ 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) {
|
||||
Ok(count) => {
|
||||
if count > 0 {
|
||||
tracing::info!("Cleaned up {} stale reserving slugs", count);
|
||||
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 from v0.8 registry",
|
||||
count
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to clean up stale reservations: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 clean up stale reservations: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to verify global registry integrity: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_else(|| topology.legacy_flat_content_db())
|
||||
} else if owner_user_id == 1 {
|
||||
topology.legacy_flat_content_db()
|
||||
} else {
|
||||
data_dir.join("content.db")
|
||||
topology
|
||||
.content_db_i64(owner_user_id)
|
||||
.unwrap_or_else(|_| topology.legacy_flat_content_db())
|
||||
}
|
||||
} else {
|
||||
data_dir
|
||||
.join("users")
|
||||
.join(owner_user_id.to_string())
|
||||
.join("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,30 +65,48 @@ 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();
|
||||
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);", []);
|
||||
}
|
||||
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
|
||||
let _ = u_conn.execute("PRAGMA wal_checkpoint(TRUNCATE);", []);
|
||||
}
|
||||
}
|
||||
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);", []);
|
||||
}
|
||||
if let Ok(u_conn) = crate::jobs::open_user_analytics_conn(db, user_id) {
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
pub mod analytics;
|
||||
pub mod auth;
|
||||
pub mod build_info;
|
||||
pub mod charts;
|
||||
pub mod cli;
|
||||
pub mod config;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod jobs;
|
||||
pub mod models;
|
||||
pub mod services;
|
||||
|
||||
@@ -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)
|
||||
&& !reserved_slugs.contains(&code);
|
||||
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 {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_user_id: 0,
|
||||
database_path: data_dir.join("admin/system.db"),
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::DuplicateSlug,
|
||||
description: format!(
|
||||
"Duplicate slugs found in global_slugs table (total rows: {}, distinct slugs: {})",
|
||||
total_count, distinct_count
|
||||
),
|
||||
});
|
||||
}
|
||||
let urls_path = topology.global_urls_db();
|
||||
let pages_path = topology.global_landing_pages_db();
|
||||
let reserved_path = topology.reserved_db();
|
||||
|
||||
if !urls_path.exists() || !pages_path.exists() || !reserved_path.exists() {
|
||||
issues.push(RegistryIssue {
|
||||
slug: "*".to_string(),
|
||||
target_type: "system".to_string(),
|
||||
owner_tenant_id: "".to_string(),
|
||||
database_path: urls_path,
|
||||
target_id: "".to_string(),
|
||||
issue_type: RegistryIssueType::AccessFailure,
|
||||
description: "One or more v0.8 slug databases are missing from disk".to_string(),
|
||||
});
|
||||
return Ok(issues);
|
||||
}
|
||||
|
||||
// 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(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else if target_type == "page" {
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM urls WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
tenant_conn
|
||||
.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM landing_pages WHERE id = ?1);",
|
||||
[&target_id],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
if !exists {
|
||||
issues.push(RegistryIssue {
|
||||
slug: slug.clone(),
|
||||
target_type: target_type.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> {
|
||||
self.get_user_dbs_with_mode(user_id, TenantOpenMode::PublicContent)
|
||||
}
|
||||
|
||||
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")?;
|
||||
if let Some(dbs) = pool.get(&user_id) {
|
||||
return Ok(dbs.clone());
|
||||
crate::db::tenant::open_for_row_id(
|
||||
&users,
|
||||
&self.db.topology,
|
||||
&self.system_db,
|
||||
&mut pool,
|
||||
user_id,
|
||||
mode,
|
||||
)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
)
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
// 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)?;
|
||||
// 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);
|
||||
|
||||
let content_path = user_dir.join("content.db");
|
||||
let analytics_path = user_dir.join("analytics.db");
|
||||
let profile_path = user_dir.join("profile.db");
|
||||
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()?;
|
||||
|
||||
let mut content_conn = Connection::open(content_path)?;
|
||||
let mut analytics_conn = Connection::open(analytics_path)?;
|
||||
let profile_conn = Connection::open(profile_path)?;
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
crate::db::sqlite::enable_wal(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_wal(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_wal(&profile_conn, "profile")?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
crate::db::sqlite::enable_foreign_keys(&content_conn, "content")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&analytics_conn, "analytics")?;
|
||||
crate::db::sqlite::enable_foreign_keys(&profile_conn, "profile")?;
|
||||
|
||||
// Run migrations
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut content_conn,
|
||||
"content",
|
||||
crate::db::migrations::CONTENT_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
)
|
||||
.map_err(|e| crate::error::AppError::Internal(e.to_string()))?;
|
||||
crate::db::migrations::run_migrations(
|
||||
&mut analytics_conn,
|
||||
"analytics",
|
||||
crate::db::migrations::ANALYTICS_MIGRATIONS,
|
||||
Some(&self.system_db),
|
||||
)
|
||||
.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
|
||||
);",
|
||||
)?;
|
||||
|
||||
let dbs = UserDbs {
|
||||
content: Arc::new(Mutex::new(content_conn)),
|
||||
analytics: Arc::new(Mutex::new(analytics_conn)),
|
||||
profile: Arc::new(Mutex::new(profile_conn)),
|
||||
};
|
||||
|
||||
pool.insert(user_id, dbs.clone());
|
||||
Ok(dbs)
|
||||
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 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
|
||||
);
|
||||
let created_user_res = {
|
||||
let conn = match state.users_db.lock() {
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/login?error=Internal error").into_response();
|
||||
}
|
||||
};
|
||||
crate::db::users::create_admin_user(&conn, &form.username, &hash)
|
||||
};
|
||||
|
||||
match created_user_res {
|
||||
Ok(u) => {
|
||||
if let Ok(system_conn) = state.system_db.lock() {
|
||||
let metadata = audit_meta(&ip, &headers);
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
@@ -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,23 +451,51 @@ pub async fn public_login_post(
|
||||
|
||||
let secure_flag =
|
||||
crate::utils::resolve_cookie_secure(state.config.cookie_secure, &headers);
|
||||
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
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 mut response_jar = jar.clone();
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = clear_user_cookie(response_jar);
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/admin/dashboard")).into_response()
|
||||
} else {
|
||||
if user.tenant_id.is_none() {
|
||||
return Redirect::to("/login?error=Invalid tenant configuration")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let cookie = Cookie::build(("bzod_user_session", session_token))
|
||||
.path("/")
|
||||
.secure(secure_flag)
|
||||
.http_only(true)
|
||||
.same_site(axum_extra::extract::cookie::SameSite::Strict)
|
||||
.max_age(time::Duration::days(30))
|
||||
.build();
|
||||
|
||||
let clear_temp = Cookie::build("bzod_temp_csrf")
|
||||
.path("/login")
|
||||
.max_age(time::Duration::ZERO)
|
||||
.build();
|
||||
|
||||
let mut response_jar = jar.clone();
|
||||
response_jar = clear_admin_cookie(response_jar);
|
||||
response_jar = response_jar.add(cookie).add(clear_temp);
|
||||
|
||||
(response_jar, Redirect::to("/user/dashboard")).into_response()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let system_conn = state.system_db.lock().unwrap();
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
}
|
||||
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 user or redirect to login
|
||||
|
||||
// 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();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
if action == "deleted" {
|
||||
let _ = system_conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
// 1. Update v0.8 slug database
|
||||
if action == "deleted" {
|
||||
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,48 +332,130 @@ 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();
|
||||
let mut values = vec![];
|
||||
if let Some(ref s) = query.search {
|
||||
if !s.trim().is_empty() {
|
||||
sql.push_str(" AND slug LIKE ?");
|
||||
values.push(rusqlite::types::Value::Text(format!("%{}%", s.trim())));
|
||||
}
|
||||
}
|
||||
if let Some(o) = query.owner {
|
||||
sql.push_str(" AND owner_user_id = ?");
|
||||
values.push(rusqlite::types::Value::Integer(o));
|
||||
}
|
||||
if let Some(ref st) = query.status {
|
||||
if !st.trim().is_empty() {
|
||||
sql.push_str(" AND status = ?");
|
||||
values.push(rusqlite::types::Value::Text(st.trim().to_string()));
|
||||
}
|
||||
}
|
||||
sql.push_str(" ORDER BY created_at DESC LIMIT 100;");
|
||||
// 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![];
|
||||
|
||||
let slugs = {
|
||||
let mut stmt = system_conn.prepare(&sql).unwrap();
|
||||
let rows = stmt
|
||||
.query_map(rusqlite::params_from_iter(values.iter()), |row| {
|
||||
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 = 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();
|
||||
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(),
|
||||
Err(e) => Redirect::to(&format!("/admin/slugs?error={}", e.message())).into_response(),
|
||||
}
|
||||
|
||||
let (old_owner, target_type, mut new_target_id) =
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let row_opt: Option<(i64, String, String)> = conn.query_row(
|
||||
"SELECT owner_user_id, target_type, target_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
).optional().unwrap_or(None);
|
||||
match row_opt {
|
||||
Some(r) => r,
|
||||
None => return Redirect::to("/admin/slugs?error=Slug not found").into_response(),
|
||||
}
|
||||
};
|
||||
|
||||
if old_owner == form.new_owner_user_id {
|
||||
return Redirect::to("/admin/slugs?error=Slug is already owned by this user")
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let old_dbs = match state.get_user_dbs(old_owner) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load current owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
let new_dbs = match state.get_user_dbs(form.new_owner_user_id) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(_) => {
|
||||
return Redirect::to("/admin/slugs?error=Failed to load new owner's database")
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let old_conn = old_dbs.content.lock().unwrap();
|
||||
let new_conn = new_dbs.content.lock().unwrap();
|
||||
|
||||
if target_type == "url" {
|
||||
let url_opt = match crate::db::content::get_url_by_code(&old_conn, &form.slug) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old URL: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(url) = url_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_urls >= quota.max_urls {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded URL quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_url = match crate::db::content::create_url_extended(
|
||||
&new_conn,
|
||||
&url.code,
|
||||
&url.destination,
|
||||
url.title.as_deref(),
|
||||
url.description.as_deref(),
|
||||
&url.tags,
|
||||
url.expires_at.as_deref(),
|
||||
url.password_hash.as_deref(),
|
||||
url.max_access_count,
|
||||
) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy URL record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_url.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_url(&old_conn, &url.id);
|
||||
}
|
||||
} else if target_type == "page" {
|
||||
let page_opt = match crate::db::content::get_landing_page_by_code(&old_conn, &form.slug)
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to retrieve old page: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(page) = page_opt {
|
||||
// Check quota
|
||||
let users_conn = state.users_db.lock().unwrap();
|
||||
let quota_opt =
|
||||
crate::db::users::get_user_quotas(&users_conn, form.new_owner_user_id)
|
||||
.unwrap_or(None);
|
||||
if let Some(quota) = quota_opt {
|
||||
if quota.current_landings >= quota.max_landings {
|
||||
return Redirect::to(
|
||||
"/admin/slugs?error=New owner has exceeded landing page quota limit",
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
// Copy
|
||||
let new_page = match crate::db::content::create_landing_page(
|
||||
&new_conn,
|
||||
&page.code,
|
||||
&page.slug,
|
||||
&page.title,
|
||||
&page.html_content,
|
||||
&page.state,
|
||||
) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/slugs?error=Failed to copy page record: {}",
|
||||
e
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
};
|
||||
new_target_id = new_page.id;
|
||||
|
||||
// Delete old
|
||||
let _ = crate::db::content::delete_landing_page(&old_conn, &page.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let _ = conn.execute(
|
||||
"UPDATE global_slugs SET owner_user_id = ?1, target_id = ?2, updated_at = ?3 WHERE slug = ?4;",
|
||||
rusqlite::params![form.new_owner_user_id, new_target_id, now, form.slug],
|
||||
);
|
||||
|
||||
let _ = conn.execute(
|
||||
"INSERT INTO slug_history (slug, old_owner_user_id, new_owner_user_id, action, timestamp, admin_username) \
|
||||
VALUES (?1, ?2, ?3, 'transferred', ?4, ?5);",
|
||||
rusqlite::params![form.slug, old_owner, form.new_owner_user_id, now, user.username],
|
||||
);
|
||||
|
||||
let _ = crate::db::audit_events::write_audit_event(
|
||||
&conn,
|
||||
&user.username,
|
||||
"SLUG_TRANSFER",
|
||||
"slug",
|
||||
&form.slug,
|
||||
Some(&format!(
|
||||
"Transferred from {} to {}",
|
||||
old_owner, form.new_owner_user_id
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Redirect::to(&format!(
|
||||
"/admin/slugs?success=Slug /{} successfully transferred to user {}",
|
||||
form.slug, form.new_owner_user_id
|
||||
))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -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 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();
|
||||
|
||||
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],
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let conn = state.system_db.lock().unwrap();
|
||||
let now = Utc::now().to_rfc3339();
|
||||
|
||||
let old_owner_user_id: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT owner_user_id FROM global_slugs WHERE slug = ?1;",
|
||||
[&form.slug],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.unwrap_or(None);
|
||||
|
||||
let _ = conn.execute("DELETE FROM global_slugs WHERE slug = ?1;", [&form.slug]);
|
||||
|
||||
let _ = conn.execute(
|
||||
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,22 +118,27 @@ 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))
|
||||
.into_response();
|
||||
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",
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin is a platform operator and cannot create unowned application pages; create landing pages via a tenant user account",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/pages/delete/:id
|
||||
@@ -460,25 +426,42 @@ 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(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
Err(e) => Redirect::to(&format!("/admin/pages?error=Failed to delete page: {}", e))
|
||||
.into_response(),
|
||||
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(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"PAGE_DELETION",
|
||||
Some("page"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/pages").into_response()
|
||||
}
|
||||
@@ -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();
|
||||
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)
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
} else {
|
||||
Err("Backup is missing content.db".into())
|
||||
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() {
|
||||
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 {
|
||||
"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,
|
||||
@@ -773,8 +893,8 @@ pub async fn status_get(State(state): State<AppState>, jar: CookieJar) -> Respon
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
@@ -825,8 +945,8 @@ pub async fn user_status_get(State(state): State<AppState>, jar: CookieJar) -> R
|
||||
queue_size,
|
||||
memory_usage,
|
||||
uptime,
|
||||
version: "0.1.0",
|
||||
git_commit: "unknown",
|
||||
version: crate::build_info::APP_VERSION,
|
||||
git_commit: crate::build_info::GIT_COMMIT,
|
||||
urls,
|
||||
};
|
||||
|
||||
@@ -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,21 +153,27 @@ 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))
|
||||
.into_response();
|
||||
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",
|
||||
)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
(
|
||||
StatusCode::FORBIDDEN,
|
||||
"Admin is a platform operator and cannot create unowned application URLs; create links via a tenant user account",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
// POST /admin/urls/delete/:id
|
||||
@@ -614,26 +549,42 @@ 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(_) => {
|
||||
{
|
||||
let conn_admin = state.admin_db.lock().unwrap();
|
||||
let _ = write_audit_log(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
// 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);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
Err(e) => {
|
||||
Redirect::to(&format!("/admin/urls?error=Failed to delete link: {}", e)).into_response()
|
||||
}
|
||||
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(
|
||||
&conn_admin,
|
||||
&state,
|
||||
&user.username,
|
||||
"URL_DELETION",
|
||||
Some("url"),
|
||||
Some(&id),
|
||||
Some(&ip),
|
||||
headers.get("user-agent").and_then(|h| h.to_str().ok()),
|
||||
);
|
||||
}
|
||||
Redirect::to("/admin/urls").into_response()
|
||||
}
|
||||
@@ -133,12 +133,14 @@ pub async fn users_create_post(
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = state.db.init_user_databases(new_user.id) {
|
||||
return Redirect::to(&format!(
|
||||
"/admin/users?error=Failed to initialize user databases: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
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: {}",
|
||||
e
|
||||
))
|
||||
.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
@@ -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()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::BadRequest(m)) => {
|
||||
(StatusCode::BAD_REQUEST, m).into_response()
|
||||
}
|
||||
Err(crate::services::slug_transfer::TransferError::Internal(m)) => {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, m).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()
|
||||
}
|
||||
};
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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,10 +42,11 @@ 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) {
|
||||
Ok(dbs) => dbs.content,
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
};
|
||||
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(),
|
||||
};
|
||||
|
||||
let page_opt = {
|
||||
let conn = content_conn.lock().unwrap();
|
||||
@@ -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),
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "URL not found").into_response(),
|
||||
Err(_) => {
|
||||
return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response()
|
||||
}
|
||||
}
|
||||
// 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(),
|
||||
};
|
||||
|
||||
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()
|
||||
{
|
||||
Ok(Some(info)) => info,
|
||||
Ok(None) => return (StatusCode::NOT_FOUND, "Not Found").into_response(),
|
||||
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "Database error").into_response(),
|
||||
}
|
||||
// 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()
|
||||
{
|
||||
Ok(info) => info,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "query_global_slugs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: None,
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
// 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: "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,22 +160,23 @@ 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) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "get_user_dbs",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
let content_conn =
|
||||
match state.open_slug_owner(&info.owner_tenant_id, TenantOpenMode::PublicContent) {
|
||||
Ok(dbs) => dbs,
|
||||
Err(e) => {
|
||||
return ResolveOutcome::DbError {
|
||||
operation: "open_slug_owner",
|
||||
message: e.to_string(),
|
||||
owner_user_id: Some(owner_user_id),
|
||||
resource_id: None,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let url = {
|
||||
let conn = match content_conn.content.lock() {
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||